From 3a8693f380acbc8cf06178a864dcb45737803e68 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Sat, 6 Jun 2026 19:51:35 +0800 Subject: [PATCH] feat(cursor): migrate remote sessions to ACP with model/variant pickers (#799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers Move stream-json remote launcher to legacy path and add ACP launcher with set_config_option model/mode sync, optimistic keepalive on config changes, and shared catalog caching. Web gets dual base/effort Cursor pickers for session and new-session flows; hide composer status bar when Cursor sends no usage_update. Co-authored-by: Cursor * fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants Enrich the web/mobile picker with agent --list-models SKUs grouped under ACP wire bases, fix session-open base highlight, and keep catalog discovery safe while the ACP transport holds the CLI lock. Co-authored-by: Cursor * fix(cursor-acp): apply ACP default model when web resets to Default Web sends model: null for Default; push session/set_config_option with the ACP default[] wire so Cursor backend matches hub state. Regression tests for setModel(null) and applyModelConfig(null). Co-authored-by: Cursor * fix(acp): clear stale agent-acp lock when owning process is gone Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or crash so listCursorModels can run cold probes again. Regression tests for guard and catalog discovery. Co-authored-by: Cursor * test(cursor): use live pid for ACP lock handler tests Stale-lock cleanup clears dead pids; handler tests must simulate an active lock with the current process pid to avoid cold probes/timeouts. Co-authored-by: Cursor * fix(acp): scope agent CLI lock guard to Cursor agent command only Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active; that blocked listCursorModels while unrelated backends were running. Co-authored-by: Cursor * fix(hub,web): reject Cursor model changes for local sessions Hub returns 409 when controlledByUser is set, matching Codex. Web hides model and variant pickers for local Cursor sessions so users do not hit a dead RPC path. Document pre-push-review in AGENTS.md. Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared). Co-authored-by: Cursor * fix(web): send stable ids for Cursor ask_question replies Parse and submit question.id and option.id so ACP receives keys like { approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- AGENTS.md | 8 + .../agent/backends/acp/AcpSdkBackend.test.ts | 224 +++++- cli/src/agent/backends/acp/AcpSdkBackend.ts | 264 +++++++- .../backends/acp/AcpStdioTransport.test.ts | 66 ++ .../agent/backends/acp/AcpStdioTransport.ts | 20 + .../agent/backends/acp/agentCliGuard.test.ts | 66 ++ cli/src/agent/backends/acp/agentCliGuard.ts | 104 +++ cli/src/agent/backends/acp/constants.ts | 3 +- cli/src/agent/permissionAdapter.test.ts | 106 ++- cli/src/agent/permissionAdapter.ts | 9 +- cli/src/agent/sessionBase.ts | 8 +- cli/src/agent/sessionFactory.test.ts | 2 + cli/src/agent/sessionFactory.ts | 1 + cli/src/claude/claudeRemote.seam.test.ts | 2 +- cli/src/commands/cursor.ts | 2 +- .../cursor/cursorAcpRemoteLauncher.test.ts | 636 ++++++++++++++++++ cli/src/cursor/cursorAcpRemoteLauncher.ts | 461 +++++++++++++ .../cursor/cursorLegacyRemoteLauncher.test.ts | 106 +++ cli/src/cursor/cursorLegacyRemoteLauncher.ts | 270 ++++++++ cli/src/cursor/cursorLocal.ts | 2 +- cli/src/cursor/cursorLocalLauncher.ts | 5 +- cli/src/cursor/cursorRemoteLauncher.test.ts | 103 +++ cli/src/cursor/cursorRemoteLauncher.ts | 285 +------- cli/src/cursor/cursorSpecialCommands.test.ts | 44 +- cli/src/cursor/cursorSpecialCommands.ts | 55 +- cli/src/cursor/cursorUserMessageQueue.test.ts | 32 +- cli/src/cursor/loop.ts | 4 +- cli/src/cursor/runCursor.ts | 68 +- cli/src/cursor/session.test.ts | 41 ++ cli/src/cursor/session.ts | 31 +- cli/src/cursor/utils/cursorAcpBackend.test.ts | 43 ++ cli/src/cursor/utils/cursorAcpBackend.ts | 33 + cli/src/cursor/utils/cursorAcpModelsBridge.ts | 16 + .../utils/cursorAcpModelsSnapshot.test.ts | 50 ++ .../cursor/utils/cursorAcpModelsSnapshot.ts | 69 ++ .../utils/cursorExtensionAdapter.test.ts | 190 ++++++ .../cursor/utils/cursorExtensionAdapter.ts | 256 +++++++ ....ts => cursorLegacyEventConverter.test.ts} | 4 +- ...erter.ts => cursorLegacyEventConverter.ts} | 0 cli/src/cursor/utils/cursorModeConfig.test.ts | 267 ++++++++ cli/src/cursor/utils/cursorModeConfig.ts | 153 +++++ cli/src/cursor/utils/cursorProtocol.test.ts | 59 ++ cli/src/cursor/utils/cursorProtocol.ts | 23 + .../common/cursorAcpModelProbe.test.ts | 66 ++ cli/src/modules/common/cursorAcpModelProbe.ts | 55 ++ cli/src/modules/common/cursorModels.test.ts | 202 +++++- cli/src/modules/common/cursorModels.ts | 151 ++++- .../common/cursorModelsSharedCache.test.ts | 29 + .../modules/common/cursorModelsSharedCache.ts | 55 ++ .../common/cursorModelsStaleLock.test.ts | 71 ++ .../common/handlers/cursorModels.test.ts | 106 +++ docs/guide/cursor.md | 6 +- hub/src/sync/rpcGateway.test.ts | 8 + hub/src/sync/sessionCache.ts | 38 +- hub/src/sync/sessionModel.test.ts | 114 ++++ hub/src/sync/syncEngine.ts | 4 + hub/src/web/routes/machines.test.ts | 78 +++ hub/src/web/routes/sessions.test.ts | 28 + hub/src/web/routes/sessions.ts | 9 +- shared/src/apiTypes.ts | 2 + shared/src/cursorCliSku.test.ts | 59 ++ shared/src/cursorCliSku.ts | 189 ++++++ shared/src/index.ts | 1 + shared/src/modes.ts | 5 +- shared/src/schemas.metadata.test.ts | 18 + shared/src/schemas.ts | 1 + shared/src/slashCommands.ts | 3 +- web/src/chat/modelConfig.test.ts | 4 + web/src/chat/modelConfig.ts | 34 + web/src/chat/normalize.test.ts | 48 ++ web/src/chat/normalizeAgent.ts | 39 ++ .../AssistantChat/HappyComposer.tsx | 126 +++- .../AssistantChat/StatusBar.test.ts | 14 + .../components/AssistantChat/StatusBar.tsx | 5 + .../AssistantChat/modelOptions.test.ts | 27 + .../components/AssistantChat/modelOptions.ts | 21 + .../components/NewSession/ModelSelector.tsx | 7 +- web/src/components/NewSession/index.tsx | 348 ++++++++-- .../NewSession/newSessionCursorModels.test.ts | 211 ++++++ .../NewSession/newSessionCursorModels.ts | 108 +++ .../NewSession/newSessionFormDraft.test.ts | 83 +++ .../NewSession/newSessionFormDraft.ts | 75 +++ web/src/components/SessionChat.tsx | 190 +++++- .../ToolCard/AskUserQuestionFooter.tsx | 37 +- .../components/ToolCard/PermissionFooter.tsx | 3 + .../components/ToolCard/askUserQuestion.ts | 8 +- .../ToolCard/cursorAskQuestion.test.ts | 62 ++ .../components/ToolCard/cursorAskQuestion.ts | 63 ++ .../ToolCard/views/AskUserQuestionView.tsx | 5 +- .../ToolCard/views/CursorCreatePlanView.tsx | 20 + web/src/components/ToolCard/views/_all.tsx | 5 + .../ToolCard/views/cursorViews.test.ts | 10 + web/src/hooks/mutations/useSessionActions.ts | 13 +- web/src/hooks/queries/useCursorModels.ts | 2 + .../queries/useCursorModelsForMachine.ts | 2 + web/src/lib/codexSlashCommands.test.ts | 10 +- web/src/lib/cursorCliSkuCatalog.test.ts | 39 ++ web/src/lib/cursorModelOptions.test.ts | 157 +++++ web/src/lib/cursorModelOptions.ts | 336 +++++++++ web/src/lib/cursorPickerFlatCatalog.test.ts | 49 ++ web/src/lib/cursorPickerState.test.ts | 138 ++++ web/src/lib/cursorPickerState.ts | 178 +++++ web/src/lib/cursorPickerUiSim.test.ts | 91 +++ web/src/lib/cursorVariantPicker.test.ts | 29 + web/src/lib/locales/en.ts | 5 +- web/src/lib/locales/zh-CN.ts | 5 +- web/src/lib/sessionChatCursorCatalog.test.ts | 48 ++ web/src/lib/sessionChatCursorModel.test.ts | 145 ++++ web/src/lib/sessionChatCursorModel.ts | 135 ++++ web/src/lib/sessionResume.test.ts | 17 + 110 files changed, 7945 insertions(+), 496 deletions(-) create mode 100644 cli/src/agent/backends/acp/AcpStdioTransport.test.ts create mode 100644 cli/src/agent/backends/acp/agentCliGuard.test.ts create mode 100644 cli/src/agent/backends/acp/agentCliGuard.ts create mode 100644 cli/src/cursor/cursorAcpRemoteLauncher.test.ts create mode 100644 cli/src/cursor/cursorAcpRemoteLauncher.ts create mode 100644 cli/src/cursor/cursorLegacyRemoteLauncher.test.ts create mode 100644 cli/src/cursor/cursorLegacyRemoteLauncher.ts create mode 100644 cli/src/cursor/cursorRemoteLauncher.test.ts create mode 100644 cli/src/cursor/session.test.ts create mode 100644 cli/src/cursor/utils/cursorAcpBackend.test.ts create mode 100644 cli/src/cursor/utils/cursorAcpBackend.ts create mode 100644 cli/src/cursor/utils/cursorAcpModelsBridge.ts create mode 100644 cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts create mode 100644 cli/src/cursor/utils/cursorAcpModelsSnapshot.ts create mode 100644 cli/src/cursor/utils/cursorExtensionAdapter.test.ts create mode 100644 cli/src/cursor/utils/cursorExtensionAdapter.ts rename cli/src/cursor/utils/{cursorEventConverter.test.ts => cursorLegacyEventConverter.test.ts} (99%) rename cli/src/cursor/utils/{cursorEventConverter.ts => cursorLegacyEventConverter.ts} (100%) create mode 100644 cli/src/cursor/utils/cursorModeConfig.test.ts create mode 100644 cli/src/cursor/utils/cursorModeConfig.ts create mode 100644 cli/src/cursor/utils/cursorProtocol.test.ts create mode 100644 cli/src/cursor/utils/cursorProtocol.ts create mode 100644 cli/src/modules/common/cursorAcpModelProbe.test.ts create mode 100644 cli/src/modules/common/cursorAcpModelProbe.ts create mode 100644 cli/src/modules/common/cursorModelsSharedCache.test.ts create mode 100644 cli/src/modules/common/cursorModelsSharedCache.ts create mode 100644 cli/src/modules/common/cursorModelsStaleLock.test.ts create mode 100644 cli/src/modules/common/handlers/cursorModels.test.ts create mode 100644 shared/src/cursorCliSku.test.ts create mode 100644 shared/src/cursorCliSku.ts create mode 100644 shared/src/schemas.metadata.test.ts create mode 100644 web/src/components/AssistantChat/StatusBar.test.ts create mode 100644 web/src/components/NewSession/newSessionCursorModels.test.ts create mode 100644 web/src/components/NewSession/newSessionCursorModels.ts create mode 100644 web/src/components/NewSession/newSessionFormDraft.test.ts create mode 100644 web/src/components/NewSession/newSessionFormDraft.ts create mode 100644 web/src/components/ToolCard/cursorAskQuestion.test.ts create mode 100644 web/src/components/ToolCard/cursorAskQuestion.ts create mode 100644 web/src/components/ToolCard/views/CursorCreatePlanView.tsx create mode 100644 web/src/components/ToolCard/views/cursorViews.test.ts create mode 100644 web/src/lib/cursorCliSkuCatalog.test.ts create mode 100644 web/src/lib/cursorModelOptions.test.ts create mode 100644 web/src/lib/cursorModelOptions.ts create mode 100644 web/src/lib/cursorPickerFlatCatalog.test.ts create mode 100644 web/src/lib/cursorPickerState.test.ts create mode 100644 web/src/lib/cursorPickerState.ts create mode 100644 web/src/lib/cursorPickerUiSim.test.ts create mode 100644 web/src/lib/cursorVariantPicker.test.ts create mode 100644 web/src/lib/sessionChatCursorCatalog.test.ts create mode 100644 web/src/lib/sessionChatCursorModel.test.ts create mode 100644 web/src/lib/sessionChatCursorModel.ts diff --git a/AGENTS.md b/AGENTS.md index d924ba67..eff9a964 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,14 @@ bun run build:single-exe # All-in-one binary - `messages.ts` - Message parsing utilities - `modes.ts` - Permission/model mode definitions +## Pre-push self-review (agents) + +Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pre-push-review/`). + +1. **Mechanical:** `bun typecheck && bun run test` (matches `.github/workflows/test.yml`) +2. **Logic:** skim `git diff origin/main...HEAD`; apply `.github/prompts/codex-pr-review.md` as a local Major checklist (no Codex required) +3. **Style:** optional + ## Testing - Test framework: Vitest (via `bun run test`) diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 0188e672..d0c32f56 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentMessage } from '@/agent/types'; import { AcpSdkBackend } from './AcpSdkBackend'; import { buildAcpStdioSpawnOptions } from './AcpStdioTransport'; @@ -187,6 +187,48 @@ describe('AcpSdkBackend', () => { }); }); + it('merges configOptions model variants into availableModels when both are present', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async (method) => { + if (method === 'session/new') { + return { + sessionId: 'cursor-session-variants', + models: { + availableModels: [ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' } + ], + currentModelId: 'composer-2.5[fast=true]' + }, + configOptions: [ + { + id: 'model-opt', + category: 'model', + currentValue: 'composer-2.5[fast=true]', + options: [ + { value: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { value: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ] + } + ] + }; + } + return null; + }, + close: async () => {} + }; + + const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + expect(backend.getSessionModelsMetadata(sessionId)?.availableModels.map((entry) => entry.modelId).sort()).toEqual([ + 'composer-2.5[fast=false]', + 'composer-2.5[fast=true]' + ]); + }); + it('captures model metadata from configOptions when models block is missing', async () => { const backend = new AcpSdkBackend({ command: 'opencode' }); const backendInternal = backend as unknown as { @@ -668,6 +710,51 @@ describe('AcpSdkBackend', () => { expect(turnCompleteIdx).toBeGreaterThan(stragglerIdx); }); + it('forwards usage_update to onUpdate during an active prompt', async () => { + backendStatics.UPDATE_QUIET_PERIOD_MS = 25; + backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; + backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1; + backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 50; + backendStatics.LATE_FLUSH_INTERVAL_MS = 5; + backendStatics.LATE_FLUSH_QUIET_PERIOD_MS = 10; + backendStatics.LATE_FLUSH_WINDOW_MS = 50; + + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + transport: { + sendRequest: (...args: unknown[]) => Promise; + close: () => Promise; + } | null; + handleSessionUpdate: (params: unknown) => void; + }; + + const messages: AgentMessage[] = []; + backendInternal.transport = { + sendRequest: async () => { + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'usage_update', used: 1_000, size: 200_000 } + }); + await sleep(5); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { sessionUpdate: 'usage_update', used: 2_500, size: 200_000 } + }); + await sleep(5); + return { stopReason: 'end_turn' }; + }, + close: async () => {} + }; + + await backend.prompt('session-1', [{ type: 'text', text: 'hi' }], (m) => messages.push(m)); + + const realtimeUsage = messages.filter( + (m): m is Extract => + m.type === 'usage' && m.contextTokens !== undefined + ); + expect(realtimeUsage.map((m) => m.contextTokens)).toEqual([1_000, 2_500]); + }); + it('emits a context-only usage on finalize when the prompt response carries no usage', async () => { backendStatics.UPDATE_QUIET_PERIOD_MS = 25; backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; @@ -712,4 +799,139 @@ describe('AcpSdkBackend', () => { contextWindow: 200_000 }); }); + + it('authenticateIfAvailable calls _client/authenticate when method is advertised', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + initializeResult: { protocolVersion: number; authMethods?: Array<{ id: string }> } | null; + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.initializeResult = { + protocolVersion: 1, + authMethods: [{ id: 'cursor_login' }] + }; + backendInternal.transport = { + sendRequest: async (method, params) => { + calls.push({ method, params }); + return null; + }, + close: async () => {} + }; + + await backend.authenticateIfAvailable('cursor_login'); + + expect(calls).toEqual([ + { method: '_client/authenticate', params: { methodId: 'cursor_login' } } + ]); + }); + + it('authenticateIfAvailable does not throw when _client/authenticate is unsupported', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + initializeResult: { protocolVersion: number; authMethods?: Array<{ id: string }> } | null; + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.initializeResult = { + protocolVersion: 1, + authMethods: [{ id: 'cursor_login' }] + }; + backendInternal.transport = { + sendRequest: async () => { + throw new Error('"Method not found": _client/authenticate'); + }, + close: async () => {} + }; + + await expect(backend.authenticateIfAvailable('cursor_login')).resolves.toBeUndefined(); + }); + + it('authenticateIfAvailable is a no-op when method is not advertised', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + initializeResult: { protocolVersion: number; authMethods?: Array<{ id: string }> } | null; + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.initializeResult = { protocolVersion: 1, authMethods: [] }; + backendInternal.transport = { + sendRequest: async (method, params) => { + calls.push({ method, params }); + return null; + }, + close: async () => {} + }; + + await backend.authenticateIfAvailable('cursor_login'); + + expect(calls).toEqual([]); + }); + + it('supportsLoadSession reflects initialize agentCapabilities', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + initializeResult: { protocolVersion: number; agentCapabilities?: { loadSession?: boolean } } | null; + }; + backendInternal.initializeResult = { + protocolVersion: 1, + agentCapabilities: { loadSession: true } + }; + + expect(backend.supportsLoadSession()).toBe(true); + + backendInternal.initializeResult = { + protocolVersion: 1, + agentCapabilities: { loadSession: false } + }; + expect(backend.supportsLoadSession()).toBe(false); + }); + + it('setMode falls back to session/set_config_option when session/set_mode is missing', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; registerRequestHandler: (method: string, handler: unknown) => void; close: () => Promise } | null; + sessionConfigOptions: Map }>>; + }; + backendInternal.transport = { + sendRequest: async (method, params) => { + calls.push({ method, params }); + if (method === 'session/set_mode') { + throw new Error('method not found'); + } + return null; + }, + registerRequestHandler: () => {}, + close: async () => {} + }; + backendInternal.sessionConfigOptions.set('session-1', [ + { id: 'mode-opt', category: 'mode', options: [{ value: 'agent' }, { value: 'plan' }] } + ]); + + await backend.setMode('session-1', 'plan'); + + expect(calls).toEqual([ + { method: 'session/set_mode', params: { sessionId: 'session-1', modeId: 'plan' } }, + { method: 'session/set_config_option', params: { sessionId: 'session-1', configId: 'mode-opt', value: 'plan' } } + ]); + }); + + it('registerExtensionRequestHandler wires transport handlers', () => { + const registered = new Map(); + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + transport: { registerRequestHandler: (method: string, handler: unknown) => void; close: () => Promise } | null; + }; + backendInternal.transport = { + registerRequestHandler(method, handler) { + registered.set(method, handler); + }, + close: async () => {} + }; + + const handler = vi.fn(); + backend.registerExtensionRequestHandler('cursor/ask_question', handler); + + expect(registered.get('cursor/ask_question')).toBe(handler); + }); }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 8426e776..13191a5a 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -42,6 +42,16 @@ export type AcpConfigOptionDescriptor = { options: Array<{ value: string; name?: string }>; }; +type AcpInitializeResult = { + protocolVersion: number; + authMethods?: Array<{ id: string; name?: string }>; + agentCapabilities?: { + loadSession?: boolean; + promptCapabilities?: unknown; + sessionCapabilities?: unknown; + }; +}; + export class AcpSdkBackend implements AgentBackend { private transport: AcpStdioTransport | null = null; private permissionHandler: ((request: PermissionRequest) => void) | null = null; @@ -51,10 +61,15 @@ export class AcpSdkBackend implements AgentBackend { private readonly sessionConfigOptions = new Map(); private messageHandler: AcpMessageHandler | null = null; private activeSessionId: string | null = null; + private initializeResult: AcpInitializeResult | null = null; + private setModeSupported: boolean | undefined = undefined; private isProcessingMessage = false; private responseCompleteResolvers: Array<() => void> = []; private lastSessionUpdateAt = 0; private latestUsageUpdate: AcpUsageUpdate | null = null; + private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; + private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; + private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; /** Retry configuration for ACP initialization */ private static readonly INIT_RETRY_OPTIONS = { @@ -135,9 +150,102 @@ export class AcpSdkBackend implements AgentBackend { throw new Error('Invalid initialize response from ACP agent'); } + this.initializeResult = { + protocolVersion: response.protocolVersion, + authMethods: Array.isArray(response.authMethods) + ? response.authMethods + .filter((entry): entry is Record => isObject(entry)) + .map((entry) => ({ + id: asString(entry.id) ?? '', + name: asString(entry.name) ?? undefined + })) + .filter((entry) => entry.id.length > 0) + : undefined, + agentCapabilities: isObject(response.agentCapabilities) + ? { + loadSession: response.agentCapabilities.loadSession === true, + promptCapabilities: response.agentCapabilities.promptCapabilities, + sessionCapabilities: response.agentCapabilities.sessionCapabilities + } + : undefined + }; + logger.debug(`[ACP] Initialized with protocol version ${response.protocolVersion}`); } + async authenticate(methodId: string): Promise { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + await this.transport.sendRequest('_client/authenticate', { methodId }); + } + + async authenticateIfAvailable(methodId: string): Promise { + const methods = this.initializeResult?.authMethods ?? []; + if (!methods.some((method) => method.id === methodId)) { + logger.debug(`[ACP] Auth method not advertised: ${methodId}`); + return; + } + try { + await this.authenticate(methodId); + } catch (error) { + // Cursor advertises cursor_login but may not implement _client/authenticate yet. + logger.debug(`[ACP] authenticate skipped (${methodId})`, error); + } + } + + supportsLoadSession(): boolean { + return this.initializeResult?.agentCapabilities?.loadSession === true; + } + + getSessionConfigOptions(sessionId: string): AcpConfigOptionDescriptor[] | undefined { + return this.sessionConfigOptions.get(sessionId); + } + + getConfigOptionByCategory(sessionId: string, category: string): AcpConfigOptionDescriptor | undefined { + return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === category); + } + + registerExtensionRequestHandler( + method: string, + handler: (params: unknown, requestId: string | number | null) => Promise + ): void { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + this.transport.registerRequestHandler(method, handler); + } + + async setMode(sessionId: string, modeId: string): Promise { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + + await this.waitForResponseComplete(); + + if (this.setModeSupported !== false) { + try { + await this.transport.sendRequest('session/set_mode', { sessionId, modeId }); + this.setModeSupported = true; + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/method not found/i.test(message)) { + this.setModeSupported = false; + } else { + throw error; + } + } + } + + const modeOption = this.getConfigOptionByCategory(sessionId, 'mode'); + if (!modeOption) { + throw new Error('ACP agent does not support session/set_mode and no mode config option is available'); + } + + await this.setConfigOption(sessionId, modeOption.id, modeId); + } + async newSession(config: AgentSessionConfig): Promise { if (!this.transport) { throw new Error('ACP transport not initialized'); @@ -261,6 +369,11 @@ export class AcpSdkBackend implements AgentBackend { return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level'); } + /** Forwards ACP `usage_update` to the web status bar when no prompt is active (e.g. session resume). */ + setUsageUpdateListener(listener: ((msg: AgentMessage) => void) | null): void { + this.usageUpdateListener = listener; + } + async prompt( sessionId: string, content: PromptContent[], @@ -286,6 +399,8 @@ export class AcpSdkBackend implements AgentBackend { this.isProcessingMessage = true; this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; + this.lastForwardedUsageUpdate = null; + this.promptUsageCallback = onUpdate; let stopReason: string | null = null; let promptUsage: AcpPromptUsage | null = null; @@ -326,6 +441,7 @@ export class AcpSdkBackend implements AgentBackend { } else if ( latestUsageUpdate && (latestUsageUpdate.contextTokens !== undefined || latestUsageUpdate.contextWindow !== undefined) + && !this.hasForwardedUsage(latestUsageUpdate) ) { // Agent did not return prompt usage (slash-handled turns, // errored turns), but we did see ACP usage updates during @@ -343,6 +459,7 @@ export class AcpSdkBackend implements AgentBackend { onUpdate({ type: 'turn_complete', stopReason }); } } finally { + this.promptUsageCallback = null; this.isProcessingMessage = false; this.notifyResponseComplete(); } @@ -444,14 +561,75 @@ export class AcpSdkBackend implements AgentBackend { private captureUsageUpdate(update: unknown): void { if (!isObject(update)) return; - if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.usageUpdate) return; - const contextTokens = this.asFiniteNumber(update.used); - const contextWindow = this.asFiniteNumber(update.size); + const sessionUpdate = asString(update.sessionUpdate); + let contextTokens: number | null = null; + let contextWindow: number | null = null; + + if (sessionUpdate === ACP_SESSION_UPDATE_TYPES.usageUpdate) { + contextTokens = this.asFiniteNumber(update.used); + contextWindow = this.asFiniteNumber(update.size); + } else if (sessionUpdate === ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) { + contextTokens = this.asFiniteNumber( + update.used + ?? update.contextTokens + ?? update.context_tokens + ?? update.contextUsed + ); + contextWindow = this.asFiniteNumber( + update.size + ?? update.contextWindow + ?? update.context_window + ?? update.contextLimit + ); + } else { + return; + } + this.latestUsageUpdate = { contextTokens: contextTokens ?? undefined, contextWindow: contextWindow ?? undefined }; + this.forwardUsageUpdate(); + } + + private hasForwardedUsage(update: AcpUsageUpdate): boolean { + return this.lastForwardedUsageUpdate !== null + && this.lastForwardedUsageUpdate.contextTokens === update.contextTokens + && this.lastForwardedUsageUpdate.contextWindow === update.contextWindow; + } + + private forwardUsageUpdate(): void { + const update = this.latestUsageUpdate; + if ( + !update + || (update.contextTokens === undefined && update.contextWindow === undefined) + ) { + return; + } + + if ( + this.lastForwardedUsageUpdate + && this.lastForwardedUsageUpdate.contextTokens === update.contextTokens + && this.lastForwardedUsageUpdate.contextWindow === update.contextWindow + ) { + return; + } + + this.lastForwardedUsageUpdate = update; + const message: AgentMessage = { + type: 'usage', + inputTokens: 0, + outputTokens: 0, + contextTokens: update.contextTokens, + contextWindow: update.contextWindow + }; + + if (this.promptUsageCallback) { + this.promptUsageCallback(message); + } else if (this.usageUpdateListener) { + this.usageUpdateListener(message); + } } private readLatestUsageUpdate(): AcpUsageUpdate | null { @@ -582,6 +760,11 @@ export class AcpSdkBackend implements AgentBackend { }); } + /** After a successful model config apply, avoid stale base-only ACP currentValue overwriting cache. */ + pinSessionModelWireId(sessionId: string, modelId: string): void { + this.updateCurrentModelOptimistic(sessionId, modelId); + } + private extractPromptUsage(response: unknown): AcpPromptUsage | null { if (!isObject(response) || !isObject(response.usage)) return null; const usage = response.usage; @@ -691,27 +874,88 @@ export class AcpSdkBackend implements AgentBackend { return; } - const availableModels: AcpModelDescriptor[] = []; + const byModelId = new Map(); + const addModel = (modelId: string, name?: string) => { + const trimmedId = modelId.trim(); + if (!trimmedId) return; + const trimmedName = name?.trim(); + const existing = byModelId.get(trimmedId); + if (!existing) { + byModelId.set( + trimmedId, + trimmedName && trimmedName !== trimmedId + ? { modelId: trimmedId, name: trimmedName } + : { modelId: trimmedId } + ); + return; + } + if (!existing.name && trimmedName && trimmedName !== trimmedId) { + byModelId.set(trimmedId, { modelId: trimmedId, name: trimmedName }); + } + }; + if (Array.isArray(rawModels)) { for (const entry of rawModels) { if (!isObject(entry)) continue; const modelId = asString(entry.modelId) ?? asString(entry.value); if (!modelId) continue; - const name = asString(entry.name) ?? undefined; - availableModels.push(name ? { modelId, name } : { modelId }); + addModel(modelId, asString(entry.name) ?? undefined); } } else { // Preserve previously-captured availableModels when the response only // updates currentModelId (e.g. a setModel response from some agents). const existing = this.sessionModelsMetadata.get(sessionId); - if (existing) { - availableModels.push(...existing.availableModels); + for (const entry of existing?.availableModels ?? []) { + addModel(entry.modelId, entry.name); } } + // Cursor often lists one wire id per family in `models` but every variant in + // `configOptions` category=model — merge so metadata matches Zed-style pickers. + if (configModelOption) { + for (const entry of configModelOption.options) { + if (!isObject(entry)) continue; + const modelId = asString(entry.value) ?? asString(entry.modelId); + if (!modelId) continue; + addModel(modelId, asString(entry.name) ?? undefined); + } + } + + const existing = this.sessionModelsMetadata.get(sessionId); + const currentModelId = this.preferSpecificCursorWireId( + rawCurrent, + existing?.currentModelId ?? null + ); + this.sessionModelsMetadata.set(sessionId, { - availableModels, - currentModelId: rawCurrent + availableModels: [...byModelId.values()], + currentModelId }); } + + private preferSpecificCursorWireId( + incoming: string | null, + existing: string | null + ): string | null { + if (!incoming) { + return existing; + } + if (!existing) { + return incoming; + } + + const incomingBase = incoming.split('[')[0]; + const existingBase = existing.split('[')[0]; + if (incomingBase !== existingBase) { + return incoming; + } + + const incomingHasVariant = incoming.includes('['); + const existingHasVariant = existing.includes('['); + if (!incomingHasVariant && existingHasVariant) { + return existing; + } + + return incoming; + } } diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts new file mode 100644 index 00000000..7fe00b06 --- /dev/null +++ b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const guard = vi.hoisted(() => ({ + register: vi.fn(), + unregister: vi.fn() +})); + +vi.mock('./agentCliGuard', () => ({ + registerActiveAcpTransport: guard.register, + unregisterActiveAcpTransport: guard.unregister +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(() => { + const handlers = new Map void>>(); + const proc = { + stdout: { + setEncoding: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(`stdout:${event}`, [...(handlers.get(`stdout:${event}`) ?? []), handler]); + }) + }, + stderr: { + setEncoding: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(`stderr:${event}`, [...(handlers.get(`stderr:${event}`) ?? []), handler]); + }) + }, + stdin: { end: vi.fn(), write: vi.fn() }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(`proc:${event}`, [...(handlers.get(`proc:${event}`) ?? []), handler]); + if (event === 'exit') { + queueMicrotask(() => handler(0, null)); + } + }), + kill: vi.fn() + }; + return proc; + }) +})); + +import { AcpStdioTransport } from './AcpStdioTransport'; + +describe('AcpStdioTransport agent CLI guard', () => { + afterEach(() => { + guard.register.mockClear(); + guard.unregister.mockClear(); + }); + + test('registers cross-process guard only for Cursor agent command', async () => { + const transport = new AcpStdioTransport({ command: 'agent', args: ['acp'] }); + expect(guard.register).toHaveBeenCalledTimes(1); + await transport.close(); + expect(guard.unregister).toHaveBeenCalledTimes(1); + }); + + test('does not register guard for non-agent ACP backends', () => { + for (const command of ['gemini', 'opencode', 'kimi']) { + guard.register.mockClear(); + guard.unregister.mockClear(); + new AcpStdioTransport({ command }); + expect(guard.register).not.toHaveBeenCalled(); + expect(guard.unregister).not.toHaveBeenCalled(); + } + }); +}); diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index 2485388e..06d3e033 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptions } from 'n import { logger } from '@/ui/logger'; import { killProcessByChildProcess } from '@/utils/process'; import { GEMINI_MODEL_PRESETS } from '@hapi/protocol'; +import { registerActiveAcpTransport, unregisterActiveAcpTransport } from './agentCliGuard'; interface JsonRpcRequest { jsonrpc: '2.0'; @@ -48,6 +49,8 @@ export function buildAcpStdioSpawnOptions(env?: Record): SpawnOp } export class AcpStdioTransport { + /** Only Cursor's `agent` CLI is single-process; other ACP backends must not block model probes. */ + private readonly shouldGuardAgentCli: boolean; private readonly process: ChildProcessWithoutNullStreams; private readonly pending = new Map void; @@ -59,18 +62,24 @@ export class AcpStdioTransport { private buffer = ''; private nextId = 1; private protocolError: Error | null = null; + private guardReleased = false; constructor(options: { command: string; args?: string[]; env?: Record; }) { + this.shouldGuardAgentCli = options.command === 'agent'; this.process = spawn( options.command, options.args ?? [], buildAcpStdioSpawnOptions(options.env) ) as ChildProcessWithoutNullStreams; + if (this.shouldGuardAgentCli) { + registerActiveAcpTransport(); + } + this.process.stdout.setEncoding('utf8'); this.process.stdout.on('data', (chunk) => this.handleStdout(chunk)); @@ -82,12 +91,14 @@ export class AcpStdioTransport { }); this.process.on('exit', (code, signal) => { + this.releaseAgentCliGuard(); const message = `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`; logger.debug(message); this.rejectAllPending(new Error(message)); }); this.process.on('error', (error) => { + this.releaseAgentCliGuard(); logger.debug('[ACP] Process error', error); const message = error instanceof Error ? error.message : String(error); this.rejectAllPending(new Error( @@ -167,9 +178,18 @@ export class AcpStdioTransport { async close(): Promise { this.process.stdin.end(); await killProcessByChildProcess(this.process); + this.releaseAgentCliGuard(); this.rejectAllPending(new Error('ACP transport closed')); } + private releaseAgentCliGuard(): void { + if (!this.shouldGuardAgentCli || this.guardReleased) { + return; + } + this.guardReleased = true; + unregisterActiveAcpTransport(); + } + private handleStdout(chunk: string): void { this.buffer += chunk; let newlineIndex = this.buffer.indexOf('\n'); diff --git a/cli/src/agent/backends/acp/agentCliGuard.test.ts b/cli/src/agent/backends/acp/agentCliGuard.test.ts new file mode 100644 index 00000000..812f45e9 --- /dev/null +++ b/cli/src/agent/backends/acp/agentCliGuard.test.ts @@ -0,0 +1,66 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, test } from 'vitest'; +import { + _resetAgentCliGuardForTests, + isAgentAcpTransportActive, + registerActiveAcpTransport, + unregisterActiveAcpTransport +} from './agentCliGuard'; + +const testHome = join(tmpdir(), `hapi-agent-cli-guard-${process.pid}`); + +function lockDir(): string { + return join(testHome, 'locks', 'agent-acp-active'); +} + +describe('agentCliGuard', () => { + const previousHome = process.env.HAPI_HOME; + + afterEach(() => { + _resetAgentCliGuardForTests(); + if (previousHome === undefined) { + delete process.env.HAPI_HOME; + } else { + process.env.HAPI_HOME = previousHome; + } + }); + + test('treats in-process ACP transport as active', () => { + process.env.HAPI_HOME = testHome; + registerActiveAcpTransport(); + expect(isAgentAcpTransportActive()).toBe(true); + unregisterActiveAcpTransport(); + expect(isAgentAcpTransportActive()).toBe(false); + }); + + test('clears stale cross-process lock when pid is not running', () => { + process.env.HAPI_HOME = testHome; + const dir = lockDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'pid'), '99999999'); + + expect(isAgentAcpTransportActive()).toBe(false); + expect(existsSync(dir)).toBe(false); + }); + + test('keeps lock when pid file points at a live process', () => { + process.env.HAPI_HOME = testHome; + const dir = lockDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'pid'), String(process.pid)); + + expect(isAgentAcpTransportActive()).toBe(true); + expect(existsSync(dir)).toBe(true); + }); + + test('clears lock when pid file is missing or invalid', () => { + process.env.HAPI_HOME = testHome; + const dir = lockDir(); + mkdirSync(dir, { recursive: true }); + + expect(isAgentAcpTransportActive()).toBe(false); + expect(existsSync(dir)).toBe(false); + }); +}); diff --git a/cli/src/agent/backends/acp/agentCliGuard.ts b/cli/src/agent/backends/acp/agentCliGuard.ts new file mode 100644 index 00000000..7c68d8e6 --- /dev/null +++ b/cli/src/agent/backends/acp/agentCliGuard.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +/** + * Cursor's `agent` CLI appears to allow only one active process at a time. + * Spawning `agent --list-models` while `agent acp` is running terminates the ACP + * child (SIGTERM / exit 143) and crashes the remote session. + * + * In-process ref counting covers RPC handlers in the same process; a HAPI_HOME + * lock directory covers runner vs session child processes. + */ +let activeAcpTransportCount = 0; + +function getAcpLockDir(): string { + const home = process.env.HAPI_HOME?.trim() || join(tmpdir(), 'hapi'); + return join(home, 'locks', 'agent-acp-active'); +} + +function readLockPid(lockDir: string): number | null { + const pidPath = join(lockDir, 'pid'); + if (!existsSync(pidPath)) { + return null; + } + + try { + const raw = readFileSync(pidPath, 'utf8').trim(); + const pid = Number(raw); + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + return pid; + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // Process exists but we lack permission to signal it. + return code === 'EPERM'; + } +} + +function removeAcpLockDir(): void { + const lockDir = getAcpLockDir(); + if (!existsSync(lockDir)) { + return; + } + try { + rmSync(lockDir, { recursive: true, force: true }); + } catch { + // Best effort — stale lock is preferable to killing a live ACP session. + } +} + +/** Remove lock directories left behind by SIGKILL / crash / reboot. */ +function clearStaleAcpLockIfNeeded(): void { + const lockDir = getAcpLockDir(); + if (!existsSync(lockDir)) { + return; + } + + const pid = readLockPid(lockDir); + if (pid === null || !isProcessAlive(pid)) { + removeAcpLockDir(); + } +} + +export function registerActiveAcpTransport(): void { + activeAcpTransportCount += 1; + const lockDir = getAcpLockDir(); + try { + mkdirSync(lockDir, { recursive: true }); + writeFileSync(join(lockDir, 'pid'), String(process.pid)); + } catch { + // Another process may have created the lock; in-process guard still applies. + } +} + +export function unregisterActiveAcpTransport(): void { + activeAcpTransportCount = Math.max(0, activeAcpTransportCount - 1); + if (activeAcpTransportCount > 0) { + return; + } + removeAcpLockDir(); +} + +export function isAgentAcpTransportActive(): boolean { + if (activeAcpTransportCount > 0) { + return true; + } + clearStaleAcpLockIfNeeded(); + return existsSync(getAcpLockDir()); +} + +export function _resetAgentCliGuardForTests(): void { + activeAcpTransportCount = 0; + removeAcpLockDir(); +} diff --git a/cli/src/agent/backends/acp/constants.ts b/cli/src/agent/backends/acp/constants.ts index 66c7d9f3..a3bddbe7 100644 --- a/cli/src/agent/backends/acp/constants.ts +++ b/cli/src/agent/backends/acp/constants.ts @@ -4,5 +4,6 @@ export const ACP_SESSION_UPDATE_TYPES = { toolCall: 'tool_call', toolCallUpdate: 'tool_call_update', plan: 'plan', - usageUpdate: 'usage_update' + usageUpdate: 'usage_update', + sessionInfoUpdate: 'session_info_update' } as const; diff --git a/cli/src/agent/permissionAdapter.test.ts b/cli/src/agent/permissionAdapter.test.ts index bf9fb2b7..ca7094b1 100644 --- a/cli/src/agent/permissionAdapter.test.ts +++ b/cli/src/agent/permissionAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ApiSessionClient } from '@/api/apiSession'; import type { AgentBackend, PermissionRequest, PermissionResponse } from './types'; import { PermissionAdapter } from './permissionAdapter'; @@ -66,6 +66,64 @@ function createHarness() { }; } +function createHarnessWithInterceptor( + intercept: (response: { id: string; approved: boolean; decision?: string }) => Promise +) { + let agentState: FakeAgentState = { + requests: {}, + completedRequests: {} + }; + + const rpcHandlers = new Map Promise | unknown>(); + let permissionHandler: ((request: PermissionRequest) => void) | null = null; + const respondCalls: Array<{ + sessionId: string; + request: PermissionRequest; + response: PermissionResponse; + }> = []; + + const session = { + rpcHandlerManager: { + registerHandler(method: string, handler: (params: unknown) => Promise | unknown) { + rpcHandlers.set(method, handler); + } + }, + updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) { + agentState = handler(agentState); + } + } as unknown as ApiSessionClient; + + const backend: AgentBackend = { + async initialize() {}, + async newSession() { + return 'agent-session'; + }, + async prompt() {}, + async cancelPrompt() {}, + async respondToPermission(sessionId, request, response) { + respondCalls.push({ sessionId, request, response }); + }, + onPermissionRequest(handler) { + permissionHandler = handler; + }, + async disconnect() {} + }; + + new PermissionAdapter(session, backend, undefined, intercept); + + return { + rpcHandlers, + respondCalls, + getAgentState: () => agentState, + emitPermissionRequest(request: PermissionRequest) { + if (!permissionHandler) { + throw new Error('Permission handler was not registered'); + } + permissionHandler(request); + } + }; +} + function createHarnessWithMode(getPermissionMode: () => 'default' | 'read-only' | 'safe-yolo' | 'yolo') { let agentState: FakeAgentState = { requests: {}, @@ -354,4 +412,50 @@ describe('PermissionAdapter', () => { } }); }); + + it('delegates permission RPC to interceptor before handling standard permissions', async () => { + const intercept = vi.fn(async () => true); + const harness = createHarnessWithInterceptor(intercept); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-intercept', + toolCallId: 'perm-intercept', + title: 'Read' + })); + + const permissionRpc = harness.rpcHandlers.get('permission'); + await permissionRpc?.({ + id: 'perm-intercept', + approved: true, + decision: 'approved' + }); + + expect(intercept).toHaveBeenCalledWith({ + id: 'perm-intercept', + approved: true, + decision: 'approved' + }); + expect(harness.respondCalls).toEqual([]); + }); + + it('handles permission RPC when interceptor returns false', async () => { + const intercept = vi.fn(async () => false); + const harness = createHarnessWithInterceptor(intercept); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-standard', + toolCallId: 'perm-standard', + title: 'Read' + })); + + const permissionRpc = harness.rpcHandlers.get('permission'); + await permissionRpc?.({ + id: 'perm-standard', + approved: true, + decision: 'approved' + }); + + expect(intercept).toHaveBeenCalledOnce(); + expect(harness.respondCalls).toHaveLength(1); + }); }); diff --git a/cli/src/agent/permissionAdapter.ts b/cli/src/agent/permissionAdapter.ts index 37990cec..8b43830c 100644 --- a/cli/src/agent/permissionAdapter.ts +++ b/cli/src/agent/permissionAdapter.ts @@ -43,12 +43,19 @@ export class PermissionAdapter { constructor( private readonly session: ApiSessionClient, private readonly backend: AgentBackend, - private readonly getPermissionMode?: () => SessionPermissionMode | undefined + private readonly getPermissionMode?: () => SessionPermissionMode | undefined, + private readonly interceptPermissionResponse?: (response: PermissionResponseMessage) => Promise ) { this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); this.session.rpcHandlerManager.registerHandler( RPC_METHODS.Permission, async (response) => { + if (this.interceptPermissionResponse) { + const handled = await this.interceptPermissionResponse(response); + if (handled) { + return; + } + } await this.handlePermissionResponse(response); } ); diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index edf80b62..a0f47b46 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -21,7 +21,7 @@ export type AgentSessionBaseOptions = { mode?: 'local' | 'remote'; sessionLabel: string; sessionIdLabel: string; - applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; + applySessionIdToMetadata: (metadata: Metadata, sessionId: string, extras?: Partial) => Metadata; permissionMode?: SessionPermissionMode; model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; @@ -42,7 +42,7 @@ export class AgentSessionBase { thinking: boolean = false; private sessionFoundCallbacks: ((sessionId: string) => void)[] = []; - private readonly applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; + private readonly applySessionIdToMetadata: (metadata: Metadata, sessionId: string, extras?: Partial) => Metadata; private readonly sessionLabel: string; private readonly sessionIdLabel: string; private keepAliveInterval: NodeJS.Timeout | null = null; @@ -103,9 +103,9 @@ export class AgentSessionBase { this._onModeChange(mode); }; - onSessionFound = (sessionId: string) => { + onSessionFound = (sessionId: string, extras?: Partial) => { this.sessionId = sessionId; - this.client.updateMetadata((metadata) => this.applySessionIdToMetadata(metadata, sessionId)); + this.client.updateMetadata((metadata) => this.applySessionIdToMetadata(metadata, sessionId, extras)); logger.debug(`[${this.sessionLabel}] ${this.sessionIdLabel} session ID ${sessionId} added to metadata`); for (const callback of this.sessionFoundCallbacks) { diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 20579731..a965d14d 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -132,6 +132,7 @@ describe('bootstrapExistingSession', () => { geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', cursorSessionId: 'cursor-thread-1', + cursorSessionProtocol: 'acp', summary: { text: 'resume me', updatedAt: 100 @@ -159,6 +160,7 @@ describe('bootstrapExistingSession', () => { geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', cursorSessionId: 'cursor-thread-1', + cursorSessionProtocol: 'acp', summary: { text: 'resume me', updatedAt: 100 diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index a71a2be1..c6e26431 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -98,6 +98,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId + if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId if (metadata.tools !== undefined) preserved.tools = metadata.tools if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands diff --git a/cli/src/claude/claudeRemote.seam.test.ts b/cli/src/claude/claudeRemote.seam.test.ts index bc4fda15..dd490ed3 100644 --- a/cli/src/claude/claudeRemote.seam.test.ts +++ b/cli/src/claude/claudeRemote.seam.test.ts @@ -111,5 +111,5 @@ describe('claudeRemote/query real seam', () => { await expect(runPromise).rejects.toThrow('next message failed') expect(received.map((message) => message.type)).toEqual(['assistant', 'result']) - }) + }, 15_000) }) diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts index 8cb79e11..f8b0df7a 100644 --- a/cli/src/commands/cursor.ts +++ b/cli/src/commands/cursor.ts @@ -50,7 +50,7 @@ export const cursorCommand: CommandDefinition = { if (!mode) { throw new Error('Missing --mode value') } - if (mode === 'plan' || mode === 'ask') { + if (mode === 'plan' || mode === 'ask' || mode === 'debug') { options.permissionMode = mode } } else if (arg === '--plan') { diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts new file mode 100644 index 00000000..c039ce04 --- /dev/null +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -0,0 +1,636 @@ +import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import type { EnhancedMode } from './loop'; + +const harness = vi.hoisted(() => ({ + initializeError: null as Error | null, + loadSessionError: null as Error | null, + supportsLoadSession: true, + loadSessionCalled: false, + newSessionCalled: false, + promptCalls: 0, + backendArgs: null as { command: string; args?: string[] } | null, + setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>, + deferSetConfigOption: null as Promise | null, + releaseSetConfigOption: null as (() => void) | null +})); + +const legacyLauncher = vi.hoisted(() => vi.fn()); + +vi.mock('./cursorLegacyRemoteLauncher', () => ({ + cursorLegacyRemoteLauncher: legacyLauncher +})); + +vi.mock('./utils/cursorAcpBackend', () => ({ + CURSOR_ACP_REQUIRED_MESSAGE: 'Cursor ACP mode is required for new Cursor remote sessions.', + createCursorAcpBackend: vi.fn((opts?: { model?: string | null }) => { + const args = ['acp']; + const model = opts?.model?.trim(); + if (model && model !== 'auto' && model !== 'default' && model !== 'default[]') { + args.unshift('--model', model); + } + harness.backendArgs = { command: 'agent', args }; + return { + initialize: vi.fn(async () => { + if (harness.initializeError) throw harness.initializeError; + }), + authenticateIfAvailable: vi.fn(async () => {}), + supportsLoadSession: vi.fn(() => harness.supportsLoadSession), + loadSession: vi.fn(async () => { + harness.loadSessionCalled = true; + if (harness.loadSessionError) throw harness.loadSessionError; + return 'loaded-acp-session'; + }), + newSession: vi.fn(async () => { + harness.newSessionCalled = true; + return 'new-acp-session'; + }), + setMode: vi.fn(async () => {}), + setModel: vi.fn(async () => {}), + setConfigOption: vi.fn(async (sessionId: string, configId: string, value: string) => { + if (configId === 'model-opt' && harness.deferSetConfigOption) { + await harness.deferSetConfigOption; + } + harness.setConfigOptionCalls.push({ sessionId, configId, value }); + }), + pinSessionModelWireId: vi.fn(), + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [ + { modelId: 'composer-2.5[fast=true]' }, + { modelId: 'composer-2.5[fast=false]' } + ], + currentModelId: 'composer-2.5[fast=true]' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'mode') { + return { + id: 'mode-opt', + options: [ + { value: 'agent' }, + { value: 'plan' }, + { value: 'debug' } + ] + }; + } + if (category === 'model') { + return { + id: 'model-opt', + options: [ + { value: 'default[]' }, + { value: 'composer-2.5[fast=true]' }, + { value: 'composer-2.5[fast=false]' } + ] + }; + } + return undefined; + }), + prompt: vi.fn(async () => { + harness.promptCalls++; + }), + cancelPrompt: vi.fn(async () => {}), + respondToPermission: vi.fn(async () => {}), + onStderrError: vi.fn(), + setUsageUpdateListener: vi.fn(), + onPermissionRequest: vi.fn(), + registerExtensionRequestHandler: vi.fn(), + disconnect: vi.fn(async () => {}) + }; + }) +})); + +vi.mock('./utils/cursorExtensionAdapter', () => ({ + CursorExtensionAdapter: class { + handlePermissionResponse = vi.fn(async () => false); + cancelAll = vi.fn(async () => {}); + } +})); + +vi.mock('@/agent/permissionAdapter', () => ({ + PermissionAdapter: class { + cancelAll = vi.fn(async () => {}); + } +})); + +vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ + buildHapiMcpBridge: async () => ({ + server: { stop: () => {} }, + mcpServers: {} + }) +})); + +vi.mock('@/ui/ink/OpencodeDisplay', () => ({ + OpencodeDisplay: () => null +})); + +vi.mock('@/ui/logger', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn() } +})); + +import { cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher'; +import { createCursorAcpBackend } from './utils/cursorAcpBackend'; +import { CursorSession } from './session'; +import { ApiSessionClient } from '@/api/apiSession'; + +function makeSession(sessionId: string | null): CursorSession { + const queue = new MessageQueue2(() => 'mode'); + const client = { + rpcHandlerManager: { + registerHandler: vi.fn() + }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + + session.onSessionFoundWithProtocol = vi.fn(); + queue.close(); + + return session; +} + +describe('cursorAcpRemoteLauncher', () => { + beforeEach(() => { + harness.initializeError = null; + harness.loadSessionError = null; + harness.supportsLoadSession = true; + harness.loadSessionCalled = false; + harness.newSessionCalled = false; + harness.promptCalls = 0; + harness.setConfigOptionCalls = []; + harness.deferSetConfigOption = null; + harness.releaseSetConfigOption = null; + legacyLauncher.mockClear(); + process.stdin.isTTY = false; + process.stdout.isTTY = false; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('spawns agent acp backend, not stream-json', async () => { + const session = makeSession(null); + await cursorAcpRemoteLauncher(session); + + expect(createCursorAcpBackend).toHaveBeenCalled(); + expect(harness.backendArgs).toEqual({ command: 'agent', args: ['acp'] }); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); + + it('throws on initialize failure without invoking legacy launcher', async () => { + harness.initializeError = new Error('agent acp not found'); + const session = makeSession(null); + + await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( + /Cursor ACP mode is required for new Cursor remote sessions/ + ); + + expect(legacyLauncher).not.toHaveBeenCalled(); + expect(harness.newSessionCalled).toBe(false); + }); + + it('throws when session/load fails instead of falling back to stream-json', async () => { + harness.loadSessionError = new Error('session not found'); + const session = makeSession('old-stream-json-id'); + + await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( + /Legacy stream-json sessions cannot be loaded via ACP/ + ); + + expect(harness.loadSessionCalled).toBe(true); + expect(harness.newSessionCalled).toBe(false); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); + + it('throws when resume id is set but session/load is unsupported', async () => { + harness.supportsLoadSession = false; + const session = makeSession('some-session-id'); + + await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( + /session\/load is not supported/ + ); + + expect(harness.loadSessionCalled).toBe(false); + expect(harness.newSessionCalled).toBe(false); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); + + it('creates a new ACP session when no resume id is provided', async () => { + const session = makeSession(null); + await cursorAcpRemoteLauncher(session); + + expect(harness.newSessionCalled).toBe(true); + expect(harness.loadSessionCalled).toBe(false); + expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('new-acp-session', 'acp'); + }); + + it('applies debug mode immediately when setPermissionMode is called', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + + session.setPermissionMode('debug'); + + await vi.waitFor(() => { + expect( + harness.setConfigOptionCalls.some( + (call) => call.configId === 'mode-opt' && call.value === 'debug' + ) + ).toBe(true); + }); + + queue.close(); + await runPromise; + }); + + it('syncs spawn model to hub via keepAlive after initial ACP apply', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const keepAlive = vi.fn(); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default', + model: 'composer-2.5[fast=false]' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + + await vi.waitFor(() => { + expect(session.model).toBe('composer-2.5[fast=false]'); + expect(keepAlive).toHaveBeenCalled(); + }); + + queue.close(); + await runPromise; + }); + + it('pushes keepalive with requested model before ACP apply finishes', async () => { + harness.deferSetConfigOption = new Promise((resolve) => { + harness.releaseSetConfigOption = resolve; + }); + + const queue = new MessageQueue2((mode) => mode.permissionMode); + const keepAlive = vi.fn(); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + session.setModel('composer-2.5[fast=false]'); + + await vi.waitFor(() => { + expect(session.model).toBe('composer-2.5[fast=false]'); + expect(harness.setConfigOptionCalls.some((call) => call.configId === 'model-opt')).toBe(false); + }); + + harness.releaseSetConfigOption?.(); + await vi.waitFor(() => { + expect(harness.setConfigOptionCalls.length).toBeGreaterThan(0); + }); + harness.deferSetConfigOption = null; + harness.releaseSetConfigOption = null; + queue.close(); + await runPromise; + }); + + it('applies model wire id immediately when setModel is called', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + session.setModel('composer-2.5[fast=false]'); + + await vi.waitFor(() => { + expect( + harness.setConfigOptionCalls.some( + (call) => call.configId === 'model-opt' && call.value === 'composer-2.5[fast=false]' + ) + ).toBe(true); + }); + + queue.close(); + await runPromise; + }); + + it('applies ACP default model when setModel is cleared', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + session.setModel('composer-2.5[fast=false]'); + await vi.waitFor(() => { + expect( + harness.setConfigOptionCalls.some( + (call) => call.configId === 'model-opt' && call.value === 'composer-2.5[fast=false]' + ) + ).toBe(true); + }); + + harness.setConfigOptionCalls.length = 0; + session.setModel(null); + + await vi.waitFor(() => { + expect( + harness.setConfigOptionCalls.some( + (call) => call.configId === 'model-opt' && call.value === 'default[]' + ) + ).toBe(true); + expect(session.model).toBeUndefined(); + }); + + queue.close(); + await runPromise; + }); + + it('rolls back optimistic setModel when ACP does not expose the requested model', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const keepAlive = vi.fn(); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + session.setModel('missing-model'); + + await vi.waitFor(() => { + expect(session.model).toBe('composer-2.5[fast=true]'); + }); + expect(keepAlive).toHaveBeenCalled(); + + queue.close(); + await runPromise; + }); + + it('applyModelConfig(null) resets ACP to the default model option', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + await session.applyModelConfig('composer-2.5[fast=false]'); + harness.setConfigOptionCalls.length = 0; + + await session.applyModelConfig(null); + + expect( + harness.setConfigOptionCalls.some( + (call) => call.configId === 'model-opt' && call.value === 'default[]' + ) + ).toBe(true); + + queue.close(); + await runPromise; + }); + + it('rejects applyModelConfig when ACP does not expose the requested model', async () => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + await expect(session.applyModelConfig('missing-model')).rejects.toThrow( + /not available via ACP/ + ); + + queue.close(); + await runPromise; + }); + + it('processes multiple queued messages with separate prompts', async () => { + const queue = new MessageQueue2((mode) => + `${mode.permissionMode}:${mode.model ?? ''}` + ); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitMessagesConsumed: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('first', { permissionMode: 'default' }); + queue.push('second', { permissionMode: 'plan' }); + queue.close(); + + await cursorAcpRemoteLauncher(session); + + expect(harness.promptCalls).toBe(2); + }); +}); diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts new file mode 100644 index 00000000..3c102876 --- /dev/null +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -0,0 +1,461 @@ +import React from 'react'; +import { logger } from '@/ui/logger'; +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { PermissionAdapter } from '@/agent/permissionAdapter'; +import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from '@/modules/common/remote/RemoteLauncherBase'; +import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; +import type { CursorSession } from './session'; +import type { PermissionMode } from './loop'; +import { createCursorAcpBackend, CURSOR_ACP_REQUIRED_MESSAGE } from './utils/cursorAcpBackend'; +import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge'; +import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot'; +import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter'; +import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig'; +import { seedCursorModelsCache } from '@/modules/common/cursorModels'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; + +class CursorAcpRemoteLauncher extends RemoteLauncherBase { + private readonly session: CursorSession; + private backend: ReturnType | null = null; + private permissionAdapter: PermissionAdapter | null = null; + private extensionAdapter: CursorExtensionAdapter | null = null; + private happyServer: { stop: () => void } | null = null; + private abortController = new AbortController(); + private displayPermissionMode: PermissionMode | null = null; + private currentBackendModel: string | null = null; + private defaultBackendModel: string | null = null; + private unregisterModelApplyHandler: (() => void) | null = null; + private modelApplySeq = 0; + + constructor(session: CursorSession) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(OpencodeDisplay, context); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + + const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); + this.happyServer = happyServer; + + const backend = createCursorAcpBackend({ cwd: session.path, model: session.model }); + this.backend = backend; + + backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); + + backend.onStderrError((error) => { + logger.debug('[cursor-acp] stderr error', error); + session.sendSessionEvent({ type: 'message', message: error.message }); + messageBuffer.addMessage(error.message, 'status'); + }); + + try { + await backend.initialize(); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + throw new Error(`${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`); + } + + await backend.authenticateIfAvailable('cursor_login'); + + const extensionAdapter = new CursorExtensionAdapter( + session.client, + backend, + (message) => this.handleAgentMessage(message) + ); + this.extensionAdapter = extensionAdapter; + + this.permissionAdapter = new PermissionAdapter( + session.client, + backend, + () => session.getPermissionMode(), + (response) => extensionAdapter.handlePermissionResponse(response) + ); + + const resumeSessionId = session.sessionId; + const mcpServerList = toAcpMcpServers(mcpServers); + let acpSessionId: string; + + if (resumeSessionId && backend.supportsLoadSession()) { + try { + acpSessionId = await backend.loadSession({ + sessionId: resumeSessionId, + cwd: session.path, + mcpServers: mcpServerList + }); + } catch (error) { + logger.warn('[cursor-acp] session/load failed', error); + throw new Error( + 'Failed to resume Cursor ACP session. Legacy stream-json sessions cannot be loaded via ACP.' + ); + } + } else if (resumeSessionId) { + throw new Error( + 'Cursor ACP session/load is not supported by this agent build. Start a new Cursor session.' + ); + } else { + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: mcpServerList + }); + } + + session.onSessionFoundWithProtocol(acpSessionId, 'acp'); + + syncCursorModelsFromAcp(backend, acpSessionId); + + const initialMetadata = backend.getSessionModelsMetadata(acpSessionId); + this.currentBackendModel = initialMetadata?.currentModelId ?? session.model ?? null; + this.defaultBackendModel = this.currentBackendModel; + + const previousSetModel = session.setModel.bind(session); + + await applyCursorAcpMode(backend, acpSessionId, session.getPermissionMode() as PermissionMode); + if (session.model) { + await this.applyLiveModel(backend, acpSessionId, session.model, previousSetModel, { + optimistic: false, + throwOnFailure: false + }); + } else if (this.currentBackendModel && !isSpawnDefaultModel(this.currentBackendModel)) { + this.pushModelStatusLine(this.currentBackendModel); + } + + this.installLiveSessionConfigSync(backend, acpSessionId, previousSetModel); + + this.applyDisplayMode(session.getPermissionMode() as PermissionMode); + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + while (!this.shouldExit) { + const waitSignal = this.abortController.signal; + const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { + if (waitSignal.aborted && !this.shouldExit) { + continue; + } + break; + } + + const requestedModel = batch.mode.model === null + ? this.defaultBackendModel + : batch.mode.model; + + const modelChanged = Boolean( + requestedModel && requestedModel !== this.currentBackendModel + ); + if (modelChanged) { + const appliedModel = await this.applyLiveModel( + backend, + acpSessionId, + requestedModel, + previousSetModel, + { optimistic: false, throwOnFailure: false } + ); + batch.mode.model = appliedModel ?? this.currentBackendModel ?? undefined; + } + + await applyCursorAcpMode(backend, acpSessionId, batch.mode.permissionMode as PermissionMode); + this.applyDisplayMode(batch.mode.permissionMode as PermissionMode); + messageBuffer.addMessage(batch.message, 'user'); + + const promptContent: PromptContent[] = [{ + type: 'text', + text: batch.message + }]; + + session.onThinkingChange(true); + + try { + await backend.prompt(acpSessionId, promptContent, (message) => { + this.handleAgentMessage(message); + }); + } catch (error) { + logger.warn('[cursor-acp] prompt failed', error); + const errMsg = error instanceof Error ? error.message : String(error); + session.sendSessionEvent({ + type: 'message', + message: `Cursor Agent failed: ${errMsg}` + }); + messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + } finally { + session.onThinkingChange(false); + await this.permissionAdapter?.cancelAll('Prompt finished'); + await this.extensionAdapter?.cancelAll('Prompt finished'); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + } + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + this.unregisterModelApplyHandler?.(); + this.unregisterModelApplyHandler = null; + + if (this.permissionAdapter) { + await this.permissionAdapter.cancelAll('Session ended'); + this.permissionAdapter = null; + } + + if (this.extensionAdapter) { + await this.extensionAdapter.cancelAll('Session ended'); + this.extensionAdapter = null; + } + + if (this.backend) { + await this.backend.disconnect(); + this.backend = null; + } + + if (this.happyServer) { + this.happyServer.stop(); + this.happyServer = null; + } + + setCursorAcpModelsSnapshot(null); + } + + private handleAgentMessage(message: AgentMessage): void { + const converted = convertAgentMessage(message); + if (converted) { + this.session.sendAgentMessage(converted); + } + + switch (message.type) { + case 'text': + this.messageBuffer.addMessage(message.text, 'assistant'); + break; + case 'reasoning': + break; + case 'usage': + break; + case 'tool_call': + this.messageBuffer.addMessage(`Tool: ${message.name}`, 'tool'); + break; + case 'tool_result': + this.messageBuffer.addMessage('Tool result', 'result'); + break; + case 'plan': + this.messageBuffer.addMessage('Plan updated', 'status'); + break; + case 'turn_complete': + break; + default: + break; + } + } + + private installLiveSessionConfigSync( + backend: AcpSdkBackend, + acpSessionId: string, + previousSetModel: CursorSession['setModel'] + ): void { + const session = this.session; + const previousSetPermissionMode = session.setPermissionMode.bind(session); + session.setPermissionMode = (mode: PermissionMode) => { + previousSetPermissionMode(mode); + void applyCursorAcpMode(backend, acpSessionId, mode).then(() => { + this.applyDisplayMode(mode); + }); + }; + + this.unregisterModelApplyHandler = session.registerModelApplyHandler(async (model) => ( + await this.applyLiveModel(backend, acpSessionId, model, previousSetModel, { + optimistic: false, + throwOnFailure: true + }) + )); + + session.setModel = (model: string | null | undefined) => { + void this.applyLiveModel(backend, acpSessionId, model, previousSetModel, { + optimistic: true, + throwOnFailure: false + }).catch((error) => { + logger.warn('[cursor-acp] Failed to apply model from session sync', error); + }); + }; + } + + private async applyLiveModel( + backend: AcpSdkBackend, + acpSessionId: string, + model: string | null | undefined, + previousSetModel: CursorSession['setModel'], + options: { optimistic: boolean; throwOnFailure: boolean } + ): Promise { + const requested = model?.trim(); + const previousModel = this.currentBackendModel ?? this.session.model ?? null; + const applySeq = ++this.modelApplySeq; + + if (!requested || isSpawnDefaultModel(requested)) { + const modelOption = backend.getConfigOptionByCategory?.(acpSessionId, 'model'); + const defaultWire = modelOption?.options?.find( + (option) => isSpawnDefaultModel(option.value) + )?.value; + if (modelOption && defaultWire && backend.setConfigOption) { + try { + await backend.setConfigOption(acpSessionId, modelOption.id, defaultWire); + backend.pinSessionModelWireId(acpSessionId, defaultWire); + } catch (error) { + logger.debug('[cursor-acp] Failed to set default model via ACP', error); + if (options.throwOnFailure) { + throw new Error('Cursor default model is not available via ACP'); + } + } + } else if (options.throwOnFailure) { + throw new Error('Cursor default model is not available via ACP'); + } + this.currentBackendModel = null; + previousSetModel(undefined); + this.session.pushKeepAlive(); + syncCursorModelsFromAcp(backend, acpSessionId); + return null; + } + + if (options.optimistic) { + const optimisticWire = wireIdForCursorSessionState(requested, requested); + this.currentBackendModel = optimisticWire; + previousSetModel(optimisticWire); + this.session.pushKeepAlive(); + } + + const result = await applyCursorAcpModel(backend, acpSessionId, requested); + if (!result.applied || !result.resolvedWireId) { + const message = `Cursor model is not available via ACP: ${requested}`; + logger.warn(`[cursor-acp] ${message}`); + + if (options.optimistic && applySeq === this.modelApplySeq) { + this.currentBackendModel = previousModel; + previousSetModel(previousModel ?? undefined); + this.session.pushKeepAlive(); + } else if (!options.throwOnFailure && previousModel && !isSpawnDefaultModel(previousModel)) { + this.currentBackendModel = previousModel; + previousSetModel(previousModel); + this.session.pushKeepAlive(); + } + syncCursorModelsFromAcp(backend, acpSessionId); + + if (options.throwOnFailure) { + throw new Error(message); + } + return previousModel; + } + + const sessionWire = wireIdForCursorSessionState( + result.requestedWireId ?? requested, + result.resolvedWireId + ); + + if (applySeq !== this.modelApplySeq) { + return this.currentBackendModel; + } + + const changed = sessionWire !== this.currentBackendModel || this.session.model !== sessionWire; + this.currentBackendModel = sessionWire; + previousSetModel(sessionWire); + if (changed) { + this.pushModelStatusLine(sessionWire); + } + this.session.pushKeepAlive(); + syncCursorModelsFromAcp(backend, acpSessionId); + return sessionWire; + } + + private pushModelStatusLine(model: string | null | undefined): void { + const trimmed = model?.trim(); + if (!trimmed || isSpawnDefaultModel(trimmed)) { + this.messageBuffer.addMessage('[MODEL:auto]', 'system'); + return; + } + this.messageBuffer.addMessage(`[MODEL:${trimmed}]`, 'system'); + } + + private applyDisplayMode(permissionMode: PermissionMode | undefined): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode; + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); + } + } + + private async handleAbort(): Promise { + const backend = this.backend; + const sessionId = this.session.sessionId; + if (backend && sessionId) { + await backend.cancelPrompt(sessionId); + } + await this.permissionAdapter?.cancelAll('User aborted'); + await this.extensionAdapter?.cancelAll('User aborted'); + this.session.queue.reset(); + this.session.onThinkingChange(false); + this.abortController.abort(); + this.abortController = new AbortController(); + this.messageBuffer.addMessage('Turn aborted', 'status'); + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()); + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } +} + +function isSpawnDefaultModel(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase(); + return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; +} + +function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string): void { + const snapshot = buildCursorModelsSnapshotFromAcp(backend, acpSessionId); + if (!snapshot) { + return; + } + + setCursorAcpModelsSnapshot(snapshot); + seedCursorModelsCache({ success: true, ...snapshot }); +} + +function toAcpMcpServers(config: Record): McpServerStdio[] { + return Object.entries(config).map(([name, entry]) => ({ + name, + command: entry.command, + args: entry.args, + env: [] + })); +} + +export async function cursorAcpRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> { + const launcher = new CursorAcpRemoteLauncher(session); + return launcher.launch(); +} diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts new file mode 100644 index 00000000..46d30548 --- /dev/null +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', () => ({ + spawn: spawnMock +})); + +vi.mock('@/ui/logger', () => ({ + logger: { debug: vi.fn(), warn: vi.fn() } +})); + +vi.mock('@/agent/messageConverter', () => ({ + convertAgentMessage: () => null +})); + +vi.mock('@/ui/ink/OpencodeDisplay', () => ({ + OpencodeDisplay: () => null +})); + +vi.mock('@/utils/process', () => ({ + killProcessByChildProcess: vi.fn(async () => {}) +})); + +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { CursorSession } from './session'; +import type { EnhancedMode } from './loop'; + +function makeChild() { + const stdoutHandlers: Array<(chunk: string) => void> = []; + return { + stdout: { on: vi.fn((event: string, handler: (chunk: string) => void) => { + if (event === 'data') stdoutHandlers.push(handler); + }) }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'exit') { + setImmediate(() => handler(0, null)); + } + }), + emitStdout(line: string) { + for (const handler of stdoutHandlers) { + handler(`${line}\n`); + } + } + }; +} + +describe('cursorLegacyRemoteLauncher', () => { + beforeEach(() => { + spawnMock.mockReset(); + process.stdin.isTTY = false; + process.stdout.isTTY = false; + }); + + it('spawns agent with stream-json and trust, not acp', async () => { + const child = makeChild(); + spawnMock.mockReturnValue(child); + + const queue = new MessageQueue2(() => 'm'); + queue.push('hello', { permissionMode: 'default' }); + queue.close(); + + const metadataUpdates: unknown[] = []; + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn((handler: (m: Record) => Record) => { + metadataUpdates.push(handler({ path: '/tmp', host: 'h', flavor: 'cursor' })); + }), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitMessagesConsumed: vi.fn() + }; + + const session = new CursorSession({ + api: {} as never, + client: client as never, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: 'legacy-id', + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote' + }); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const args = spawnMock.mock.calls[0]?.[1] as string[]; + expect(args).toContain('-p'); + expect(args).toContain('stream-json'); + expect(args).toContain('--trust'); + expect(args).toContain('--resume'); + expect(args).toContain('legacy-id'); + expect(args).not.toContain('acp'); + + expect(metadataUpdates[0]).toEqual(expect.objectContaining({ + cursorSessionId: 'legacy-id', + cursorSessionProtocol: 'stream-json' + })); + }); +}); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts new file mode 100644 index 00000000..5d24ab9d --- /dev/null +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -0,0 +1,270 @@ +import React from 'react'; +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { logger } from '@/ui/logger'; +import { killProcessByChildProcess } from '@/utils/process'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from '@/modules/common/remote/RemoteLauncherBase'; +import type { CursorSession } from './session'; +// TODO(cursor-acp): remove legacy stream-json resume path after migration window. +// New Cursor sessions use ACP only. This path exists because pre-ACP Cursor +// session_id values are not loadable via ACP session/load. + +import type { CursorStreamEvent } from './utils/cursorLegacyEventConverter'; +import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorLegacyEventConverter'; +import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; + +function buildAgentArgs(opts: { + message: string; + cwd: string; + sessionId: string | null; + mode?: string; + model?: string; + yolo?: boolean; +}): string[] { + const args = ['-p', opts.message, '--output-format', 'stream-json', '--trust', '--workspace', opts.cwd]; + + if (opts.sessionId) { + args.push('--resume', opts.sessionId); + } + if (opts.mode && (opts.mode === 'plan' || opts.mode === 'ask' || opts.mode === 'debug')) { + args.push('--mode', opts.mode); + } + if (opts.model) { + args.push('--model', opts.model); + } + if (opts.yolo) { + args.push('--yolo'); + } + + return args; +} + +function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean } { + if (mode === 'plan') return { mode: 'plan' }; + if (mode === 'ask') return { mode: 'ask' }; + if (mode === 'debug') return { mode: 'debug' }; + if (mode === 'yolo') return { yolo: true }; + return {}; +} + +class CursorRemoteLauncher extends RemoteLauncherBase { + private readonly session: CursorSession; + private abortController = new AbortController(); + private displayPermissionMode: string | null = null; + + constructor(session: CursorSession) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(OpencodeDisplay, context); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + let cursorSessionId: string | null = session.sessionId; + if (cursorSessionId) { + session.onSessionFoundWithProtocol(cursorSessionId, 'stream-json'); + } + + while (!this.shouldExit) { + const waitSignal = this.abortController.signal; + const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { + if (waitSignal.aborted && !this.shouldExit) { + continue; + } + break; + } + + const { message, mode } = batch; + const specialCommand = parseCursorSpecialCommand(message); + + const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); + this.applyDisplayMode(mode.permissionMode as string); + messageBuffer.addMessage(message, 'user'); + + if (specialCommand.type === 'pass-through') { + logger.debug(`[cursor-remote] /${specialCommand.command} — pass-through to agent -p`); + messageBuffer.addMessage(cursorPassThroughStatusMessage(specialCommand.command), 'status'); + } + + const args = buildAgentArgs({ + message, + cwd: session.path, + sessionId: cursorSessionId, + mode: agentMode, + model: mode.model, + yolo + }); + + logger.debug(`[cursor-remote] Spawning agent with args: ${args.join(' ')}`); + + session.onThinkingChange(true); + + try { + const exitCode = await this.runAgentProcess(args, session.path, (event) => { + if (event.type === 'system' && event.subtype === 'init' && event.session_id) { + cursorSessionId = event.session_id; + session.onSessionFoundWithProtocol(event.session_id, 'stream-json'); + } else if (event.type === 'thinking') { + if (event.subtype === 'completed') { + // keep thinking until we get assistant/result + } + } else if (event.type === 'assistant' || event.type === 'tool_call' || event.type === 'result') { + const agentMsg = convertCursorEventToAgentMessage(event); + if (agentMsg) { + const codexMsg = convertAgentMessage(agentMsg); + if (codexMsg) { + session.sendAgentMessage(codexMsg); + } + switch (agentMsg.type) { + case 'text': + messageBuffer.addMessage(agentMsg.text, 'assistant'); + break; + case 'tool_call': + messageBuffer.addMessage(`Tool: ${agentMsg.name}`, 'tool'); + break; + case 'tool_result': + messageBuffer.addMessage('Tool result', 'result'); + break; + case 'turn_complete': + break; + default: + break; + } + } + } + }); + + if (exitCode !== 0 && exitCode !== null) { + logger.debug(`[cursor-remote] Agent exited with code ${exitCode}`); + messageBuffer.addMessage(`Agent exited with code ${exitCode}`, 'status'); + } + } catch (error) { + logger.warn('[cursor-remote] Agent run failed', error); + const errMsg = error instanceof Error ? error.message : String(error); + session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); + messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + } finally { + session.onThinkingChange(false); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + } + } + } + + private runAgentProcess( + args: string[], + cwd: string, + onEvent: (event: ReturnType & object) => void + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn('agent', args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: process.platform === 'win32' + }); + + const abortHandler = () => { + killProcessByChildProcess(child, false).catch(() => {}); + resolve(null); + }; + this.abortController.signal.addEventListener('abort', abortHandler); + + const cleanup = () => { + this.abortController.signal.removeEventListener('abort', abortHandler); + }; + + child.on('error', (err) => { + cleanup(); + reject(err); + }); + + child.on('exit', (code, signal) => { + cleanup(); + resolve(code); + }); + + const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); + rl.on('line', (line) => { + const event = parseCursorEvent(line); + if (event) { + onEvent(event); + } + }); + + child.stderr?.on('data', (chunk) => { + const text = chunk.toString(); + if (text.trim()) { + logger.debug('[cursor-remote] agent stderr:', text.trim()); + } + }); + }); + } + + private applyDisplayMode(permissionMode: string | undefined): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode; + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + this.abortController.abort(); + } + + private async handleAbort(): Promise { + this.session.queue.reset(); + this.session.onThinkingChange(false); + this.abortController.abort(); + this.abortController = new AbortController(); + this.messageBuffer.addMessage('Turn aborted', 'status'); + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()); + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } +} + +export async function cursorLegacyRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> { + const launcher = new CursorRemoteLauncher(session); + return launcher.launch(); +} diff --git a/cli/src/cursor/cursorLocal.ts b/cli/src/cursor/cursorLocal.ts index a979f68e..36c5ac17 100644 --- a/cli/src/cursor/cursorLocal.ts +++ b/cli/src/cursor/cursorLocal.ts @@ -24,7 +24,7 @@ export async function cursorLocal(opts: { chatId: string | null; path: string; model?: string; - mode?: 'plan' | 'ask'; + mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean; onChatFound?: (chatId: string) => void; cursorArgs?: string[]; diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index 7998f6e6..2e26a24b 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -3,13 +3,16 @@ import { cursorLocal } from './cursorLocal'; import { CursorSession } from './session'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; -function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask'; yolo?: boolean } { +function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean } { if (mode === 'plan') { return { mode: 'plan' }; } if (mode === 'ask') { return { mode: 'ask' }; } + if (mode === 'debug') { + return { mode: 'debug' }; + } if (mode === 'yolo') { return { yolo: true }; } diff --git a/cli/src/cursor/cursorRemoteLauncher.test.ts b/cli/src/cursor/cursorRemoteLauncher.test.ts new file mode 100644 index 00000000..c91fe1ec --- /dev/null +++ b/cli/src/cursor/cursorRemoteLauncher.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Metadata } from '@hapi/protocol/schemas'; +import type { CursorSession } from './session'; + +const legacyLauncher = vi.hoisted(() => vi.fn(async () => 'exit' as const)); +const acpLauncher = vi.hoisted(() => vi.fn(async () => 'exit' as const)); + +vi.mock('./cursorLegacyRemoteLauncher', () => ({ + cursorLegacyRemoteLauncher: legacyLauncher +})); + +vi.mock('./cursorAcpRemoteLauncher', () => ({ + cursorAcpRemoteLauncher: acpLauncher +})); + +import { cursorRemoteLauncher } from './cursorRemoteLauncher'; + +const baseMetadata: Metadata = { + flavor: 'cursor', + path: '/tmp', + host: 'test' +}; + +function makeSession(): CursorSession { + return { path: '/tmp' } as CursorSession; +} + +describe('cursorRemoteLauncher', () => { + beforeEach(() => { + legacyLauncher.mockClear(); + acpLauncher.mockClear(); + acpLauncher.mockResolvedValue('exit'); + legacyLauncher.mockResolvedValue('exit'); + }); + + it('uses ACP launcher for new sessions without cursorSessionId', async () => { + await cursorRemoteLauncher(makeSession(), baseMetadata); + + expect(acpLauncher).toHaveBeenCalledTimes(1); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); + + it('uses legacy launcher only when metadata marks a pre-ACP session', async () => { + const legacyMetadata: Metadata = { + ...baseMetadata, + cursorSessionId: 'old-stream-json-id', + cursorSessionProtocol: 'stream-json' + }; + + await cursorRemoteLauncher(makeSession(), legacyMetadata); + + expect(legacyLauncher).toHaveBeenCalledTimes(1); + expect(acpLauncher).not.toHaveBeenCalled(); + }); + + it('uses legacy launcher when cursorSessionId exists without protocol (pre-migration)', async () => { + const legacyMetadata: Metadata = { + ...baseMetadata, + cursorSessionId: 'old-stream-json-id' + }; + + await cursorRemoteLauncher(makeSession(), legacyMetadata); + + expect(legacyLauncher).toHaveBeenCalledTimes(1); + expect(acpLauncher).not.toHaveBeenCalled(); + }); + + it('uses ACP launcher when cursorSessionProtocol is acp even with session id', async () => { + const acpMetadata: Metadata = { + ...baseMetadata, + cursorSessionId: 'acp-session-id', + cursorSessionProtocol: 'acp' + }; + + await cursorRemoteLauncher(makeSession(), acpMetadata); + + expect(acpLauncher).toHaveBeenCalledTimes(1); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); + + it('does not fallback to stream-json when the ACP launcher fails', async () => { + acpLauncher.mockRejectedValueOnce(new Error('Cursor ACP unavailable')); + + await expect(cursorRemoteLauncher(makeSession(), baseMetadata)).rejects.toThrow('Cursor ACP unavailable'); + + expect(acpLauncher).toHaveBeenCalledTimes(1); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); + + it('does not fallback to stream-json when ACP resume fails for an acp-marked session', async () => { + const acpMetadata: Metadata = { + ...baseMetadata, + cursorSessionId: 'acp-session-id', + cursorSessionProtocol: 'acp' + }; + acpLauncher.mockRejectedValueOnce(new Error('Failed to resume Cursor ACP session')); + + await expect(cursorRemoteLauncher(makeSession(), acpMetadata)).rejects.toThrow('Failed to resume Cursor ACP session'); + + expect(acpLauncher).toHaveBeenCalledTimes(1); + expect(legacyLauncher).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/cursor/cursorRemoteLauncher.ts b/cli/src/cursor/cursorRemoteLauncher.ts index 27b2fe72..cfcacc6d 100644 --- a/cli/src/cursor/cursorRemoteLauncher.ts +++ b/cli/src/cursor/cursorRemoteLauncher.ts @@ -1,277 +1,16 @@ -import React from 'react'; -import { spawn } from 'node:child_process'; -import { createInterface } from 'node:readline'; -import { logger } from '@/ui/logger'; -import { killProcessByChildProcess } from '@/utils/process'; -import { convertAgentMessage } from '@/agent/messageConverter'; -import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; -import { - RemoteLauncherBase, - type RemoteLauncherDisplayContext, - type RemoteLauncherExitReason -} from '@/modules/common/remote/RemoteLauncherBase'; +import type { Metadata } from '@hapi/protocol/schemas'; import type { CursorSession } from './session'; -import type { CursorStreamEvent } from './utils/cursorEventConverter'; -import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorEventConverter'; -import { parseCursorSpecialCommand } from './cursorSpecialCommands'; +import { cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher'; +import { cursorLegacyRemoteLauncher } from './cursorLegacyRemoteLauncher'; +import { resolveCursorRemoteProtocol } from './utils/cursorProtocol'; -function buildAgentArgs(opts: { - message: string; - cwd: string; - sessionId: string | null; - mode?: string; - model?: string; - yolo?: boolean; -}): string[] { - const args = ['-p', opts.message, '--output-format', 'stream-json', '--trust', '--workspace', opts.cwd]; - - if (opts.sessionId) { - args.push('--resume', opts.sessionId); +export async function cursorRemoteLauncher( + session: CursorSession, + metadata?: Metadata | null +): Promise<'switch' | 'exit'> { + const protocol = resolveCursorRemoteProtocol(metadata); + if (protocol === 'stream-json') { + return cursorLegacyRemoteLauncher(session); } - if (opts.mode && (opts.mode === 'plan' || opts.mode === 'ask')) { - args.push('--mode', opts.mode); - } - if (opts.model) { - args.push('--model', opts.model); - } - if (opts.yolo) { - args.push('--yolo'); - } - - return args; -} - -function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean } { - if (mode === 'plan') return { mode: 'plan' }; - if (mode === 'ask') return { mode: 'ask' }; - if (mode === 'yolo') return { yolo: true }; - return {}; -} - -class CursorRemoteLauncher extends RemoteLauncherBase { - private readonly session: CursorSession; - private abortController = new AbortController(); - private displayPermissionMode: string | null = null; - - constructor(session: CursorSession) { - super(process.env.DEBUG ? session.logPath : undefined); - this.session = session; - } - - public async launch(): Promise { - return this.start({ - onExit: () => this.handleExitFromUi(), - onSwitchToLocal: () => this.handleSwitchFromUi() - }); - } - - protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { - return React.createElement(OpencodeDisplay, context); - } - - protected async runMainLoop(): Promise { - const session = this.session; - const messageBuffer = this.messageBuffer; - - this.setupAbortHandlers(session.client.rpcHandlerManager, { - onAbort: () => this.handleAbort(), - onSwitch: () => this.handleSwitchRequest() - }); - - const sendReady = () => { - session.sendSessionEvent({ type: 'ready' }); - }; - - let cursorSessionId: string | null = session.sessionId; - if (cursorSessionId) { - session.onSessionFound(cursorSessionId); - } - - while (!this.shouldExit) { - const waitSignal = this.abortController.signal; - const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); - if (!batch) { - if (waitSignal.aborted && !this.shouldExit) { - continue; - } - break; - } - - const { message, mode } = batch; - const specialCommand = parseCursorSpecialCommand(message); - - if (specialCommand.type === 'invalid') { - session.sendSessionEvent({ type: 'message', message: specialCommand.message }); - messageBuffer.addMessage(specialCommand.message, 'status'); - if (session.queue.size() === 0 && !this.shouldExit) { - sendReady(); - } - continue; - } - - const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); - this.applyDisplayMode(mode.permissionMode as string); - messageBuffer.addMessage(message, 'user'); - - if (specialCommand.type === 'summarize') { - logger.debug('[cursor-remote] /summarize — pass-through to agent -p'); - messageBuffer.addMessage('Context summarization requested', 'status'); - } else if (specialCommand.type === 'clear') { - logger.debug('[cursor-remote] /clear — pass-through to agent -p'); - messageBuffer.addMessage('Context clear requested', 'status'); - } - - const args = buildAgentArgs({ - message, - cwd: session.path, - sessionId: cursorSessionId, - mode: agentMode, - model: mode.model, - yolo - }); - - logger.debug(`[cursor-remote] Spawning agent with args: ${args.join(' ')}`); - - session.onThinkingChange(true); - - try { - const exitCode = await this.runAgentProcess(args, session.path, (event) => { - if (event.type === 'system' && event.subtype === 'init' && event.session_id) { - cursorSessionId = event.session_id; - session.onSessionFound(event.session_id); - } else if (event.type === 'thinking') { - if (event.subtype === 'completed') { - // keep thinking until we get assistant/result - } - } else if (event.type === 'assistant' || event.type === 'tool_call' || event.type === 'result') { - const agentMsg = convertCursorEventToAgentMessage(event); - if (agentMsg) { - const codexMsg = convertAgentMessage(agentMsg); - if (codexMsg) { - session.sendAgentMessage(codexMsg); - } - switch (agentMsg.type) { - case 'text': - messageBuffer.addMessage(agentMsg.text, 'assistant'); - break; - case 'tool_call': - messageBuffer.addMessage(`Tool: ${agentMsg.name}`, 'tool'); - break; - case 'tool_result': - messageBuffer.addMessage('Tool result', 'result'); - break; - case 'turn_complete': - break; - default: - break; - } - } - } - }); - - if (exitCode !== 0 && exitCode !== null) { - logger.debug(`[cursor-remote] Agent exited with code ${exitCode}`); - messageBuffer.addMessage(`Agent exited with code ${exitCode}`, 'status'); - } - } catch (error) { - logger.warn('[cursor-remote] Agent run failed', error); - const errMsg = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); - messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); - } finally { - session.onThinkingChange(false); - if (session.queue.size() === 0 && !this.shouldExit) { - sendReady(); - } - } - } - } - - private runAgentProcess( - args: string[], - cwd: string, - onEvent: (event: ReturnType & object) => void - ): Promise { - return new Promise((resolve, reject) => { - const child = spawn('agent', args, { - cwd, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', - windowsHide: process.platform === 'win32' - }); - - const abortHandler = () => { - killProcessByChildProcess(child, false).catch(() => {}); - resolve(null); - }; - this.abortController.signal.addEventListener('abort', abortHandler); - - const cleanup = () => { - this.abortController.signal.removeEventListener('abort', abortHandler); - }; - - child.on('error', (err) => { - cleanup(); - reject(err); - }); - - child.on('exit', (code, signal) => { - cleanup(); - resolve(code); - }); - - const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); - rl.on('line', (line) => { - const event = parseCursorEvent(line); - if (event) { - onEvent(event); - } - }); - - child.stderr?.on('data', (chunk) => { - const text = chunk.toString(); - if (text.trim()) { - logger.debug('[cursor-remote] agent stderr:', text.trim()); - } - }); - }); - } - - private applyDisplayMode(permissionMode: string | undefined): void { - if (permissionMode && permissionMode !== this.displayPermissionMode) { - this.displayPermissionMode = permissionMode; - this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); - } - } - - protected async cleanup(): Promise { - this.clearAbortHandlers(this.session.client.rpcHandlerManager); - this.abortController.abort(); - } - - private async handleAbort(): Promise { - this.session.queue.reset(); - this.session.onThinkingChange(false); - this.abortController.abort(); - this.abortController = new AbortController(); - this.messageBuffer.addMessage('Turn aborted', 'status'); - } - - private async handleExitFromUi(): Promise { - await this.requestExit('exit', () => this.handleAbort()); - } - - private async handleSwitchFromUi(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } - - private async handleSwitchRequest(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } -} - -export async function cursorRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> { - const launcher = new CursorRemoteLauncher(session); - return launcher.launch(); + return cursorAcpRemoteLauncher(session); } diff --git a/cli/src/cursor/cursorSpecialCommands.test.ts b/cli/src/cursor/cursorSpecialCommands.test.ts index 920035b7..8cd6aa0f 100644 --- a/cli/src/cursor/cursorSpecialCommands.test.ts +++ b/cli/src/cursor/cursorSpecialCommands.test.ts @@ -1,33 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { parseCursorSpecialCommand } from './cursorSpecialCommands'; +import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; describe('parseCursorSpecialCommand', () => { - it('accepts /summarize with optional instructions', () => { - expect(parseCursorSpecialCommand('/summarize')).toEqual({ - type: 'summarize', - message: '/summarize' + it('accepts /compress with optional instructions', () => { + expect(parseCursorSpecialCommand('/compress')).toEqual({ + type: 'pass-through', + command: 'compress', + message: '/compress' }); - expect(parseCursorSpecialCommand(' /summarize keep peer relocate recap ')).toEqual({ - type: 'summarize', - message: '/summarize keep peer relocate recap' + expect(parseCursorSpecialCommand(' /compress keep recap ')).toEqual({ + type: 'pass-through', + command: 'compress', + message: '/compress keep recap' }); }); - it('accepts exact /clear', () => { - expect(parseCursorSpecialCommand(' /clear ')).toEqual({ type: 'clear' }); + it('ignores removed or unknown slash commands', () => { + expect(parseCursorSpecialCommand('/context')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/context now')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/summarize')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/clear')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/debug')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/compressor')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/contextual')).toEqual({ type: null }); }); +}); - it('rejects /clear with arguments', () => { - expect(parseCursorSpecialCommand('/clear now')).toEqual({ - type: 'invalid', - command: 'clear', - message: '/clear does not accept arguments' - }); - }); - - it('ignores regular slash-like messages', () => { - expect(parseCursorSpecialCommand('/summarizer')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('please /summarize')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/clearing')).toEqual({ type: null }); +describe('cursorPassThroughStatusMessage', () => { + it('returns a status line for compress', () => { + expect(cursorPassThroughStatusMessage('compress')).toContain('compression'); }); }); diff --git a/cli/src/cursor/cursorSpecialCommands.ts b/cli/src/cursor/cursorSpecialCommands.ts index 5bafa0ff..75b5b11c 100644 --- a/cli/src/cursor/cursorSpecialCommands.ts +++ b/cli/src/cursor/cursorSpecialCommands.ts @@ -1,32 +1,49 @@ +export const CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS = ['compress', 'model'] as const; + +export type CursorPassThroughCommand = typeof CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS[number]; + export type CursorSpecialCommand = - | { type: 'summarize'; message: string } - | { type: 'clear' } - | { type: 'invalid'; command: 'clear'; message: string } + | { type: 'pass-through'; command: CursorPassThroughCommand; message: string } | { type: null }; +function matchCommandWithOptionalArgs(trimmed: string, command: CursorPassThroughCommand): string | null { + const prefix = `/${command}`; + if (trimmed === prefix) { + return trimmed; + } + if (trimmed.startsWith(`${prefix} `)) { + return trimmed; + } + return null; +} + /** * Parse Cursor-specific slash commands for remote sessions. - * Summarize accepts optional trailing instructions after the command. - * Messages are still passed verbatim to `agent -p` — this parser is for detection and UI contract only. + * Commands with optional trailing text are passed verbatim to the Cursor agent. + * This parser is for detection and UI contract only. */ export function parseCursorSpecialCommand(message: string): CursorSpecialCommand { const trimmed = message.trim(); - if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) { - return { type: 'summarize', message: trimmed }; - } - - if (trimmed === '/clear') { - return { type: 'clear' }; - } - - if (trimmed.startsWith('/clear ')) { - return { - type: 'invalid', - command: 'clear', - message: '/clear does not accept arguments' - }; + for (const command of CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS) { + const matched = matchCommandWithOptionalArgs(trimmed, command); + if (matched) { + return { type: 'pass-through', command, message: matched }; + } } return { type: null }; } + +export function cursorPassThroughStatusMessage(command: CursorPassThroughCommand): string { + switch (command) { + case 'compress': + return 'Context compression requested'; + case 'model': + return 'Model change requested'; + default: { + const exhaustive: never = command; + return exhaustive; + } + } +} diff --git a/cli/src/cursor/cursorUserMessageQueue.test.ts b/cli/src/cursor/cursorUserMessageQueue.test.ts index f99ce023..a80c7cf1 100644 --- a/cli/src/cursor/cursorUserMessageQueue.test.ts +++ b/cli/src/cursor/cursorUserMessageQueue.test.ts @@ -7,27 +7,17 @@ import type { EnhancedMode } from './loop'; const mode: EnhancedMode = { permissionMode: 'default' }; describe('enqueueCursorUserMessage', () => { - it('does not batch invalid /clear with a following prompt', async () => { + it('isolates /compress from a following same-mode prompt', async () => { const queue = new MessageQueue2((m) => m.permissionMode); - enqueueCursorUserMessage(queue, '/clear now', mode, 'a'); - enqueueCursorUserMessage(queue, 'continue work', mode, 'b'); - - const first = await queue.waitForMessagesAndGetAsString(); - expect(first?.message).toBe('/clear now'); - expect(parseCursorSpecialCommand(first!.message).type).toBe('invalid'); - - const second = await queue.waitForMessagesAndGetAsString(); - expect(second?.message).toBe('continue work'); - }); - - it('isolates /summarize from a following same-mode prompt', async () => { - const queue = new MessageQueue2((m) => m.permissionMode); - enqueueCursorUserMessage(queue, '/summarize keep recap', mode, 'a'); + enqueueCursorUserMessage(queue, '/compress keep recap', mode, 'a'); enqueueCursorUserMessage(queue, 'next task', mode, 'b'); const first = await queue.waitForMessagesAndGetAsString(); - expect(first?.message).toBe('/summarize keep recap'); - expect(parseCursorSpecialCommand(first!.message).type).toBe('summarize'); + expect(first?.message).toBe('/compress keep recap'); + expect(parseCursorSpecialCommand(first!.message)).toMatchObject({ + type: 'pass-through', + command: 'compress' + }); const second = await queue.waitForMessagesAndGetAsString(); expect(second?.message).toBe('next task'); @@ -36,18 +26,18 @@ describe('enqueueCursorUserMessage', () => { it('preserves a normal prompt queued before a slash command', async () => { const queue = new MessageQueue2((m) => m.permissionMode); enqueueCursorUserMessage(queue, 'first work', mode, 'a'); - enqueueCursorUserMessage(queue, '/summarize', mode, 'b'); - enqueueCursorUserMessage(queue, 'after summarize', mode, 'c'); + enqueueCursorUserMessage(queue, '/compress', mode, 'b'); + enqueueCursorUserMessage(queue, 'after compress', mode, 'c'); const first = await queue.waitForMessagesAndGetAsString(); expect(first?.message).toBe('first work'); expect(first?.isolate).toBe(false); const second = await queue.waitForMessagesAndGetAsString(); - expect(second?.message).toBe('/summarize'); + expect(second?.message).toBe('/compress'); expect(second?.isolate).toBe(true); const third = await queue.waitForMessagesAndGetAsString(); - expect(third?.message).toBe('after summarize'); + expect(third?.message).toBe('after compress'); }); }); diff --git a/cli/src/cursor/loop.ts b/cli/src/cursor/loop.ts index 2a186b4a..c286f8dc 100644 --- a/cli/src/cursor/loop.ts +++ b/cli/src/cursor/loop.ts @@ -4,6 +4,7 @@ import { runLocalRemoteSession } from '@/agent/loopBase'; import { CursorSession } from './session'; import { cursorLocalLauncher } from './cursorLocalLauncher'; import { cursorRemoteLauncher } from './cursorRemoteLauncher'; +import type { Metadata } from '@hapi/protocol/schemas'; import { ApiClient, ApiSessionClient } from '@/lib'; import type { CursorPermissionMode } from '@hapi/protocol/types'; @@ -26,6 +27,7 @@ interface LoopOptions { permissionMode?: PermissionMode; resumeSessionId?: string; model?: string; + sessionMetadata?: Metadata | null; onSessionReady?: (session: CursorSession) => void; } @@ -54,7 +56,7 @@ export async function loop(opts: LoopOptions): Promise { startingMode: opts.startingMode, logTag: 'cursor-loop', runLocal: cursorLocalLauncher, - runRemote: cursorRemoteLauncher, + runRemote: (session) => cursorRemoteLauncher(session, opts.sessionMetadata), onSessionReady: opts.onSessionReady }); } diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 282de7e9..d861b133 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -8,10 +8,14 @@ import type { CursorSession } from './session'; import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; +import { + resolveNullableSessionModel, + resolveSessionConfigPermissionMode +} from '@/agent/sessionConfigRpc'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { enqueueCursorUserMessage } from './cursorUserMessageQueue'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; const formatFailureReason = (message: string): string => { const maxLength = 200; @@ -106,20 +110,57 @@ export async function runCursor(opts: { return removed; }); - registerSessionConfigRpc({ - rpcHandlerManager: session.rpcHandlerManager, - flavor: 'cursor', - modelMode: 'nullable', - appliedFallback: () => ({ permissionMode: currentPermissionMode }), - onApply: (config) => { - if (config.permissionMode !== undefined) { - currentPermissionMode = config.permissionMode; + session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + + const config = payload as { + permissionMode?: unknown; + model?: unknown; + modelReasoningEffort?: unknown; + }; + const applied: { + permissionMode?: PermissionMode; + model?: string | null; + } = {}; + + if (config.modelReasoningEffort !== undefined) { + throw new Error('Invalid model reasoning effort'); + } + + const nextPermissionMode = config.permissionMode !== undefined + ? resolveSessionConfigPermissionMode(config.permissionMode, 'cursor') + : undefined; + + if (config.model !== undefined) { + const requestedModel = resolveNullableSessionModel(config.model); + const sessionInstance = sessionWrapperRef.current; + if (!sessionInstance?.canApplyModelConfig()) { + throw new Error('Cursor ACP session is not ready to apply model changes'); } - if (config.model !== undefined) { - currentModel = config.model ?? undefined; + + const appliedModel = await sessionInstance.applyModelConfig(requestedModel); + currentModel = appliedModel ?? undefined; + applied.model = appliedModel; + } + + if (nextPermissionMode !== undefined) { + currentPermissionMode = nextPermissionMode; + applied.permissionMode = currentPermissionMode; + + const sessionInstance = sessionWrapperRef.current; + if (sessionInstance) { + sessionInstance.setPermissionMode(currentPermissionMode); + sessionInstance.pushKeepAlive(); } - }, - onAfterApply: syncSessionMode + } + + return { + applied: Object.keys(applied).length > 0 + ? applied + : { permissionMode: currentPermissionMode } + }; }); let crashed = false; @@ -136,6 +177,7 @@ export async function runCursor(opts: { permissionMode: currentPermissionMode, resumeSessionId: opts.resumeSessionId, model: opts.model, + sessionMetadata: bootstrap.metadata, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/cursor/session.test.ts b/cli/src/cursor/session.test.ts new file mode 100644 index 00000000..c1b89533 --- /dev/null +++ b/cli/src/cursor/session.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { CursorSession } from './session'; +import type { EnhancedMode } from './loop'; + +describe('CursorSession', () => { + it('onSessionFoundWithProtocol writes cursorSessionId and protocol to metadata', () => { + const updates: unknown[] = []; + const client = { + updateMetadata: vi.fn((handler: (metadata: Record) => Record) => { + updates.push(handler({ path: '/tmp', host: 'h', flavor: 'cursor' })); + }), + keepAlive: vi.fn(), + emitMessagesConsumed: vi.fn() + }; + + const session = new CursorSession({ + api: {} as never, + client: client as never, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + messageQueue: new MessageQueue2(() => 'hash'), + onModeChange: vi.fn(), + startedBy: 'runner', + startingMode: 'remote', + mode: 'remote' + }); + + session.onSessionFoundWithProtocol('acp-session-99', 'acp'); + + expect(session.sessionId).toBe('acp-session-99'); + expect(updates[0]).toEqual({ + path: '/tmp', + host: 'h', + flavor: 'cursor', + cursorSessionId: 'acp-session-99', + cursorSessionProtocol: 'acp' + }); + }); +}); diff --git a/cli/src/cursor/session.ts b/cli/src/cursor/session.ts index 427e00e2..06851ce9 100644 --- a/cli/src/cursor/session.ts +++ b/cli/src/cursor/session.ts @@ -3,18 +3,22 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode, PermissionMode } from './loop'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; +import type { CursorSessionProtocol } from './utils/cursorProtocol'; type LocalLaunchFailure = { message: string; exitReason: LocalLaunchExitReason; }; +type CursorModelApplyHandler = (model: string | null | undefined) => Promise; + export class CursorSession extends AgentSessionBase { readonly cursorArgs?: string[]; model?: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; + private modelApplyHandler: CursorModelApplyHandler | null = null; constructor(opts: { api: ApiClient; @@ -42,9 +46,10 @@ export class CursorSession extends AgentSessionBase { mode: opts.mode, sessionLabel: 'CursorSession', sessionIdLabel: 'Cursor', - applySessionIdToMetadata: (metadata, sessionId) => ({ + applySessionIdToMetadata: (metadata, sessionId, extras) => ({ ...metadata, - cursorSessionId: sessionId + cursorSessionId: sessionId, + ...extras }), permissionMode: opts.permissionMode }); @@ -64,6 +69,24 @@ export class CursorSession extends AgentSessionBase { this.model = model ?? undefined; }; + registerModelApplyHandler = (handler: CursorModelApplyHandler): (() => void) => { + this.modelApplyHandler = handler; + return () => { + if (this.modelApplyHandler === handler) { + this.modelApplyHandler = null; + } + }; + }; + + canApplyModelConfig = (): boolean => this.modelApplyHandler !== null; + + applyModelConfig = async (model: string | null | undefined): Promise => { + if (!this.modelApplyHandler) { + throw new Error('Cursor ACP session is not ready to apply model changes'); + } + return await this.modelApplyHandler(model); + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; @@ -79,4 +102,8 @@ export class CursorSession extends AgentSessionBase { sendSessionEvent = (event: Parameters[0]): void => { this.client.sendSessionEvent(event); }; + + onSessionFoundWithProtocol = (sessionId: string, protocol: CursorSessionProtocol): void => { + this.onSessionFound(sessionId, { cursorSessionProtocol: protocol }); + }; } diff --git a/cli/src/cursor/utils/cursorAcpBackend.test.ts b/cli/src/cursor/utils/cursorAcpBackend.test.ts new file mode 100644 index 00000000..2ee4374f --- /dev/null +++ b/cli/src/cursor/utils/cursorAcpBackend.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createCursorAcpBackend, CURSOR_ACP_REQUIRED_MESSAGE } from './cursorAcpBackend'; + +describe('createCursorAcpBackend', () => { + it('uses agent acp command, not stream-json flags', () => { + const backend = createCursorAcpBackend({ cwd: '/tmp' }); + const internal = backend as unknown as { options: { command: string; args?: string[] } }; + + expect(internal.options.command).toBe('agent'); + expect(internal.options.args).toEqual(['acp']); + expect(internal.options.args).not.toContain('-p'); + expect(internal.options.args).not.toContain('stream-json'); + }); + + it('passes --model before acp when a concrete model is requested', () => { + const backend = createCursorAcpBackend({ + cwd: '/tmp', + model: 'composer-2.5[fast=true]' + }); + const internal = backend as unknown as { options: { args?: string[] } }; + + expect(internal.options.args).toEqual([ + '--model', + 'composer-2.5[fast=true]', + 'acp' + ]); + }); + + it('omits --model for default/auto spawn selection', () => { + const backend = createCursorAcpBackend({ cwd: '/tmp', model: 'auto' }); + const internal = backend as unknown as { options: { args?: string[] } }; + + expect(internal.options.args).toEqual(['acp']); + }); +}); + +describe('CURSOR_ACP_REQUIRED_MESSAGE', () => { + it('documents that stream-json is not a fallback for new sessions', () => { + expect(CURSOR_ACP_REQUIRED_MESSAGE).toMatch(/ACP/i); + expect(CURSOR_ACP_REQUIRED_MESSAGE).not.toMatch(/stream-json/i); + expect(CURSOR_ACP_REQUIRED_MESSAGE).not.toMatch(/fallback/i); + }); +}); diff --git a/cli/src/cursor/utils/cursorAcpBackend.ts b/cli/src/cursor/utils/cursorAcpBackend.ts new file mode 100644 index 00000000..119ab57f --- /dev/null +++ b/cli/src/cursor/utils/cursorAcpBackend.ts @@ -0,0 +1,33 @@ +import { AcpSdkBackend } from '@/agent/backends/acp'; + +function filterEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +function isDefaultSpawnModel(model: string | null | undefined): boolean { + if (!model) return true; + const normalized = model.trim().toLowerCase(); + return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; +} + +export function createCursorAcpBackend(opts: { cwd: string; model?: string | null }): AcpSdkBackend { + const args = ['acp']; + if (!isDefaultSpawnModel(opts.model)) { + args.unshift('--model', opts.model!.trim()); + } + + return new AcpSdkBackend({ + command: 'agent', + args, + env: filterEnv(process.env) + }); +} + +export const CURSOR_ACP_REQUIRED_MESSAGE = + 'Cursor ACP mode is required for new Cursor remote sessions. Run `agent update` and verify `agent help acp`.'; diff --git a/cli/src/cursor/utils/cursorAcpModelsBridge.ts b/cli/src/cursor/utils/cursorAcpModelsBridge.ts new file mode 100644 index 00000000..89efbc42 --- /dev/null +++ b/cli/src/cursor/utils/cursorAcpModelsBridge.ts @@ -0,0 +1,16 @@ +import type { CursorModelSummary } from '@hapi/protocol/apiTypes'; + +export type CursorModelsSnapshot = { + availableModels: CursorModelSummary[]; + currentModelId: string | null; +}; + +let snapshot: CursorModelsSnapshot | null = null; + +export function setCursorAcpModelsSnapshot(value: CursorModelsSnapshot | null): void { + snapshot = value; +} + +export function getCursorAcpModelsSnapshot(): CursorModelsSnapshot | null { + return snapshot; +} diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts new file mode 100644 index 00000000..2aaa652d --- /dev/null +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { buildCursorModelsSnapshotFromAcp } from './cursorAcpModelsSnapshot'; + +describe('buildCursorModelsSnapshotFromAcp', () => { + it('merges configOptions model variants over the shorter availableModels list', () => { + const backend = { + getSessionModelsMetadata: () => ({ + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }), + getConfigOptionByCategory: (_sessionId: string, category: string) => { + if (category !== 'model') return undefined; + return { + id: 'model-opt', + currentValue: 'composer-2.5[fast=true]', + options: [ + { value: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { value: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ] + }; + } + }; + + const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1'); + + expect(snapshot?.availableModels.map((entry) => entry.modelId).sort()).toEqual([ + 'composer-2.5[fast=false]', + 'composer-2.5[fast=true]' + ]); + expect(snapshot?.currentModelId).toBe('composer-2.5[fast=true]'); + }); + + it('uses configOptions alone when models metadata is missing', () => { + const backend = { + getSessionModelsMetadata: () => undefined, + getConfigOptionByCategory: () => ({ + id: 'model-opt', + currentValue: 'claude-opus-4-8[effort=high,fast=false]', + options: [ + { value: 'claude-opus-4-8[effort=high,fast=false]', name: 'Claude Opus 4.8' }, + { value: 'claude-opus-4-8[effort=low,fast=false]', name: 'Claude Opus 4.8' } + ] + }) + }; + + const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1'); + + expect(snapshot?.availableModels).toHaveLength(2); + }); +}); diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts new file mode 100644 index 00000000..3d836da1 --- /dev/null +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts @@ -0,0 +1,69 @@ +import type { AcpSdkBackend } from '@/agent/backends/acp'; +import type { CursorModelSummary } from '@hapi/protocol/apiTypes'; + +export type CursorModelsSnapshot = { + availableModels: CursorModelSummary[]; + currentModelId: string | null; +}; + +function mergeModelEntries( + target: Map, + entries: Iterable<{ modelId: string; name?: string | null }> +): void { + for (const entry of entries) { + const modelId = entry.modelId.trim(); + if (!modelId) continue; + + const name = entry.name?.trim(); + const existing = target.get(modelId); + if (!existing) { + target.set(modelId, name && name !== modelId ? { modelId, name } : { modelId }); + continue; + } + if (!existing.name && name && name !== modelId) { + target.set(modelId, { modelId, name }); + } + } +} + +/** + * Zed-style Cursor catalog: `configOptions` model category lists every wire id; + * `availableModels` alone is often one variant per base family. + */ +export function buildCursorModelsSnapshotFromAcp( + backend: Pick, + sessionId: string +): CursorModelsSnapshot | null { + const metadata = backend.getSessionModelsMetadata(sessionId); + const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + + if (!metadata && !modelOption) { + return null; + } + + const merged = new Map(); + + if (modelOption?.options?.length) { + mergeModelEntries(merged, modelOption.options.map((option) => ({ + modelId: option.value, + name: option.name + }))); + } + + if (metadata?.availableModels?.length) { + mergeModelEntries(merged, metadata.availableModels); + } + + if (merged.size === 0) { + return null; + } + + const currentModelId = metadata?.currentModelId + ?? modelOption?.currentValue + ?? null; + + return { + availableModels: [...merged.values()], + currentModelId + }; +} diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts new file mode 100644 index 00000000..7cee2942 --- /dev/null +++ b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentState } from '@/api/types'; +import type { AgentMessage } from '@/agent/types'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { CursorExtensionAdapter } from './cursorExtensionAdapter'; + +type ExtensionHandler = (params: unknown, requestId: string | number | null) => Promise; + +function createHarness() { + const handlers = new Map(); + let agentState: AgentState = { requests: {}, completedRequests: {} }; + const messages: AgentMessage[] = []; + + const session = { + updateAgentState(handler: (state: AgentState) => AgentState) { + agentState = handler(agentState); + } + } as unknown as ApiSessionClient; + + const backend = { + registerExtensionRequestHandler(method: string, handler: ExtensionHandler) { + handlers.set(method, handler); + } + } as unknown as AcpSdkBackend; + + const adapter = new CursorExtensionAdapter(session, backend, (message) => { + messages.push(message); + }); + + return { + handlers, + adapter, + getAgentState: () => agentState, + getMessages: () => messages + }; +} + +describe('CursorExtensionAdapter', () => { + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + }); + + it('queues cursor/ask_question as CursorAskQuestion pending request', async () => { + const { handlers, getAgentState } = createHarness(); + const handler = handlers.get('cursor/ask_question'); + expect(handler).toBeTypeOf('function'); + + const pending = handler!({ + toolCallId: 'q-1', + questions: [{ id: 'q1', prompt: 'Pick one', options: [{ id: 'a', label: 'A' }] }] + }, null); + + expect(getAgentState().requests).toMatchObject({ + 'q-1': { + tool: 'CursorAskQuestion', + createdAt: 1_700_000_000_000 + } + }); + + void pending; + }); + + it('resolves ask_question with answered outcome and formatted answers', async () => { + const { handlers, adapter } = createHarness(); + const pending = handlers.get('cursor/ask_question')!({ + toolCallId: 'q-1', + questions: [] + }, null); + + const handled = await adapter.handlePermissionResponse({ + id: 'q-1', + approved: true, + answers: { q1: ['opt-a'] } + }); + expect(handled).toBe(true); + await expect(pending).resolves.toEqual({ + outcome: 'answered', + answers: [{ questionId: 'q1', selectedOptionIds: ['opt-a'] }] + }); + }); + + it('resolves ask_question denial as cancelled', async () => { + const { handlers, adapter } = createHarness(); + const pending = handlers.get('cursor/ask_question')!({ toolCallId: 'q-2' }, null); + + await adapter.handlePermissionResponse({ + id: 'q-2', + approved: false, + decision: 'denied' + }); + + await expect(pending).resolves.toEqual({ outcome: 'cancelled' }); + }); + + it('resolves create_plan approval as accepted', async () => { + const { handlers, adapter } = createHarness(); + const pending = handlers.get('cursor/create_plan')!({ + toolCallId: 'plan-1', + plan: '# Plan' + }, null); + + await adapter.handlePermissionResponse({ + id: 'plan-1', + approved: true, + decision: 'approved' + }); + + await expect(pending).resolves.toEqual({ outcome: 'accepted' }); + }); + + it('resolves create_plan denial as rejected', async () => { + const { handlers, adapter } = createHarness(); + const pending = handlers.get('cursor/create_plan')!({ toolCallId: 'plan-2' }, null); + + await adapter.handlePermissionResponse({ + id: 'plan-2', + approved: false, + decision: 'denied' + }); + + await expect(pending).resolves.toEqual({ outcome: 'rejected' }); + }); + + it('returns false from handlePermissionResponse for unrelated permission ids', async () => { + const { adapter } = createHarness(); + const handled = await adapter.handlePermissionResponse({ + id: 'perm-read', + approved: true + }); + expect(handled).toBe(false); + }); + + it('maps cursor/update_todos to plan agent messages', async () => { + const { handlers, getMessages } = createHarness(); + await handlers.get('cursor/update_todos')!({ + todos: [ + { content: 'Step one', status: 'in_progress' }, + { content: 'Step two', status: 'completed' } + ] + }, null); + + expect(getMessages()).toEqual([ + { + type: 'plan', + items: [ + { content: 'Step one', priority: 'medium', status: 'in_progress' }, + { content: 'Step two', priority: 'medium', status: 'completed' } + ] + } + ]); + }); + + it('emits CursorTask tool call and result for cursor/task', async () => { + const { handlers, getMessages } = createHarness(); + await handlers.get('cursor/task')!({ + toolCallId: 'task-1', + title: 'Run tests' + }, null); + + expect(getMessages()).toEqual([ + expect.objectContaining({ + type: 'tool_call', + id: 'task-1', + name: 'CursorTask' + }), + expect.objectContaining({ + type: 'tool_result', + id: 'task-1', + status: 'completed' + }) + ]); + }); + + it('cancelAll resolves pending extension requests as cancelled', async () => { + const { handlers, adapter, getAgentState } = createHarness(); + const askPending = handlers.get('cursor/ask_question')!({ toolCallId: 'q-cancel' }, null); + const planPending = handlers.get('cursor/create_plan')!({ toolCallId: 'p-cancel' }, null); + + await adapter.cancelAll('User aborted'); + + await expect(askPending).resolves.toEqual({ outcome: 'cancelled' }); + await expect(planPending).resolves.toEqual({ outcome: 'cancelled' }); + expect(getAgentState().requests).toEqual({}); + expect(getAgentState().completedRequests).toMatchObject({ + 'q-cancel': { status: 'canceled', decision: 'abort' }, + 'p-cancel': { status: 'canceled', decision: 'abort' } + }); + }); +}); diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.ts b/cli/src/cursor/utils/cursorExtensionAdapter.ts new file mode 100644 index 00000000..f1370018 --- /dev/null +++ b/cli/src/cursor/utils/cursorExtensionAdapter.ts @@ -0,0 +1,256 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentState } from '@/api/types'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { logger } from '@/ui/logger'; +import { asString, isObject } from '@hapi/protocol'; +import type { AgentMessage, PlanItem } from '@/agent/types'; +import { randomUUID } from 'node:crypto'; + +type PendingExtensionRequest = { + tool: string; + arguments: unknown; + respond: (result: unknown) => void; +}; + +type PermissionResponseMessage = { + id: string; + approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + answers?: Record; +}; + +export type CursorExtensionMessageHandler = (message: AgentMessage) => void; + +export class CursorExtensionAdapter { + private readonly pending = new Map(); + + constructor( + private readonly session: ApiSessionClient, + private readonly backend: AcpSdkBackend, + private readonly onMessage: CursorExtensionMessageHandler + ) { + this.registerHandlers(); + } + + handlePermissionResponse = async (response: PermissionResponseMessage): Promise => { + if (!this.pending.has(response.id)) { + return false; + } + await this.handleResponse(response); + return true; + }; + + private registerHandlers(): void { + this.backend.registerExtensionRequestHandler('cursor/ask_question', async (params) => { + return await this.handleBlockingRequest('CursorAskQuestion', params); + }); + + this.backend.registerExtensionRequestHandler('cursor/create_plan', async (params) => { + return await this.handleBlockingRequest('CursorCreatePlan', params); + }); + + this.backend.registerExtensionRequestHandler('cursor/update_todos', async (params) => { + this.handleTodoUpdate(params); + return {}; + }); + + this.backend.registerExtensionRequestHandler('cursor/task', async (params) => { + this.handleTaskNotification(params); + return {}; + }); + + this.backend.registerExtensionRequestHandler('cursor/generate_image', async (params) => { + this.handleGenerateImage(params); + return {}; + }); + } + + private async handleBlockingRequest(tool: string, params: unknown): Promise { + const requestId = extractToolCallId(params) ?? `cursor-${randomUUID()}`; + const args = isObject(params) ? params : { toolCallId: requestId }; + + return await new Promise((resolve) => { + this.pending.set(requestId, { + tool, + arguments: args, + respond: resolve + }); + + this.session.updateAgentState((currentState) => ({ + ...currentState, + requests: { + ...currentState.requests, + [requestId]: { + tool, + arguments: args, + createdAt: Date.now() + } + } + } satisfies AgentState)); + + logger.debug(`[cursor-acp] Extension request queued: ${tool} (${requestId})`); + }); + } + + private async handleResponse(response: PermissionResponseMessage): Promise { + const pending = this.pending.get(response.id); + if (!pending) { + return; + } + + this.pending.delete(response.id); + + const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); + if (pending.tool === 'CursorAskQuestion') { + if (decision === 'abort' || decision === 'denied') { + pending.respond({ outcome: 'cancelled' }); + } else { + pending.respond({ + outcome: 'answered', + answers: formatQuestionAnswers(pending.arguments, response.answers) + }); + } + } else if (decision === 'abort') { + pending.respond({ outcome: 'cancelled' }); + } else if (decision === 'denied') { + pending.respond({ outcome: 'rejected' }); + } else { + pending.respond({ outcome: 'accepted' }); + } + + const status = response.approved ? 'approved' : 'denied'; + this.session.updateAgentState((currentState) => { + const requestEntry = currentState.requests?.[response.id]; + const { [response.id]: _, ...remaining } = currentState.requests ?? {}; + return { + ...currentState, + requests: remaining, + completedRequests: { + ...currentState.completedRequests, + [response.id]: { + tool: pending.tool, + arguments: pending.arguments, + createdAt: requestEntry?.createdAt ?? Date.now(), + completedAt: Date.now(), + status, + decision + } + } + } satisfies AgentState; + }); + } + + private handleTodoUpdate(params: unknown): void { + if (!isObject(params)) return; + const todos = Array.isArray(params.todos) ? params.todos : []; + const items: PlanItem[] = []; + for (const entry of todos) { + if (!isObject(entry)) continue; + const content = asString(entry.content) ?? asString(entry.title) ?? ''; + if (!content) continue; + const status = normalizeTodoStatus(asString(entry.status)); + items.push({ + content, + priority: 'medium', + status + }); + } + if (items.length > 0) { + this.onMessage({ type: 'plan', items }); + } + } + + private handleTaskNotification(params: unknown): void { + if (!isObject(params)) return; + const toolCallId = extractToolCallId(params) ?? `cursor-task-${randomUUID()}`; + const title = asString(params.title) ?? asString(params.description) ?? 'Cursor task'; + this.onMessage({ + type: 'tool_call', + id: toolCallId, + name: 'CursorTask', + input: params, + status: 'completed' + }); + this.onMessage({ + type: 'tool_result', + id: toolCallId, + output: params, + status: 'completed' + }); + } + + private handleGenerateImage(params: unknown): void { + if (!isObject(params)) return; + const toolCallId = extractToolCallId(params) ?? `cursor-image-${randomUUID()}`; + this.onMessage({ + type: 'tool_call', + id: toolCallId, + name: 'CursorGenerateImage', + input: params, + status: 'completed' + }); + this.onMessage({ + type: 'tool_result', + id: toolCallId, + output: params, + status: 'completed' + }); + } + + async cancelAll(reason: string): Promise { + const entries = Array.from(this.pending.entries()); + this.pending.clear(); + + for (const [id, pending] of entries) { + pending.respond( + pending.tool === 'CursorAskQuestion' + ? { outcome: 'cancelled' } + : { outcome: 'cancelled' } + ); + + this.session.updateAgentState((currentState) => { + const requestEntry = currentState.requests?.[id]; + const { [id]: _, ...remaining } = currentState.requests ?? {}; + return { + ...currentState, + requests: remaining, + completedRequests: { + ...currentState.completedRequests, + [id]: { + tool: pending.tool, + arguments: pending.arguments, + createdAt: requestEntry?.createdAt ?? Date.now(), + completedAt: Date.now(), + status: 'canceled', + reason, + decision: 'abort' + } + } + } satisfies AgentState; + }); + } + } +} + +function extractToolCallId(params: unknown): string | null { + if (!isObject(params)) return null; + return asString(params.toolCallId); +} + +function formatQuestionAnswers( + params: unknown, + answers: Record | undefined +): Array<{ questionId: string; selectedOptionIds: string[] }> { + if (!answers) return []; + return Object.entries(answers).map(([questionId, selectedOptionIds]) => ({ + questionId, + selectedOptionIds + })); +} + +function normalizeTodoStatus(status: string | null): PlanItem['status'] { + if (status === 'in_progress' || status === 'completed' || status === 'pending') { + return status; + } + return 'pending'; +} diff --git a/cli/src/cursor/utils/cursorEventConverter.test.ts b/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts similarity index 99% rename from cli/src/cursor/utils/cursorEventConverter.test.ts rename to cli/src/cursor/utils/cursorLegacyEventConverter.test.ts index 595f7f09..a4d7840c 100644 --- a/cli/src/cursor/utils/cursorEventConverter.test.ts +++ b/cli/src/cursor/utils/cursorLegacyEventConverter.test.ts @@ -4,9 +4,9 @@ import { convertCursorEventToAgentMessage, __resetCursorEventConverterStateForTests, type CursorStreamEvent -} from './cursorEventConverter'; +} from './cursorLegacyEventConverter'; -describe('cursorEventConverter', () => { +describe('cursorLegacyEventConverter', () => { beforeEach(() => { __resetCursorEventConverterStateForTests(); }); diff --git a/cli/src/cursor/utils/cursorEventConverter.ts b/cli/src/cursor/utils/cursorLegacyEventConverter.ts similarity index 100% rename from cli/src/cursor/utils/cursorEventConverter.ts rename to cli/src/cursor/utils/cursorLegacyEventConverter.ts diff --git a/cli/src/cursor/utils/cursorModeConfig.test.ts b/cli/src/cursor/utils/cursorModeConfig.test.ts new file mode 100644 index 00000000..ea4c39db --- /dev/null +++ b/cli/src/cursor/utils/cursorModeConfig.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { + applyCursorAcpModel, + applyCursorAcpMode, + resolveCursorAcpWireId, + toCursorAcpMode, + wireIdForCursorSessionState +} from './cursorModeConfig'; + +function mockModelBackend(overrides: Record = {}): AcpSdkBackend { + return { + pinSessionModelWireId: vi.fn(), + ...overrides + } as unknown as AcpSdkBackend; +} + +describe('toCursorAcpMode', () => { + it('maps HAPI cursor modes to Cursor ACP modes', () => { + expect(toCursorAcpMode('default')).toBe('agent'); + expect(toCursorAcpMode('yolo')).toBe('agent'); + expect(toCursorAcpMode('plan')).toBe('plan'); + expect(toCursorAcpMode('ask')).toBe('ask'); + expect(toCursorAcpMode('debug')).toBe('debug'); + expect(toCursorAcpMode(undefined)).toBe('agent'); + }); +}); + +describe('applyCursorAcpMode', () => { + it('prefers set_config_option for mode changes', async () => { + const setConfigOption = vi.fn(async () => {}); + const setMode = vi.fn(async () => {}); + const backend = { + setConfigOption, + setMode, + getConfigOptionByCategory: vi.fn(() => ({ + id: 'mode-opt', + options: [{ value: 'debug' }, { value: 'plan' }, { value: 'agent' }] + })) + } as unknown as AcpSdkBackend; + + await applyCursorAcpMode(backend, 'session-1', 'debug'); + + expect(setConfigOption).toHaveBeenCalledWith('session-1', 'mode-opt', 'debug'); + expect(setMode).not.toHaveBeenCalled(); + }); + + it('maps default permission mode to agent when ACP exposes agent only', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = { + setConfigOption, + setMode: vi.fn(), + getConfigOptionByCategory: vi.fn(() => ({ + id: 'mode-opt', + options: [{ value: 'agent' }, { value: 'plan' }, { value: 'debug' }] + })) + } as unknown as AcpSdkBackend; + + await applyCursorAcpMode(backend, 'session-1', 'default'); + + expect(setConfigOption).toHaveBeenCalledWith('session-1', 'mode-opt', 'agent'); + }); + + it('falls back to setMode when config option is unavailable', async () => { + const setMode = vi.fn(async () => {}); + const backend = { setMode } as unknown as AcpSdkBackend; + + await applyCursorAcpMode(backend, 'session-1', 'plan'); + + expect(setMode).toHaveBeenCalledWith('session-1', 'plan'); + }); + + it('falls back to setMode when set_config_option throws', async () => { + const setConfigOption = vi.fn(async () => { + throw new Error('rejected'); + }); + const setMode = vi.fn(async () => {}); + const backend = { + setConfigOption, + setMode, + getConfigOptionByCategory: vi.fn(() => ({ + id: 'mode-opt', + options: [{ value: 'ask' }] + })) + } as unknown as AcpSdkBackend; + + await applyCursorAcpMode(backend, 'session-1', 'ask'); + + expect(setMode).toHaveBeenCalledWith('session-1', 'ask'); + }); + + it('swallows setMode errors', async () => { + const setMode = vi.fn(async () => { + throw new Error('method not found'); + }); + const backend = { setMode } as unknown as AcpSdkBackend; + + await expect(applyCursorAcpMode(backend, 'session-1', 'ask')).resolves.toBeUndefined(); + }); +}); + +describe('wireIdForCursorSessionState', () => { + it('keeps explicit variant wire ids from the user request', () => { + expect( + wireIdForCursorSessionState( + 'composer-2.5[fast=false]', + 'composer-2.5[fast=true]' + ) + ).toBe('composer-2.5[fast=false]'); + }); + + it('uses resolved wire id for base-only requests', () => { + expect( + wireIdForCursorSessionState('composer-2.5', 'composer-2.5[fast=true]') + ).toBe('composer-2.5[fast=true]'); + }); +}); + +describe('applyCursorAcpModel', () => { + const metadata = { + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }; + + it('returns not applied when model id is empty', async () => { + const setConfigOption = vi.fn(); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => metadata), + getConfigOptionByCategory: vi.fn(() => ({ id: 'model-opt' })) + }); + await expect(applyCursorAcpModel(backend, 's1', null)).resolves.toEqual({ applied: false }); + expect(setConfigOption).not.toHaveBeenCalled(); + }); + + it('uses session/set_config_option for ACP wire ids (Zed-style)', async () => { + const setConfigOption = vi.fn(async () => {}); + const setModel = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + setModel, + getSessionModelsMetadata: vi.fn(() => metadata), + getConfigOptionByCategory: vi.fn(() => ({ id: 'model-opt' })) + }); + + await expect( + applyCursorAcpModel(backend, 's1', 'composer-2.5[fast=true]') + ).resolves.toEqual({ + applied: true, + resolvedWireId: 'composer-2.5[fast=true]', + requestedWireId: 'composer-2.5[fast=true]' + }); + expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=true]'); + expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5[fast=true]'); + expect(setModel).not.toHaveBeenCalled(); + }); + + it('rejects ids not present in ACP configOptions', async () => { + const setConfigOption = vi.fn(); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => metadata), + getConfigOptionByCategory: vi.fn(() => ({ id: 'model-opt' })) + }); + + await expect( + applyCursorAcpModel(backend, 's1', 'claude-opus-4-8[effort=high]') + ).resolves.toEqual({ applied: false }); + expect(setConfigOption).not.toHaveBeenCalled(); + }); + + it('resolves spawn wire id via config option list when metadata lists one variant', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + setModel: vi.fn(), + getSessionModelsMetadata: vi.fn(() => metadata), + getConfigOptionByCategory: vi.fn(() => ({ + id: 'model-opt', + options: [ + { value: 'composer-2.5[fast=true]' }, + { value: 'composer-2.5[fast=false]' } + ] + })) + }); + + await expect( + applyCursorAcpModel(backend, 's1', 'composer-2.5[fast=false]') + ).resolves.toEqual({ + applied: true, + resolvedWireId: 'composer-2.5[fast=false]', + requestedWireId: 'composer-2.5[fast=false]' + }); + expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]'); + }); + + it('retries set_config_option once before failing apply', async () => { + const setConfigOption = vi.fn() + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce(undefined); + const setModel = vi.fn(async () => { + throw new Error('should not reach set_model'); + }); + const backend = mockModelBackend({ + setConfigOption, + setModel, + getSessionModelsMetadata: vi.fn(() => metadata), + getConfigOptionByCategory: vi.fn(() => ({ id: 'model-opt' })) + }); + + await expect( + applyCursorAcpModel(backend, 's1', 'composer-2.5[fast=true]') + ).resolves.toEqual({ + applied: true, + resolvedWireId: 'composer-2.5[fast=true]', + requestedWireId: 'composer-2.5[fast=true]' + }); + expect(setConfigOption).toHaveBeenCalledTimes(2); + expect(setModel).not.toHaveBeenCalled(); + }); + + it('returns not applied when set_config_option is unavailable', async () => { + const setModel = vi.fn(async () => {}); + const backend = mockModelBackend({ + setModel, + getSessionModelsMetadata: vi.fn(() => metadata), + getConfigOptionByCategory: vi.fn(() => undefined) + }); + + await expect( + applyCursorAcpModel(backend, 's1', 'composer-2.5[fast=true]') + ).resolves.toEqual({ applied: false }); + expect(setModel).not.toHaveBeenCalled(); + }); +}); + +describe('resolveCursorAcpWireId', () => { + const available = [ + { modelId: 'composer-2.5[fast=true]' }, + { modelId: 'composer-2.5[fast=false]' } + ]; + + it('returns exact wire id matches', () => { + expect(resolveCursorAcpWireId('composer-2.5[fast=false]', available)).toBe( + 'composer-2.5[fast=false]' + ); + }); + + it('maps base-only CLI sku requests onto the sole ACP wire for that base', () => { + expect(resolveCursorAcpWireId('composer-2.5', [{ modelId: 'composer-2.5[fast=true]' }])).toBe( + 'composer-2.5[fast=true]' + ); + }); + + it('maps legacy Cursor CLI fast aliases onto matching ACP wire ids', () => { + expect(resolveCursorAcpWireId('composer-2.5-fast', available)).toBe( + 'composer-2.5[fast=true]' + ); + }); + + it('does not match partial ACP parameter requests against full config option wire ids', () => { + expect(resolveCursorAcpWireId('claude-opus-4-8[effort=high]', [ + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=low,fast=false]' }, + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]' } + ])).toBe(null); + }); +}); diff --git a/cli/src/cursor/utils/cursorModeConfig.ts b/cli/src/cursor/utils/cursorModeConfig.ts new file mode 100644 index 00000000..2f21a3b3 --- /dev/null +++ b/cli/src/cursor/utils/cursorModeConfig.ts @@ -0,0 +1,153 @@ +import type { CursorPermissionMode } from '@hapi/protocol/types'; +import { matchCliSkuToAcpWireId } from '@hapi/protocol'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { logger } from '@/ui/logger'; + +export type CursorAcpMode = 'agent' | 'plan' | 'ask' | 'debug'; + +function isDefaultCursorModelId(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase(); + return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; +} + +export function toCursorAcpMode(mode: CursorPermissionMode | undefined): CursorAcpMode { + if (mode === 'plan') return 'plan'; + if (mode === 'ask') return 'ask'; + if (mode === 'debug') return 'debug'; + return 'agent'; +} + +function resolveAcpModeConfigValue( + mode: CursorPermissionMode | undefined, + backend: AcpSdkBackend, + sessionId: string +): string { + const acpMode = toCursorAcpMode(mode); + const modeOption = backend.getConfigOptionByCategory?.(sessionId, 'mode'); + const optionValues = modeOption?.options.map((entry) => entry.value) ?? []; + if (optionValues.includes(acpMode)) { + return acpMode; + } + if (mode === 'yolo' || mode === 'default') { + if (optionValues.includes('agent')) { + return 'agent'; + } + } + if (mode === 'debug' && optionValues.includes('debug')) { + return 'debug'; + } + return acpMode; +} + +export async function applyCursorAcpMode( + backend: AcpSdkBackend, + sessionId: string, + mode: CursorPermissionMode | undefined +): Promise { + const configValue = resolveAcpModeConfigValue(mode, backend, sessionId); + + const modeOption = backend.getConfigOptionByCategory?.(sessionId, 'mode'); + if (modeOption && backend.setConfigOption) { + try { + await backend.setConfigOption(sessionId, modeOption.id, configValue); + return; + } catch (error) { + logger.debug('[cursor-acp] session/set_config_option for mode failed, trying set_mode', error); + } + } + + try { + await backend.setMode(sessionId, configValue); + } catch (error) { + logger.warn(`[cursor-acp] Failed to set mode ${configValue}`, error); + } +} + +export type ApplyCursorAcpModelResult = { + applied: boolean; + /** Wire id applied via ACP when switching succeeds */ + resolvedWireId?: string; + /** Original hub/UI request before catalog resolution */ + requestedWireId?: string; +}; + +/** Wire id stored on session + keepalive (preserve explicit variant picks). */ +export function wireIdForCursorSessionState(requested: string, resolved: string): string { + const trimmed = requested.trim(); + if (trimmed.includes('[')) { + return trimmed; + } + return resolved; +} + +/** + * Map a spawn / hub wire id onto a live ACP configOptions entry. + * Exact wire ids only — no legacy alias, base-only, or nearest-variant fallback. + */ +export function resolveCursorAcpWireId( + requested: string, + available: readonly { modelId: string }[] +): string | null { + const trimmed = requested.trim(); + if (!trimmed) { + return null; + } + + const exact = available.find((entry) => entry.modelId === trimmed); + if (exact) { + return exact.modelId; + } + + return matchCliSkuToAcpWireId(trimmed, available); +} + +/** + * Apply a model from the live ACP configOptions list (Zed-style). + * Only wire ids present in `availableModels` are accepted. + */ +export async function applyCursorAcpModel( + backend: AcpSdkBackend, + sessionId: string, + modelId: string | null | undefined +): Promise { + const trimmed = modelId?.trim(); + if (!trimmed || isDefaultCursorModelId(trimmed)) { + return { applied: false }; + } + + const metadata = backend.getSessionModelsMetadata(sessionId); + const available = metadata?.availableModels ?? []; + const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? []; + const catalog = [...available, ...optionWireIds]; + const resolved = resolveCursorAcpWireId(trimmed, catalog); + if (!resolved) { + logger.debug(`[cursor-acp] Model ${trimmed} is not in ACP configOptions; skipping`); + return { applied: false }; + } + + const trySetConfigOption = async (): Promise => { + if (!modelOption || !backend.setConfigOption) { + return false; + } + try { + await backend.setConfigOption(sessionId, modelOption.id, resolved); + return true; + } catch (error) { + logger.debug('[cursor-acp] session/set_config_option failed, trying set_model', error); + return false; + } + }; + + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await trySetConfigOption()) { + backend.pinSessionModelWireId(sessionId, resolved); + return { applied: true, resolvedWireId: resolved, requestedWireId: trimmed }; + } + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + } + + return { applied: false }; +} diff --git a/cli/src/cursor/utils/cursorProtocol.test.ts b/cli/src/cursor/utils/cursorProtocol.test.ts new file mode 100644 index 00000000..9dc166e4 --- /dev/null +++ b/cli/src/cursor/utils/cursorProtocol.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { isLegacyCursorSession, resolveCursorRemoteProtocol } from './cursorProtocol'; + +const baseMetadata = { flavor: 'cursor', path: '/tmp', host: 'test' }; + +describe('cursorProtocol', () => { + it('routes new sessions to ACP', () => { + expect(resolveCursorRemoteProtocol(baseMetadata)).toBe('acp'); + expect(isLegacyCursorSession(baseMetadata)).toBe(false); + }); + + it('routes legacy stream-json sessions by metadata', () => { + const metadata = { + ...baseMetadata, + cursorSessionId: 'old-session', + cursorSessionProtocol: 'stream-json' as const + }; + expect(resolveCursorRemoteProtocol(metadata)).toBe('stream-json'); + expect(isLegacyCursorSession(metadata)).toBe(true); + }); + + it('routes sessions with cursorSessionId but no protocol to legacy', () => { + const metadata = { + ...baseMetadata, + cursorSessionId: 'old-session' + }; + expect(resolveCursorRemoteProtocol(metadata)).toBe('stream-json'); + }); + + it('routes explicit ACP metadata to ACP even with session id', () => { + const metadata = { + ...baseMetadata, + cursorSessionId: 'acp-session', + cursorSessionProtocol: 'acp' as const + }; + expect(resolveCursorRemoteProtocol(metadata)).toBe('acp'); + expect(isLegacyCursorSession(metadata)).toBe(false); + }); + + it('never treats non-cursor flavor as legacy', () => { + expect(isLegacyCursorSession({ ...baseMetadata, flavor: 'claude', cursorSessionId: 'x' })).toBe(false); + expect(resolveCursorRemoteProtocol({ ...baseMetadata, flavor: 'claude', cursorSessionId: 'x' })).toBe('acp'); + }); + + it('does not use legacy when stream-json protocol is set but cursorSessionId is missing', () => { + const metadata = { + ...baseMetadata, + cursorSessionProtocol: 'stream-json' as const + }; + expect(isLegacyCursorSession(metadata)).toBe(false); + expect(resolveCursorRemoteProtocol(metadata)).toBe('acp'); + }); + + it('defaults null/undefined metadata to ACP (new session)', () => { + expect(resolveCursorRemoteProtocol(null)).toBe('acp'); + expect(resolveCursorRemoteProtocol(undefined)).toBe('acp'); + }); +}); + diff --git a/cli/src/cursor/utils/cursorProtocol.ts b/cli/src/cursor/utils/cursorProtocol.ts new file mode 100644 index 00000000..64936f7c --- /dev/null +++ b/cli/src/cursor/utils/cursorProtocol.ts @@ -0,0 +1,23 @@ +import type { Metadata } from '@hapi/protocol/schemas'; + +export type CursorSessionProtocol = 'acp' | 'stream-json'; + +export function isLegacyCursorSession(metadata: Metadata | null | undefined): boolean { + if (metadata?.flavor !== 'cursor') { + return false; + } + if (metadata.cursorSessionProtocol === 'acp') { + return false; + } + if (metadata.cursorSessionProtocol === 'stream-json') { + return Boolean(metadata.cursorSessionId); + } + return Boolean(metadata.cursorSessionId); +} + +export function resolveCursorRemoteProtocol(metadata: Metadata | null | undefined): CursorSessionProtocol { + if (isLegacyCursorSession(metadata)) { + return 'stream-json'; + } + return 'acp'; +} diff --git a/cli/src/modules/common/cursorAcpModelProbe.test.ts b/cli/src/modules/common/cursorAcpModelProbe.test.ts new file mode 100644 index 00000000..5adc9ccf --- /dev/null +++ b/cli/src/modules/common/cursorAcpModelProbe.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ + initializeError: null as Error | null, + snapshot: null as { + availableModels: Array<{ modelId: string; name?: string }> + currentModelId: string | null + } | null +})); + +vi.mock('@/cursor/utils/cursorAcpBackend', () => ({ + createCursorAcpBackend: vi.fn(() => ({ + initialize: vi.fn(async () => { + if (harness.initializeError) { + throw harness.initializeError; + } + }), + newSession: vi.fn(async () => 'probe-session'), + getSessionModelsMetadata: vi.fn(() => harness.snapshot), + getConfigOptionByCategory: vi.fn(() => null), + disconnect: vi.fn(async () => {}) + })) +})); + +import { createCursorAcpBackend } from '@/cursor/utils/cursorAcpBackend'; +import { + cursorProbeResponseHasWireCatalog, + runCursorAcpModelProbe +} from './cursorAcpModelProbe'; + +afterEach(() => { + harness.initializeError = null; + harness.snapshot = null; + vi.mocked(createCursorAcpBackend).mockClear(); +}); + +describe('runCursorAcpModelProbe', () => { + test('returns wire catalog from ACP session/new snapshot', async () => { + harness.snapshot = { + availableModels: [ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ], + currentModelId: 'composer-2.5[fast=true]' + }; + + const result = await runCursorAcpModelProbe('/tmp/project'); + + expect(result).toEqual({ + success: true, + availableModels: harness.snapshot.availableModels, + currentModelId: 'composer-2.5[fast=true]' + }); + expect(cursorProbeResponseHasWireCatalog(result)).toBe(true); + expect(createCursorAcpBackend).toHaveBeenCalledWith({ cwd: '/tmp/project' }); + }); + + test('returns error when ACP initialize fails', async () => { + harness.initializeError = new Error('agent acp unavailable'); + + const result = await runCursorAcpModelProbe(); + + expect(result.success).toBe(false); + expect(result.error).toContain('agent acp unavailable'); + }); +}); diff --git a/cli/src/modules/common/cursorAcpModelProbe.ts b/cli/src/modules/common/cursorAcpModelProbe.ts new file mode 100644 index 00000000..127c6a38 --- /dev/null +++ b/cli/src/modules/common/cursorAcpModelProbe.ts @@ -0,0 +1,55 @@ +import { createCursorAcpBackend } from '@/cursor/utils/cursorAcpBackend'; +import { buildCursorModelsSnapshotFromAcp } from '@/cursor/utils/cursorAcpModelsSnapshot'; +import type { ListCursorModelsResponse } from './cursorModels'; +import { getErrorMessage } from './rpcResponses'; + +function isCursorAcpWireModelId(modelId: string): boolean { + const trimmed = modelId.trim(); + return trimmed === 'default[]' || trimmed.includes('['); +} + +function hasAcpWireCatalog(response: ListCursorModelsResponse): boolean { + return (response.availableModels ?? []).some((model) => isCursorAcpWireModelId(model.modelId)); +} + +/** + * Short-lived `agent acp` subprocess: `initialize` + `session/new` to capture the + * Zed-style wire catalog. Used for New Session when no on-disk ACP cache exists. + */ +export async function runCursorAcpModelProbe(cwd?: string): Promise { + const resolvedCwd = cwd?.trim() || process.cwd(); + const backend = createCursorAcpBackend({ cwd: resolvedCwd }); + + try { + await backend.initialize(); + const sessionId = await backend.newSession({ + cwd: resolvedCwd, + mcpServers: [] + }); + const snapshot = buildCursorModelsSnapshotFromAcp(backend, sessionId); + if (!snapshot || snapshot.availableModels.length === 0) { + return { success: false, error: 'Cursor ACP session/new returned no models' }; + } + + const response: ListCursorModelsResponse = { + success: true, + availableModels: snapshot.availableModels, + currentModelId: snapshot.currentModelId + }; + if (!hasAcpWireCatalog(response)) { + return { success: false, error: 'Cursor ACP catalog has no wire model ids' }; + } + return response; + } catch (error) { + return { + success: false, + error: getErrorMessage(error, 'Failed to discover Cursor models via ACP') + }; + } finally { + await backend.disconnect().catch(() => undefined); + } +} + +export function cursorProbeResponseHasWireCatalog(response: ListCursorModelsResponse): boolean { + return response.success === true && hasAcpWireCatalog(response); +} diff --git a/cli/src/modules/common/cursorModels.test.ts b/cli/src/modules/common/cursorModels.test.ts index 6e472965..4fc2576d 100644 --- a/cli/src/modules/common/cursorModels.test.ts +++ b/cli/src/modules/common/cursorModels.test.ts @@ -1,5 +1,52 @@ -import { describe, expect, test } from 'vitest' -import { parseCursorModelsOutput } from './cursorModels' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { setCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: spawnMock }; +}); + +vi.mock('@/agent/backends/acp/agentCliGuard', () => ({ + isAgentAcpTransportActive: vi.fn(() => false), + _resetAgentCliGuardForTests: vi.fn() +})); + +const acpProbeMock = vi.hoisted(() => ({ + runCursorAcpModelProbe: vi.fn() +})); + +vi.mock('./cursorAcpModelProbe', () => ({ + runCursorAcpModelProbe: acpProbeMock.runCursorAcpModelProbe, + cursorProbeResponseHasWireCatalog: (response: { success?: boolean; availableModels?: Array<{ modelId: string }> }) => + response.success === true + && (response.availableModels ?? []).some((model) => model.modelId.includes('[')) +})); + +import { isAgentAcpTransportActive } from '@/agent/backends/acp/agentCliGuard'; +import { + readSharedCursorModelsCache, + writeSharedCursorModelsCache, + _resetSharedCursorModelsCacheForTests +} from './cursorModelsSharedCache'; +import { + _resetCursorModelsCacheForTests, + listCursorModels, + parseCursorModelsOutput, + seedCursorModelsCache +} from './cursorModels'; + +afterEach(() => { + _resetCursorModelsCacheForTests() + _resetSharedCursorModelsCacheForTests() + setCursorAcpModelsSnapshot(null) + vi.mocked(isAgentAcpTransportActive).mockReturnValue(false) + spawnMock.mockReset() + acpProbeMock.runCursorAcpModelProbe.mockReset() +}) describe('parseCursorModelsOutput', () => { test('parses Cursor agent model list output', () => { @@ -35,3 +82,154 @@ composer-2.5 - Composer 2.5 expect(result.currentModelId).toBe('composer-2.5-fast') }) }) + +describe('listCursorModels', () => { + test('does not spawn agent --list-models while ACP transport is active', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }) + + const result = await listCursorModels() + + expect(result).toEqual({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + + test('returns empty list while ACP is active but no snapshot is seeded yet', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + + const result = await listCursorModels() + + expect(result).toEqual({ + success: true, + availableModels: [], + currentModelId: null + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + + test('prefers shared on-disk cache over stale in-memory cache while ACP lock is active', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + seedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'stale-cli-sku' }], + currentModelId: 'stale-cli-sku' + }) + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }) + + const result = await listCursorModels() + + expect(result.availableModels).toEqual([ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' } + ]) + expect(spawnMock).not.toHaveBeenCalled() + }) + + test('reads shared on-disk cache while ACP lock is active in another process', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }) + + const result = await listCursorModels() + + expect(result.availableModels).toEqual([ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' } + ]) + expect(spawnMock).not.toHaveBeenCalled() + expect(readSharedCursorModelsCache()?.currentModelId).toBe('composer-2.5[fast=true]') + }) + + test('prefers ACP wire probe over CLI slug probe when cache is empty', async () => { + acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }); + spawnMock.mockImplementation(() => ({ + stdout: { + on: vi.fn((event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data') { + handler(Buffer.from('composer-2.5-fast - Composer 2.5 Fast\n')); + } + }) + }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, handler: (code: number) => void) => { + if (event === 'exit') { + setTimeout(() => handler(0), 0); + } + }), + kill: vi.fn() + })); + + const result = await listCursorModels(); + + expect(acpProbeMock.runCursorAcpModelProbe).toHaveBeenCalled(); + expect(result.availableModels).toEqual([ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' } + ]); + expect(result.cliModelSkus?.some((row) => row.modelId === 'composer-2.5-fast')).toBe(true); + }); + + test('does not cache CLI slug probe when ACP probe returns no wires', async () => { + acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ + success: false, + error: 'no acp' + }); + spawnMock.mockImplementation(() => ({ + stdout: { + on: vi.fn((event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data') { + handler(Buffer.from('composer-2.5 - Composer 2.5 (current)\n')); + } + }) + }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, handler: (code: number) => void) => { + if (event === 'exit') { + setTimeout(() => handler(0), 0); + } + }), + kill: vi.fn() + })); + + const result = await listCursorModels(); + + expect(result).toEqual({ + success: true, + availableModels: [], + currentModelId: null + }); + }); + + test('prefers live ACP snapshot over cache while ACP transport is active', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + seedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'stale' }], + currentModelId: 'stale' + }) + setCursorAcpModelsSnapshot({ + availableModels: [{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }], + currentModelId: 'composer-2.5-fast' + }) + + const result = await listCursorModels() + + expect(result.currentModelId).toBe('composer-2.5-fast') + }) +}) diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index eb001104..d61d4641 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -1,6 +1,83 @@ import { spawn } from 'node:child_process'; import type { CursorModelsResponse, CursorModelSummary } from '@hapi/protocol/apiTypes'; +import { isAgentAcpTransportActive } from '@/agent/backends/acp/agentCliGuard'; +import { getCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'; import { getErrorMessage } from './rpcResponses'; +import { + readSharedCursorModelsCache, + writeSharedCursorModelsCache, + _resetSharedCursorModelsCacheForTests +} from './cursorModelsSharedCache'; +import { + cursorCliSkuBaseId, + cursorModelBaseId, + isCursorAcpWireModelId +} from '@hapi/protocol'; +import { + cursorProbeResponseHasWireCatalog, + runCursorAcpModelProbe +} from './cursorAcpModelProbe'; + +function filterCliSkusForWireBases( + cliSkus: CursorModelSummary[], + wires: CursorModelSummary[] +): CursorModelSummary[] { + const wireBases = new Set( + wires.map((entry) => cursorModelBaseId(entry.modelId)).filter((base) => base.length > 0) + ); + + return cliSkus.filter((entry) => { + const modelId = entry.modelId.trim(); + if (!modelId || modelId === 'auto' || isCursorAcpWireModelId(modelId)) { + return false; + } + return wireBases.has(cursorCliSkuBaseId(modelId)); + }); +} + +function attachCliSkusToResponse( + response: ListCursorModelsResponse, + cliSkus: readonly CursorModelSummary[] +): ListCursorModelsResponse { + if ((response.cliModelSkus?.length ?? 0) > 0) { + return response; + } + + const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId)); + const filtered = filterCliSkusForWireBases([...cliSkus], wires); + return filtered.length > 0 ? { ...response, cliModelSkus: filtered } : response; +} + +async function enrichCursorModelsWithCliSkus( + response: ListCursorModelsResponse +): Promise { + if ((response.cliModelSkus?.length ?? 0) > 0) { + return response; + } + + const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpWireModelId(entry.modelId)); + if (wires.length === 0) { + return response; + } + + const shared = readSharedCursorModelsCache(); + if (shared?.cliModelSkus?.length) { + return attachCliSkusToResponse(response, shared.cliModelSkus); + } + + // Never spawn `agent --list-models` while an ACP session holds the CLI lock. + if (isAgentAcpTransportActive()) { + return response; + } + + try { + const probe = await runCursorModelProbe(); + const cliSkus = filterCliSkusForWireBases(probe.availableModels ?? [], wires); + return cliSkus.length > 0 ? { ...response, cliModelSkus: cliSkus } : response; + } catch { + return response; + } +} export type ListCursorModelsResponse = CursorModelsResponse; @@ -104,9 +181,52 @@ async function runCursorModelProbe(): Promise { }); } +async function applyInMemoryCache(response: ListCursorModelsResponse): Promise { + const enriched = await enrichCursorModelsWithCliSkus(response); + if ((enriched.availableModels?.length ?? 0) > 0) { + cache.expiresAt = Date.now() + CACHE_TTL_MS; + cache.response = enriched; + writeSharedCursorModelsCache(enriched); + } + return enriched; +} + +/** ACP session snapshot (Zed-style); falls back to seeded / on-disk cache from the last live session. */ +async function listCursorModelsWhileAcpActive(): Promise { + const acp = getCursorAcpModelsSnapshot(); + if (acp && acp.availableModels.length > 0) { + return applyInMemoryCache({ success: true, ...acp }); + } + // Session child writes the on-disk cache; prefer it over this process's in-memory entry. + const shared = readSharedCursorModelsCache(); + if (shared) { + return applyInMemoryCache(shared); + } + if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) { + const shared = readSharedCursorModelsCache(); + const cachedSkus = cache.response.cliModelSkus ?? shared?.cliModelSkus ?? []; + return attachCliSkusToResponse(cache.response, cachedSkus); + } + return { success: true, availableModels: [], currentModelId: null }; +} + export async function listCursorModels(): Promise { - if (cache.expiresAt > Date.now()) { - return cache.response; + if (isAgentAcpTransportActive()) { + return listCursorModelsWhileAcpActive(); + } + + const acp = getCursorAcpModelsSnapshot(); + if (acp && acp.availableModels.length > 0) { + return applyInMemoryCache({ success: true, ...acp }); + } + + if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) { + return enrichCursorModelsWithCliSkus(cache.response); + } + + const shared = readSharedCursorModelsCache(); + if (shared) { + return applyInMemoryCache(shared); } if (inflight) { @@ -115,10 +235,24 @@ export async function listCursorModels(): Promise { inflight = (async () => { try { - const response = await runCursorModelProbe(); - cache.expiresAt = Date.now() + CACHE_TTL_MS; - cache.response = response; - return response; + const acpResponse = await runCursorAcpModelProbe(); + if (cursorProbeResponseHasWireCatalog(acpResponse)) { + return applyInMemoryCache(acpResponse); + } + + const probeResponse = await runCursorModelProbe(); + if (cursorProbeResponseHasWireCatalog(probeResponse)) { + return applyInMemoryCache(probeResponse); + } + + // CLI `--list-models` returns slug ids without bracket params; never cache + // those for the web picker (New Session would show only Default + current slug). + if (acpResponse.success) { + return acpResponse; + } + return probeResponse.success + ? { success: true, availableModels: [], currentModelId: null } + : probeResponse; } catch (error) { return { success: false, @@ -132,8 +266,13 @@ export async function listCursorModels(): Promise { return inflight; } +export function seedCursorModelsCache(response: ListCursorModelsResponse): void { + void applyInMemoryCache(response); +} + export function _resetCursorModelsCacheForTests(): void { cache.expiresAt = 0; cache.response = { success: true, availableModels: [], currentModelId: null }; inflight = null; + _resetSharedCursorModelsCacheForTests(); } diff --git a/cli/src/modules/common/cursorModelsSharedCache.test.ts b/cli/src/modules/common/cursorModelsSharedCache.test.ts new file mode 100644 index 00000000..90006a5e --- /dev/null +++ b/cli/src/modules/common/cursorModelsSharedCache.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { + readSharedCursorModelsCache, + writeSharedCursorModelsCache, + _resetSharedCursorModelsCacheForTests +} from './cursorModelsSharedCache'; + +afterEach(() => { + _resetSharedCursorModelsCacheForTests(); +}); + +describe('cursorModelsSharedCache', () => { + test('round-trips a usable models response', () => { + const payload = { + success: true as const, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }; + + writeSharedCursorModelsCache(payload); + + expect(readSharedCursorModelsCache()).toEqual(payload); + }); + + test('ignores empty or invalid cache files', () => { + writeSharedCursorModelsCache({ success: true, availableModels: [], currentModelId: null }); + expect(readSharedCursorModelsCache()).toBeNull(); + }); +}); diff --git a/cli/src/modules/common/cursorModelsSharedCache.ts b/cli/src/modules/common/cursorModelsSharedCache.ts new file mode 100644 index 00000000..6a3d5fce --- /dev/null +++ b/cli/src/modules/common/cursorModelsSharedCache.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { CursorModelsResponse } from '@hapi/protocol/apiTypes'; + +function getHapiHomeDir(): string { + return process.env.HAPI_HOME?.trim() || join(tmpdir(), 'hapi'); +} + +function getSharedCachePath(): string { + return join(getHapiHomeDir(), 'cache', 'cursor-models.json'); +} + +function isUsableModelsResponse(response: CursorModelsResponse | null): response is CursorModelsResponse { + return Boolean( + response?.success + && (response.availableModels?.length ?? 0) > 0 + ); +} + +/** Cross-process catalog for New Session while an ACP lock blocks `agent --list-models`. */ +export function readSharedCursorModelsCache(): CursorModelsResponse | null { + const path = getSharedCachePath(); + if (!existsSync(path)) { + return null; + } + + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as CursorModelsResponse; + return isUsableModelsResponse(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function writeSharedCursorModelsCache(response: CursorModelsResponse): void { + if (!isUsableModelsResponse(response)) { + return; + } + + const path = getSharedCachePath(); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(response), 'utf8'); + } catch { + // Best effort — in-process cache still works in the session child. + } +} + +export function _resetSharedCursorModelsCacheForTests(): void { + const path = getSharedCachePath(); + if (existsSync(path)) { + rmSync(path, { force: true }); + } +} diff --git a/cli/src/modules/common/cursorModelsStaleLock.test.ts b/cli/src/modules/common/cursorModelsStaleLock.test.ts new file mode 100644 index 00000000..552f94c4 --- /dev/null +++ b/cli/src/modules/common/cursorModelsStaleLock.test.ts @@ -0,0 +1,71 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { _resetAgentCliGuardForTests } from '@/agent/backends/acp/agentCliGuard'; + +const testHome = join(tmpdir(), `hapi-cursor-models-lock-${process.pid}`); + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: spawnMock }; +}); + +const acpProbeMock = vi.hoisted(() => ({ + runCursorAcpModelProbe: vi.fn() +})); + +vi.mock('./cursorAcpModelProbe', () => ({ + runCursorAcpModelProbe: acpProbeMock.runCursorAcpModelProbe, + cursorProbeResponseHasWireCatalog: (response: { success?: boolean; availableModels?: Array<{ modelId: string }> }) => + response.success === true + && (response.availableModels ?? []).some((model) => model.modelId.includes('[')) +})); + +import { + _resetCursorModelsCacheForTests, + listCursorModels +} from './cursorModels'; +import { _resetSharedCursorModelsCacheForTests } from './cursorModelsSharedCache'; + +describe('listCursorModels stale ACP lock', () => { + const previousHome = process.env.HAPI_HOME; + + afterEach(() => { + _resetAgentCliGuardForTests(); + _resetCursorModelsCacheForTests(); + _resetSharedCursorModelsCacheForTests(); + spawnMock.mockReset(); + acpProbeMock.runCursorAcpModelProbe.mockReset(); + if (previousHome === undefined) { + delete process.env.HAPI_HOME; + } else { + process.env.HAPI_HOME = previousHome; + } + }); + + test('runs cold ACP probe after clearing a stale cross-process lock', async () => { + process.env.HAPI_HOME = testHome; + const dir = join(testHome, 'locks', 'agent-acp-active'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'pid'), '99999999'); + + acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }); + + const result = await listCursorModels(); + + expect(existsSync(dir)).toBe(false); + expect(acpProbeMock.runCursorAcpModelProbe).toHaveBeenCalled(); + expect(result.availableModels).toEqual([ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' } + ]); + }); +}); diff --git a/cli/src/modules/common/handlers/cursorModels.test.ts b/cli/src/modules/common/handlers/cursorModels.test.ts new file mode 100644 index 00000000..a7b2f50e --- /dev/null +++ b/cli/src/modules/common/handlers/cursorModels.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, rm } from 'fs/promises'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { _resetAgentCliGuardForTests } from '@/agent/backends/acp/agentCliGuard'; +import { + _resetCursorModelsCacheForTests, + seedCursorModelsCache +} from '../cursorModels'; +import { + _resetSharedCursorModelsCacheForTests, + writeSharedCursorModelsCache +} from '../cursorModelsSharedCache'; +import { registerCursorModelHandlers } from './cursorModels'; + +async function createTempHapiHome(): Promise { + const path = join(tmpdir(), `hapi-cursor-rpc-${Date.now()}-${Math.random().toString(16).slice(2)}`); + await mkdir(path, { recursive: true }); + return path; +} + +describe('listCursorModels machine RPC handler', () => { + let testHome: string; + let savedHapiHome: string | undefined; + let rpc: RpcHandlerManager; + + beforeEach(async () => { + savedHapiHome = process.env.HAPI_HOME; + testHome = await createTempHapiHome(); + process.env.HAPI_HOME = testHome; + _resetCursorModelsCacheForTests(); + _resetSharedCursorModelsCacheForTests(); + _resetAgentCliGuardForTests(); + + rpc = new RpcHandlerManager({ scopePrefix: 'machine-test' }); + registerCursorModelHandlers(rpc); + }); + + afterEach(async () => { + _resetCursorModelsCacheForTests(); + _resetSharedCursorModelsCacheForTests(); + _resetAgentCliGuardForTests(); + if (savedHapiHome === undefined) { + delete process.env.HAPI_HOME; + } else { + process.env.HAPI_HOME = savedHapiHome; + } + await rm(testHome, { recursive: true, force: true }); + }); + + async function listViaRpc(): Promise<{ + success: boolean; + availableModels?: Array<{ modelId: string; name?: string }>; + currentModelId?: string | null; + }> { + const raw = await rpc.handleRequest({ + method: `machine-test:${RPC_METHODS.ListCursorModels}`, + params: '{}' + }); + return JSON.parse(raw) as { + success: boolean; + availableModels?: Array<{ modelId: string; name?: string }>; + currentModelId?: string | null; + }; + } + + it('returns shared on-disk ACP catalog while cross-process ACP lock is held', async () => { + mkdirSync(join(testHome, 'locks', 'agent-acp-active'), { recursive: true }); + writeFileSync(join(testHome, 'locks', 'agent-acp-active', 'pid'), String(process.pid)); + + seedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'stale-cli-sku' }], + currentModelId: 'stale-cli-sku' + }); + writeSharedCursorModelsCache({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }); + + const result = await listViaRpc(); + + expect(result).toEqual({ + success: true, + availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }], + currentModelId: 'composer-2.5[fast=true]' + }); + }); + + it('returns empty list when ACP lock is held but no shared cache exists yet', async () => { + mkdirSync(join(testHome, 'locks', 'agent-acp-active'), { recursive: true }); + writeFileSync(join(testHome, 'locks', 'agent-acp-active', 'pid'), String(process.pid)); + + const result = await listViaRpc(); + + expect(result).toEqual({ + success: true, + availableModels: [], + currentModelId: null + }); + }); +}); diff --git a/docs/guide/cursor.md b/docs/guide/cursor.md index 434214d0..ca919d51 100644 --- a/docs/guide/cursor.md +++ b/docs/guide/cursor.md @@ -41,12 +41,12 @@ Set mode via `--mode` flag or change from the web UI during a session. ## Modes - **Local mode** - Run `hapi cursor` from terminal. Full interactive experience. -- **Remote mode** - Spawn from web/phone when no terminal. Uses `agent -p` with `--output-format stream-json` and `--trust`. Each user message spawns one agent process; session continues via `--resume`. +- **Remote mode** - Spawn from web/phone when no terminal. New Cursor sessions use `agent acp` with HAPI permission approval, plan/question UI, and richer tool updates. Legacy sessions created before the ACP migration may still resume via the old `agent -p` stream-json path temporarily. ## Limitations -- **Tool approval** - In remote mode, `--trust` is used; tools run without per-request approval. Use `--yolo` for full bypass. -- **Session resume** - Pass `--resume ` or `--continue` to resume. Use `agent ls` to list previous chats and get chat IDs. +- **Legacy sessions** - Cursor sessions created before the ACP migration can still resume temporarily via stream-json. Start a new Cursor session to get ACP permissions, plans, todos, and question support. +- **Session resume** - ACP sessions resume through `session/load`. Old stream-json `session_id` values are not loadable via ACP; those sessions keep using the legacy path until you start fresh. ### Headless safety: AskQuestion behavior diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 705d35a1..8c98fad9 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -60,5 +60,13 @@ describe('RpcGateway RPC timeouts', () => { expect(timeouts).toEqual([120_000]) }) + + it('uses an extended RPC timeout when listing Cursor models for a machine', async () => { + const { gateway, timeouts } = createGateway() + + await gateway.listCursorModelsForMachine('machine-1') + + expect(timeouts).toEqual([120_000]) + }) }) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 8006cc24..50e558f2 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -7,6 +7,7 @@ import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos' import { extractBackgroundTaskDelta } from './backgroundTasks' const QUEUED_MESSAGE_THINKING_GRACE_MS = 15_000 +type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'collaborationMode' export class SessionCache { private readonly sessions: Map = new Map() @@ -15,6 +16,7 @@ export class SessionCache { private readonly deduplicateInProgress: Set = new Set() private readonly deduplicatePending: Set = new Set() private readonly pendingThinkingUntilBySessionId: Map = new Map() + private readonly runtimeConfigUpdatedAtBySessionId: Map>> = new Map() constructor( private readonly store: Store, @@ -79,6 +81,7 @@ export class SessionCache { if (!stored) { const existed = this.sessions.delete(sessionId) this.pendingThinkingUntilBySessionId.delete(sessionId) + this.runtimeConfigUpdatedAtBySessionId.delete(sessionId) if (existed) { this.publisher.emit({ type: 'session-removed', sessionId }) } @@ -197,11 +200,11 @@ export class SessionCache { if (requestedThinking || pendingThinkingUntil <= hubNow) { this.pendingThinkingUntilBySessionId.delete(session.id) } - if (payload.permissionMode !== undefined) { + if (payload.permissionMode !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'permissionMode', t)) { session.permissionMode = payload.permissionMode this.persistPreferredPermissionMode(session, payload.permissionMode) } - if (payload.model !== undefined) { + if (payload.model !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'model', t)) { if (payload.model !== session.model) { this.store.sessions.setSessionModel(payload.sid, payload.model, session.namespace, { touchUpdatedAt: false @@ -209,7 +212,7 @@ export class SessionCache { } session.model = payload.model } - if (payload.modelReasoningEffort !== undefined) { + if (payload.modelReasoningEffort !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'modelReasoningEffort', t)) { if (payload.modelReasoningEffort !== session.modelReasoningEffort) { this.store.sessions.setSessionModelReasoningEffort(payload.sid, payload.modelReasoningEffort, session.namespace, { touchUpdatedAt: false @@ -217,7 +220,7 @@ export class SessionCache { } session.modelReasoningEffort = payload.modelReasoningEffort } - if (payload.effort !== undefined) { + if (payload.effort !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'effort', t)) { if (payload.effort !== session.effort) { this.store.sessions.setSessionEffort(payload.sid, payload.effort, session.namespace, { touchUpdatedAt: false @@ -225,7 +228,7 @@ export class SessionCache { } session.effort = payload.effort } - if (payload.collaborationMode !== undefined) { + if (payload.collaborationMode !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'collaborationMode', t)) { session.collaborationMode = payload.collaborationMode } @@ -412,9 +415,11 @@ export class SessionCache { return } + const appliedAt = Date.now() if (config.permissionMode !== undefined) { session.permissionMode = config.permissionMode this.persistPreferredPermissionMode(session, config.permissionMode) + this.markRuntimeConfigUpdated(sessionId, 'permissionMode', appliedAt) } if (config.model !== undefined) { if (config.model !== session.model) { @@ -426,6 +431,7 @@ export class SessionCache { } } session.model = config.model + this.markRuntimeConfigUpdated(sessionId, 'model', appliedAt) } if (config.modelReasoningEffort !== undefined) { if (config.modelReasoningEffort !== session.modelReasoningEffort) { @@ -437,6 +443,7 @@ export class SessionCache { } } session.modelReasoningEffort = config.modelReasoningEffort + this.markRuntimeConfigUpdated(sessionId, 'modelReasoningEffort', appliedAt) } if (config.effort !== undefined) { if (config.effort !== session.effort) { @@ -448,14 +455,35 @@ export class SessionCache { } } session.effort = config.effort + this.markRuntimeConfigUpdated(sessionId, 'effort', appliedAt) } if (config.collaborationMode !== undefined) { session.collaborationMode = config.collaborationMode + this.markRuntimeConfigUpdated(sessionId, 'collaborationMode', appliedAt) } this.publisher.emit({ type: 'session-updated', sessionId, data: session }) } + private markRuntimeConfigUpdated( + sessionId: string, + key: RuntimeConfigKey, + at: number + ): void { + const existing = this.runtimeConfigUpdatedAtBySessionId.get(sessionId) ?? {} + existing[key] = at + this.runtimeConfigUpdatedAtBySessionId.set(sessionId, existing) + } + + private isStaleRuntimeKeepAlive( + sessionId: string, + key: RuntimeConfigKey, + payloadTime: number + ): boolean { + const updatedAt = this.runtimeConfigUpdatedAtBySessionId.get(sessionId)?.[key] + return updatedAt !== undefined && payloadTime < updatedAt + } + async renameSession(sessionId: string, name: string): Promise { const session = this.sessions.get(sessionId) if (!session) { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index a9aaaa29..6cb45ebc 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -119,6 +119,120 @@ describe('session model', () => { expect(store.sessions.getSession(session.id)?.model).toBeNull() }) + it('ignores stale keepalive model values after an applied config update', () => { + const originalDateNow = Date.now + let now = 1_780_000_000_000 + Date.now = () => now + try { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-stale-heartbeat', + { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }, + null, + 'default', + 'composer-2.5[fast=true]' + ) + + const staleKeepAliveTime = now + now += 1_000 + cache.applySessionConfig(session.id, { model: 'gpt-5.5[reasoning=medium]' }) + + cache.handleSessionAlive({ + sid: session.id, + time: staleKeepAliveTime, + thinking: false, + model: 'composer-2.5[fast=true]' + }) + + expect(cache.getSession(session.id)?.model).toBe('gpt-5.5[reasoning=medium]') + expect(store.sessions.getSession(session.id)?.model).toBe('gpt-5.5[reasoning=medium]') + + now += 1_000 + cache.handleSessionAlive({ + sid: session.id, + time: now, + thinking: false, + model: 'claude-opus-4-8[effort=high]' + }) + + expect(cache.getSession(session.id)?.model).toBe('claude-opus-4-8[effort=high]') + } finally { + Date.now = originalDateNow + } + }) + + it('syncs cursor spawn model to resolved ACP wire id via keepalive', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-spawn-model', + { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }, + null, + 'default', + 'composer-2.5' + ) + + expect(session.model).toBe('composer-2.5') + + cache.handleSessionAlive({ + sid: session.id, + time: Date.now(), + thinking: false, + model: 'composer-2.5[fast=true]' + }) + + expect(cache.getSession(session.id)?.model).toBe('composer-2.5[fast=true]') + expect(store.sessions.getSession(session.id)?.model).toBe('composer-2.5[fast=true]') + }) + + it('passes cursor spawn model to runner when spawning a remote session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + engine.getOrCreateMachine( + 'machine-cursor', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-cursor', time: Date.now() }) + + let capturedModel: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + agent: string, + model?: string + ) => { + capturedModel = model + return { type: 'success', sessionId: 'spawned-cursor-session' } + } + + const result = await engine.spawnSession( + 'machine-cursor', + '/tmp/project', + 'cursor', + 'composer-2.5[fast=false]' + ) + + expect(result).toEqual({ type: 'success', sessionId: 'spawned-cursor-session' }) + expect(capturedModel).toBe('composer-2.5[fast=false]') + } finally { + engine.stop() + } + }) + it('persists keepalive model changes, including clearing the model', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 52164fb3..cd08717c 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -459,6 +459,7 @@ export class SyncEngine { throw new Error('Invalid response from session config RPC') } const obj = result as { + error?: string applied?: { permissionMode?: Session['permissionMode'] model?: Session['model'] @@ -467,6 +468,9 @@ export class SyncEngine { collaborationMode?: Session['collaborationMode'] } } + if (typeof obj.error === 'string' && obj.error.trim().length > 0) { + throw new Error(obj.error) + } const applied = obj.applied if (!applied || typeof applied !== 'object') { throw new Error('Missing applied session config') diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 21302013..3c4e6457 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -121,6 +121,49 @@ describe('machines routes', () => { }) }) + it('returns 503 when cursor-models is requested without a sync engine', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => null)) + + const response = await app.request('/api/machines/machine-1/cursor-models') + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + success: false, + error: 'Not connected' + }) + }) + + it('returns 500 when listing Cursor models fails', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCursorModelsForMachine: async () => { + throw new Error('rpc offline') + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/cursor-models') + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + success: false, + error: 'rpc offline' + }) + }) + it('returns Cursor models for an online machine', async () => { const machine = createMachine() const engine = { @@ -155,4 +198,39 @@ describe('machines routes', () => { currentModelId: 'composer-2.5' }) }) + + it('returns ACP wire ids from the machine RPC for New Session model pickers', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCursorModelsForMachine: async () => ({ + success: true, + availableModels: [ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ], + currentModelId: 'composer-2.5[fast=true]' + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/cursor-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ], + currentModelId: 'composer-2.5[fast=true]' + }) + }) }) diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 49c9cc6a..68e1f9f1 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -446,6 +446,34 @@ describe('sessions routes', () => { ]) }) + it('rejects model changes for local Cursor sessions', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor' + }, + agentState: { + controlledByUser: true, + requests: {}, + completedRequests: {} + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'composer-2.5[fast=true]' }) + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Model selection can only be changed for remote Cursor sessions' + }) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 55271efd..50b6f665 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -378,8 +378,13 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!supportsModelChange(flavor)) { return c.json({ error: 'Model selection is not supported for this session' }, 400) } - if (flavor === 'codex' && sessionResult.session.agentState?.controlledByUser === true) { - return c.json({ error: 'Model selection can only be changed for remote Codex sessions' }, 409) + if (sessionResult.session.agentState?.controlledByUser === true) { + if (flavor === 'codex') { + return c.json({ error: 'Model selection can only be changed for remote Codex sessions' }, 409) + } + if (flavor === 'cursor') { + return c.json({ error: 'Model selection can only be changed for remote Cursor sessions' }, 409) + } } try { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 5c71d53f..2762a373 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -296,6 +296,8 @@ export type OpencodeModelSummary = { export type OpencodeModelsResponse = { success: boolean availableModels?: OpencodeModelSummary[] + /** CLI `agent --list-models` skus grouped under ACP wire bases for variant pickers. */ + cliModelSkus?: OpencodeModelSummary[] currentModelId?: string | null error?: string } diff --git a/shared/src/cursorCliSku.test.ts b/shared/src/cursorCliSku.test.ts new file mode 100644 index 00000000..5f5cb7ca --- /dev/null +++ b/shared/src/cursorCliSku.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + cursorCliSkuBaseId, + findBestCliSkuForAcpWire, + isCursorAcpWireModelId, + matchCliSkuToAcpWireId +} from './cursorCliSku'; + +describe('cursorCliSkuBaseId', () => { + it('strips effort/speed suffixes from CLI skus', () => { + expect(cursorCliSkuBaseId('gpt-5.5-high-fast')).toBe('gpt-5.5'); + expect(cursorCliSkuBaseId('composer-2.5-fast')).toBe('composer-2.5'); + expect(cursorCliSkuBaseId('gpt-5.3-codex-xhigh-fast')).toBe('gpt-5.3-codex'); + }); + + it('keeps wire base ids unchanged', () => { + expect(cursorCliSkuBaseId('composer-2.5[fast=true]')).toBe('composer-2.5'); + }); +}); + +describe('matchCliSkuToAcpWireId', () => { + const available = [ + { modelId: 'composer-2.5[fast=true]' }, + { modelId: 'composer-2.5[fast=false]' }, + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' } + ]; + + it('returns exact wire matches', () => { + expect(matchCliSkuToAcpWireId('composer-2.5[fast=false]', available)).toBe('composer-2.5[fast=false]'); + }); + + it('maps CLI skus onto the matching ACP wire for the same base', () => { + expect(matchCliSkuToAcpWireId('composer-2.5-fast', available)).toBe('composer-2.5[fast=true]'); + expect(matchCliSkuToAcpWireId('gpt-5.5-medium', available)).toBe('gpt-5.5[context=272k,reasoning=medium,fast=false]'); + }); + + it('picks the best wire when multiple ACP variants exist', () => { + expect(matchCliSkuToAcpWireId('composer-2.5', available)).toBe('composer-2.5[fast=true]'); + }); +}); + +describe('findBestCliSkuForAcpWire', () => { + it('picks the sku that best matches wire params, not the first partial match', () => { + const wire = 'gpt-5.5[context=272k,reasoning=medium,fast=false]'; + const best = findBestCliSkuForAcpWire(wire, [ + 'gpt-5.5-high-fast', + 'gpt-5.5-medium', + 'gpt-5.5-low' + ]); + expect(best).toBe('gpt-5.5-medium'); + }); +}); + +describe('isCursorAcpWireModelId', () => { + it('detects wire ids', () => { + expect(isCursorAcpWireModelId('gpt-5.5[fast=false]')).toBe(true); + expect(isCursorAcpWireModelId('gpt-5.5-high-fast')).toBe(false); + }); +}); diff --git a/shared/src/cursorCliSku.ts b/shared/src/cursorCliSku.ts new file mode 100644 index 00000000..f72d3eda --- /dev/null +++ b/shared/src/cursorCliSku.ts @@ -0,0 +1,189 @@ +/** ACP wire ids use bracket params; CLI `agent --list-models` slugs do not. */ +export function isCursorAcpWireModelId(modelId: string): boolean { + const trimmed = modelId.trim(); + return trimmed === 'default[]' || trimmed.includes('['); +} + +export function cursorModelBaseId(modelId: string): string { + const trimmed = modelId.trim(); + const bracket = trimmed.indexOf('['); + return bracket === -1 ? trimmed : trimmed.slice(0, bracket); +} + +/** Longest-first suffixes from Cursor CLI sku ids (e.g. `gpt-5.5-high-fast` → `gpt-5.5`). */ +const CLI_SKU_SUFFIXES = [ + '-extra-high-fast', + '-extra-high', + '-xhigh-fast', + '-xhigh', + '-high-fast', + '-high', + '-medium-fast', + '-medium', + '-low-fast', + '-low', + '-none-fast', + '-none', + '-thinking-high-fast', + '-thinking-high', + '-thinking', + '-fast', +] as const; + +export function cursorCliSkuBaseId(slug: string): string { + const trimmed = slug.trim(); + if (!trimmed || isCursorAcpWireModelId(trimmed)) { + return cursorModelBaseId(trimmed); + } + + let base = trimmed; + let changed = true; + while (changed) { + changed = false; + for (const suffix of CLI_SKU_SUFFIXES) { + if (base.endsWith(suffix)) { + base = base.slice(0, -suffix.length); + changed = true; + break; + } + } + } + return base; +} + +function parseWireParams(modelId: string): Record { + const variant = modelId.includes('[') ? modelId.slice(modelId.indexOf('[') + 1).replace(/\]$/, '') : ''; + if (!variant) { + return {}; + } + + const params: Record = {}; + for (const part of variant.split(',')) { + const segment = part.trim(); + if (!segment) continue; + const eq = segment.indexOf('='); + if (eq === -1) { + params[segment] = 'true'; + continue; + } + params[segment.slice(0, eq).trim()] = segment.slice(eq + 1).trim(); + } + return params; +} + +function inferSkuParamHints(slug: string): Record { + const lower = slug.toLowerCase(); + const hints: Record = {}; + + if (lower.includes('extra-high') || lower.includes('xhigh')) { + hints.reasoning = 'extra-high'; + hints.effort = 'xhigh'; + } else if (lower.includes('-high')) { + hints.reasoning = 'high'; + hints.effort = 'high'; + } else if (lower.includes('-low')) { + hints.reasoning = 'low'; + hints.effort = 'low'; + } else if (lower.includes('-medium')) { + hints.reasoning = 'medium'; + hints.effort = 'medium'; + } else if (lower.includes('-none')) { + hints.reasoning = 'none'; + } + + if (lower.endsWith('-fast') || lower.includes('-fast')) { + hints.fast = 'true'; + } + + if (lower.includes('thinking')) { + hints.thinking = 'true'; + } + + return hints; +} + +function scoreWireAgainstSku(slug: string, wireId: string): number { + const hints = inferSkuParamHints(slug); + const params = parseWireParams(wireId); + let score = 0; + + for (const [key, value] of Object.entries(hints)) { + if (params[key] === value) { + score += 2; + } else if (params[key] !== undefined) { + score -= 1; + } + } + + return score; +} + +/** Best-matching CLI sku for highlighting when session state stores an ACP wire id. */ +export function findBestCliSkuForAcpWire( + wireId: string, + skuIds: readonly string[] +): string | null { + let best: string | null = null; + let bestScore = Number.NEGATIVE_INFINITY; + + for (const sku of skuIds) { + const trimmed = sku.trim(); + if (!trimmed || isCursorAcpWireModelId(trimmed)) { + continue; + } + if (matchCliSkuToAcpWireId(trimmed, [{ modelId: wireId }]) !== wireId) { + continue; + } + const score = scoreWireAgainstSku(trimmed, wireId); + if (score > bestScore) { + bestScore = score; + best = trimmed; + } + } + + return best; +} + +/** + * Map UI/CLI model id (wire or slug) onto an ACP configOptions wire id. + */ +export function matchCliSkuToAcpWireId( + requested: string, + available: readonly { modelId: string }[] +): string | null { + const trimmed = requested.trim(); + if (!trimmed) { + return null; + } + + const exact = available.find((entry) => entry.modelId === trimmed); + if (exact) { + return exact.modelId; + } + + if (isCursorAcpWireModelId(trimmed)) { + return null; + } + + const skuBase = cursorCliSkuBaseId(trimmed); + const wires = available.filter( + (entry) => isCursorAcpWireModelId(entry.modelId) && cursorModelBaseId(entry.modelId) === skuBase + ); + if (wires.length === 0) { + return null; + } + if (wires.length === 1) { + return wires[0].modelId; + } + + let best = wires[0].modelId; + let bestScore = Number.NEGATIVE_INFINITY; + for (const entry of wires) { + const score = scoreWireAgainstSku(trimmed, entry.modelId); + if (score > bestScore) { + bestScore = score; + best = entry.modelId; + } + } + return best; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index d95373fa..b8f5e291 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './apiTypes' +export * from './cursorCliSku' export * from './messages' export * from './buildInfo' export * from './effort' diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 2409018d..c8eb7389 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -29,7 +29,7 @@ export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number] export const OPENCODE_PERMISSION_MODES = ['default', 'plan', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] -export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'yolo'] as const +export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'debug', 'yolo'] as const export type CursorPermissionMode = typeof CURSOR_PERMISSION_MODES[number] export const PERMISSION_MODES = [ @@ -38,6 +38,7 @@ export const PERMISSION_MODES = [ 'bypassPermissions', 'plan', 'ask', + 'debug', 'read-only', 'safe-yolo', 'yolo' @@ -50,6 +51,7 @@ export const PERMISSION_MODE_LABELS: Record = { acceptEdits: 'Accept Edits', plan: 'Plan Mode', ask: 'Ask Mode', + debug: 'Debug Mode', bypassPermissions: 'Yolo', 'read-only': 'Read Only', 'safe-yolo': 'Safe Yolo', @@ -63,6 +65,7 @@ export const PERMISSION_MODE_TONES: Record = acceptEdits: 'warning', plan: 'info', ask: 'info', + debug: 'info', bypassPermissions: 'danger', 'read-only': 'warning', 'safe-yolo': 'warning', diff --git a/shared/src/schemas.metadata.test.ts b/shared/src/schemas.metadata.test.ts new file mode 100644 index 00000000..b29a6706 --- /dev/null +++ b/shared/src/schemas.metadata.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { MetadataSchema } from './schemas'; + +describe('MetadataSchema cursorSessionProtocol', () => { + const base = { + path: '/tmp', + host: 'test' + }; + + it('accepts acp and stream-json protocol values', () => { + expect(MetadataSchema.safeParse({ ...base, cursorSessionProtocol: 'acp' }).success).toBe(true); + expect(MetadataSchema.safeParse({ ...base, cursorSessionProtocol: 'stream-json' }).success).toBe(true); + }); + + it('rejects unknown protocol values', () => { + expect(MetadataSchema.safeParse({ ...base, cursorSessionProtocol: 'websocket' }).success).toBe(false); + }); +}); diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 53267a18..f1087aee 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -38,6 +38,7 @@ export const MetadataSchema = z.object({ geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), cursorSessionId: z.string().optional(), + cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(), kimiSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index 4572ac81..b0c06976 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -40,8 +40,7 @@ export const BUILTIN_SLASH_COMMANDS = { { name: 'init', description: 'Generate or refresh AGENTS.md for this project', source: 'builtin' }, ], cursor: [ - { name: 'summarize', description: 'Summarize conversation context to free window space (pass-through to Cursor agent)', source: 'builtin' }, - { name: 'clear', description: 'Clear conversation context if supported by Cursor agent', source: 'builtin' }, + { name: 'compress', description: 'Compress conversation context to free window space (pass-through to Cursor agent)', source: 'builtin' }, ], } as const satisfies Record diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index 308ff152..6917c6c5 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -14,6 +14,10 @@ describe('getContextBudgetTokens', () => { expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBe(248_400) }) + it('parses context budget from Cursor wire ids', () => { + expect(getContextBudgetTokens('composer-2.5-fast[context=300k]', 'cursor')).toBe(290_000) + }) + it('returns null for unknown non-Claude sessions', () => { expect(getContextBudgetTokens('gemini-3-pro', 'gemini')).toBeNull() }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index 47542c10..a079e030 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -17,11 +17,45 @@ const LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS = 1_000_000 // The value matches the context window currently reported by Codex App Server token-count events. const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 258_400 +function parseCursorWireContextWindow(model: string): number | null { + const match = model.match(/\[([^\]]+)\]/) + if (!match) { + return null + } + for (const segment of match[1].split(',')) { + const part = segment.trim() + const eq = part.indexOf('=') + if (eq === -1 || part.slice(0, eq).trim() !== 'context') { + continue + } + const raw = part.slice(eq + 1).trim().toLowerCase() + const digits = raw.match(/(\d+)/)?.[1] + if (!digits) { + return null + } + const value = Number.parseInt(digits, 10) + if (!Number.isFinite(value) || value <= 0) { + return null + } + return raw.endsWith('k') ? value * 1000 : value + } + return null +} + export function getContextBudgetTokens(model: string | null | undefined, flavor?: string | null): number | null { if (flavor === 'codex') { return Math.max(1, DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) } + if (flavor === 'cursor') { + const trimmedModel = model?.trim() + const windowTokens = trimmedModel ? parseCursorWireContextWindow(trimmedModel) : null + if (!windowTokens) { + return null + } + return Math.max(1, windowTokens - CONTEXT_HEADROOM_TOKENS) + } + if (flavor !== 'claude') { return null } diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 3e32964f..3a3db02e 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -522,6 +522,54 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes ACP plan messages as completed update_plan snapshots', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'plan', + entries: [ + { content: 'Inspect event stream', status: 'completed' }, + { content: 'Render plan card', status: 'in_progress' } + ], + id: 'cursor-plan-1' + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [ + { + type: 'tool-call', + id: 'cursor-plan-state', + name: 'update_plan', + input: { + plan: [ + { step: 'Inspect event stream', status: 'completed' }, + { step: 'Render plan card', status: 'in_progress' } + ], + source: 'cursor' + } + }, + { + type: 'tool-result', + tool_use_id: 'cursor-plan-state', + content: { + plan: [ + { step: 'Inspect event stream', status: 'completed' }, + { step: 'Render plan card', status: 'in_progress' } + ], + source: 'cursor' + } + } + ] + }) + }) + it('normalizes Codex plan updates as completed update_plan snapshots', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index b56333c2..39de43c6 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -722,6 +722,45 @@ export function normalizeAgentRecord( } } + if (data.type === 'plan') { + const plan = normalizePlanEntries(data.entries ?? data.items ?? data) + if (plan.length === 0) return null + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [ + { + type: 'tool-call', + id: 'cursor-plan-state', + name: 'update_plan', + input: { + plan, + source: 'cursor' + }, + description: null, + uuid, + parentUUID: null + }, + { + type: 'tool-result', + tool_use_id: 'cursor-plan-state', + content: { + plan, + source: 'cursor' + }, + is_error: false, + uuid, + parentUUID: null + } + ], + meta + } + } + if (data.type === 'plan_update') { const plan = normalizePlanEntries(data.plan ?? data.update ?? data.items ?? data.steps ?? data) if (plan.length === 0) return null diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 12c8ff68..c47dcdae 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -26,7 +26,7 @@ import { useComposerDraft } from '@/hooks/useComposerDraft' import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' -import { StatusBar } from '@/components/AssistantChat/StatusBar' +import { shouldShowComposerStatusBar, StatusBar } from '@/components/AssistantChat/StatusBar' import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem' @@ -62,9 +62,17 @@ export function HappyComposer(props: { controlledByUser?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> + /** Cursor: selected base model key (not wire id). */ + selectedModelBase?: string | null + /** Cursor: selected variant sku/wire for highlight when session stores an ACP wire id. */ + selectedModelVariant?: string | null + /** Cursor: effort/variant wire ids for the selected base model. */ + modelEffortOptions?: Array<{ value: string; label: string }> onCollaborationModeChange?: (mode: CodexCollaborationMode) => void onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void + /** Cursor: effort/variant wire id (separate from base model change). */ + onModelEffortChange?: (wireId: string | null) => void onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void onEffortChange?: (effort: string | null) => void onSwitchToRemote?: () => void @@ -103,9 +111,13 @@ export function HappyComposer(props: { controlledByUser = false, agentFlavor, availableModelOptions, + selectedModelBase, + selectedModelVariant, + modelEffortOptions, onCollaborationModeChange, onPermissionModeChange, onModelChange, + onModelEffortChange, onModelReasoningEffortChange, onEffortChange, onSwitchToRemote, @@ -497,6 +509,14 @@ export function HappyComposer(props: { haptic('light') }, [onModelChange, controlsDisabled, haptic]) + const handleModelEffortChange = useCallback((nextWireId: string | null) => { + const handler = onModelEffortChange ?? onModelChange + if (!handler || controlsDisabled) return + handler(nextWireId) + setShowSettings(false) + haptic('light') + }, [onModelEffortChange, onModelChange, controlsDisabled, haptic]) + const handleModelReasoningEffortChange = useCallback((nextModelReasoningEffort: string | null) => { if (!onModelReasoningEffortChange || controlsDisabled) return onModelReasoningEffortChange(nextModelReasoningEffort) @@ -514,12 +534,18 @@ export function HappyComposer(props: { const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && modelOptions.length > 0) + const showModelEffortSettings = Boolean( + (onModelEffortChange ?? onModelChange) + && modelEffortOptions + && modelEffortOptions.length > 0 + ) const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0) const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) const showSettingsButton = Boolean( showCollaborationSettings || showPermissionSettings || showModelSettings + || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings ) @@ -536,7 +562,7 @@ export function HappyComposer(props: { }, [api]) const overlays = useMemo(() => { - if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings)) { + if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings)) { return (
@@ -618,7 +644,7 @@ export function HappyComposer(props: {
) : null} - {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? ( + {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
) : null} @@ -627,7 +653,11 @@ export function HappyComposer(props: {
{t('misc.model')}
- {modelOptions.map((option) => ( + {modelOptions.map((option) => { + const isSelected = selectedModelBase !== undefined + ? selectedModelBase === option.value + : model === option.value + return ( + ) + })} +
+ ) : null} + + {showModelSettings && showModelEffortSettings ? ( +
+ ) : null} + + {showModelEffortSettings ? ( +
+
+ {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} +
+ {modelEffortOptions!.map((option) => ( + @@ -659,7 +731,7 @@ export function HappyComposer(props: {
) : null} - {(showModelSettings || showModelReasoningEffortSettings) && showEffortSettings ? ( + {(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
) : null} @@ -765,6 +837,10 @@ export function HappyComposer(props: { showCollaborationSettings, showPermissionSettings, showModelSettings, + showModelEffortSettings, + modelEffortOptions, + selectedModelBase, + selectedModelVariant, showModelReasoningEffortSettings, showEffortSettings, modelOptions, @@ -795,22 +871,24 @@ export function HappyComposer(props: { {overlays} - + {shouldShowComposerStatusBar(agentFlavor) ? ( + + ) : null}
{attachments.length > 0 ? ( diff --git a/web/src/components/AssistantChat/StatusBar.test.ts b/web/src/components/AssistantChat/StatusBar.test.ts new file mode 100644 index 00000000..0a52dca5 --- /dev/null +++ b/web/src/components/AssistantChat/StatusBar.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowComposerStatusBar } from './StatusBar' + +describe('shouldShowComposerStatusBar', () => { + it('hides the composer status bar for Cursor sessions', () => { + expect(shouldShowComposerStatusBar('cursor')).toBe(false) + }) + + it('shows the composer status bar for other agents', () => { + expect(shouldShowComposerStatusBar('claude')).toBe(true) + expect(shouldShowComposerStatusBar('codex')).toBe(true) + expect(shouldShowComposerStatusBar(null)).toBe(true) + }) +}) diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 08547d06..3f974a8b 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -139,6 +139,11 @@ function isCodexFastMode(model?: string | null, effort?: string | null): boolean return normalizedModel.includes('mini') || normalizedModel.includes('fast') } +/** Cursor native ACP does not emit usage_update; hide the bar to avoid empty/misleading UI. */ +export function shouldShowComposerStatusBar(agentFlavor: string | null | undefined): boolean { + return agentFlavor !== 'cursor' +} + export function StatusBar(props: { active: boolean thinking: boolean diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index e5fadbcb..3df8f28d 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -101,6 +101,33 @@ describe('getModelOptionsForFlavor', () => { ]) }) + it('does not inject raw wire id when dual picker base is already listed', () => { + const wire = 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]' + const options = getModelOptionsForFlavor('cursor', wire, [ + { value: null, label: 'Default' }, + { value: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, + { value: 'composer-2.5', label: 'Composer 2.5' }, + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, + { value: 'composer-2.5', label: 'Composer 2.5' }, + ]) + }) + + it('injects unknown wire id only when catalog lacks base and wire', () => { + const wire = 'claude-opus-4-9[effort=high,fast=false]' + const options = getModelOptionsForFlavor('cursor', wire, [ + { value: null, label: 'Default' }, + { value: 'composer-2.5', label: 'Composer 2.5' }, + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: wire, label: wire }, + { value: 'composer-2.5', label: 'Composer 2.5' }, + ]) + }) + it('includes the current opencode model when it is missing from explicit options', () => { const options = getModelOptionsForFlavor('opencode', 'ollama/legacy', [ { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index cab7c3a8..11a5fea4 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -13,6 +13,20 @@ function normalizeCurrentModel(model?: string | null): string | null { return trimmedModel } +/** Base id before ACP wire suffix, e.g. `claude-opus-4-8[effort=high]` → `claude-opus-4-8`. */ +function cursorWireBaseId(modelId: string): string { + const bracket = modelId.indexOf('[') + return bracket === -1 ? modelId : modelId.slice(0, bracket) +} + +function cursorCatalogCoversCurrentModel(options: ModelOption[], currentModel: string): boolean { + if (options.some((option) => option.value === currentModel)) { + return true + } + const baseId = cursorWireBaseId(currentModel) + return options.some((option) => option.value === baseId) +} + function withCurrentModelOption(options: ModelOption[], currentModel?: string | null): ModelOption[] { const normalizedCurrentModel = normalizeCurrentModel(currentModel) if (!normalizedCurrentModel || options.some((option) => option.value === normalizedCurrentModel)) { @@ -87,6 +101,13 @@ export function getModelOptionsForFlavor( return getClaudeModelOptions(currentModel, customOptions) } if (customOptions && customOptions.length > 0) { + if (flavor === 'cursor') { + const normalizedCurrent = normalizeCurrentModel(currentModel) + if (!normalizedCurrent || cursorCatalogCoversCurrentModel(customOptions, normalizedCurrent)) { + return customOptions + } + return withCurrentModelOption(customOptions, currentModel) + } return withCurrentModelOption(customOptions, currentModel) } if (flavor === 'gemini') { diff --git a/web/src/components/NewSession/ModelSelector.tsx b/web/src/components/NewSession/ModelSelector.tsx index 7977c55a..248b1193 100644 --- a/web/src/components/NewSession/ModelSelector.tsx +++ b/web/src/components/NewSession/ModelSelector.tsx @@ -5,6 +5,7 @@ import { useTranslation } from '@/lib/use-translation' export function ModelSelector(props: { agent: AgentType model: string + label?: string options?: Array<{ value: string; label: string }> isDisabled: boolean isLoading?: boolean @@ -20,8 +21,10 @@ export function ModelSelector(props: { return (
controlled when catalog reloads after Browse remount. */ +export function resolveNewSessionCursorEffortSelectValue( + model: string, + effortOptions: readonly { value: string }[] +): string { + if (effortOptions.length === 0) { + return 'auto' + } + if (model !== 'auto' && effortOptions.some((row) => row.value === model)) { + return model + } + return 'auto' +} + +export function isCursorEffortWireAllowed( + wireId: string, + catalog: CursorModelCatalog, + baseKey: string | null +): boolean { + if (!baseKey || baseKey === 'auto') { + return false + } + const variants = catalog.variantsByBase.get(baseKey) ?? [] + return variants.some((entry) => entry.wireId === wireId) +} + +export { pickCursorModelsForPicker, resolveWireIdForBaseChange, resolveCursorBaseFromWire } diff --git a/web/src/components/NewSession/newSessionFormDraft.test.ts b/web/src/components/NewSession/newSessionFormDraft.test.ts new file mode 100644 index 00000000..98507577 --- /dev/null +++ b/web/src/components/NewSession/newSessionFormDraft.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + clearNewSessionFormDraft, + loadNewSessionFormDraft, + newSessionDraftMatchesMachine, + saveNewSessionFormDraft, + shouldRestoreNewSessionFormDraft +} from './newSessionFormDraft' + +describe('newSessionFormDraft', () => { + afterEach(() => { + clearNewSessionFormDraft() + }) + + it('round-trips cursor model fields through sessionStorage', () => { + saveNewSessionFormDraft({ + agent: 'cursor', + model: 'composer-2.5[fast=false]', + cursorSelectedBase: 'composer-2.5', + machineId: 'machine-1', + effort: 'auto', + modelReasoningEffort: 'default', + yoloMode: false, + sessionType: 'simple', + worktreeName: '' + }) + + expect(loadNewSessionFormDraft()).toEqual({ + agent: 'cursor', + model: 'composer-2.5[fast=false]', + cursorSelectedBase: 'composer-2.5', + machineId: 'machine-1', + effort: 'auto', + modelReasoningEffort: 'default', + yoloMode: false, + sessionType: 'simple', + worktreeName: '' + }) + }) + + it('restores only when returning from browse with a directory in search', () => { + expect(shouldRestoreNewSessionFormDraft({})).toBe(false) + expect(shouldRestoreNewSessionFormDraft({ initialDirectory: '/tmp/proj' })).toBe(true) + expect(shouldRestoreNewSessionFormDraft({ + initialDirectory: '/tmp/proj', + initialMachineId: 'machine-1' + })).toBe(true) + }) + + it('matches machine when draft has no machine id', () => { + const draft = loadNewSessionFormDraft() + void draft + saveNewSessionFormDraft({ + agent: 'cursor', + model: 'auto', + cursorSelectedBase: 'auto', + machineId: null, + effort: 'auto', + modelReasoningEffort: 'default', + yoloMode: false, + sessionType: 'simple', + worktreeName: '' + }) + const loaded = loadNewSessionFormDraft()! + expect(newSessionDraftMatchesMachine(loaded, 'machine-1')).toBe(true) + }) + + it('rejects draft when machine id differs', () => { + saveNewSessionFormDraft({ + agent: 'cursor', + model: 'composer-2.5[fast=true]', + cursorSelectedBase: 'composer-2.5', + machineId: 'machine-a', + effort: 'auto', + modelReasoningEffort: 'default', + yoloMode: false, + sessionType: 'simple', + worktreeName: '' + }) + const draft = loadNewSessionFormDraft()! + expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false) + }) +}) diff --git a/web/src/components/NewSession/newSessionFormDraft.ts b/web/src/components/NewSession/newSessionFormDraft.ts new file mode 100644 index 00000000..3e19a8b5 --- /dev/null +++ b/web/src/components/NewSession/newSessionFormDraft.ts @@ -0,0 +1,75 @@ +import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types' + +const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft' + +export type NewSessionFormDraft = { + agent: AgentType + model: string + cursorSelectedBase: string + machineId: string | null + effort: ClaudeEffort + modelReasoningEffort: CodexReasoningEffort + yoloMode: boolean + sessionType: SessionType + worktreeName: string +} + +export function saveNewSessionFormDraft(draft: NewSessionFormDraft): void { + try { + sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)) + } catch { + // sessionStorage may be unavailable (private mode, quota) + } +} + +export function loadNewSessionFormDraft(): NewSessionFormDraft | null { + try { + const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY) + if (!raw) { + return null + } + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.agent !== 'string' || typeof parsed.model !== 'string') { + return null + } + return { + agent: parsed.agent as AgentType, + model: parsed.model, + cursorSelectedBase: typeof parsed.cursorSelectedBase === 'string' ? parsed.cursorSelectedBase : 'auto', + machineId: typeof parsed.machineId === 'string' ? parsed.machineId : null, + effort: (parsed.effort as ClaudeEffort | undefined) ?? 'auto', + modelReasoningEffort: (parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default', + yoloMode: Boolean(parsed.yoloMode), + sessionType: (parsed.sessionType as SessionType | undefined) ?? 'simple', + worktreeName: typeof parsed.worktreeName === 'string' ? parsed.worktreeName : '' + } + } catch { + return null + } +} + +export function clearNewSessionFormDraft(): void { + try { + sessionStorage.removeItem(DRAFT_STORAGE_KEY) + } catch { + // ignore + } +} + +/** Restore draft after Browse → /sessions/new?directory=… remount. */ +export function shouldRestoreNewSessionFormDraft(args: { + initialDirectory?: string + initialMachineId?: string +}): boolean { + return Boolean(args.initialDirectory?.trim()) +} + +export function newSessionDraftMatchesMachine( + draft: NewSessionFormDraft, + machineId: string | null | undefined +): boolean { + if (!draft.machineId || !machineId) { + return true + } + return draft.machineId === machineId +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index ae23d9b0..12c86dbf 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -34,6 +34,18 @@ import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModels } from '@/hooks/queries/useCursorModels' +import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' +import { + buildCursorCatalogFromSources, + buildCursorPickerState, + resolveCursorBaseFromWire +} from '@/lib/cursorPickerState' +import { + resolveSessionCursorBaseSelectValue, + resolveSessionCursorModelChange, + resolveSessionCursorVariantSelectValue +} from '@/lib/sessionChatCursorModel' +import { buildCursorEffortPickerOptions, resolveCursorVariantOptions } from '@/lib/cursorModelOptions' import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' import { useVoiceOptional } from '@/lib/voice-context' import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' @@ -163,6 +175,8 @@ export function SessionChat(props: { const visibleGroupsRef = useRef([]) const [forceScrollToken, setForceScrollToken] = useState(0) const [outlineOpen, setOutlineOpen] = useState(false) + const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') + const lastSyncedCursorModelRef = useRef(undefined) const agentFlavor = props.session.metadata?.flavor ?? null const controlledByUser = props.session.agentState?.controlledByUser === true const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser @@ -205,21 +219,91 @@ export function SessionChat(props: { sessionId: props.session.id, enabled: agentFlavor === 'cursor' && props.session.active }) - const cursorModelOptions = useMemo(() => { + const sessionMachineId = props.session.metadata?.machineId ?? null + const machineCursorModelsState = useCursorModelsForMachine({ + api: props.api, + machineId: sessionMachineId, + enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId) + }) + const sessionCliModelSkus = useMemo(() => { + if (cursorModelsState.cliModelSkus.length > 0) { + return cursorModelsState.cliModelSkus + } + return machineCursorModelsState.cliModelSkus + }, [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus]) + const cursorPicker = useMemo(() => { if (agentFlavor !== 'cursor') { - return undefined + return null } - return [ - { value: null, label: 'Default' }, - ...cursorModelsState.availableModels - .filter((cursorModel) => cursorModel.modelId !== 'auto') - .map((cursorModel) => ({ - value: cursorModel.modelId, - label: cursorModel.name ?? cursorModel.modelId - })) - ] - }, [agentFlavor, cursorModelsState.availableModels]) + const catalog = buildCursorCatalogFromSources({ + sessionModels: cursorModelsState.availableModels, + machineModels: machineCursorModelsState.availableModels, + cliModelSkus: sessionCliModelSkus, + currentWireId: cursorModelsState.currentModelId ?? props.session.model, + sessionModelFromHub: props.session.model, + defaultValue: null + }) + return buildCursorPickerState({ + catalog, + currentWireId: props.session.model ?? cursorModelsState.currentModelId, + defaultValue: null + }) + }, [ + agentFlavor, + cursorModelsState.availableModels, + cursorModelsState.cliModelSkus, + cursorModelsState.currentModelId, + machineCursorModelsState.availableModels, + sessionCliModelSkus, + props.session.model + ]) + + useEffect(() => { + if (agentFlavor !== 'cursor' || !cursorPicker) { + lastSyncedCursorModelRef.current = undefined + return + } + const sessionModel = props.session.model ?? null + const baseFromSession = sessionModel + ? resolveCursorBaseFromWire(sessionModel, cursorPicker.catalog) + : 'auto' + if (lastSyncedCursorModelRef.current === sessionModel) { + if (!sessionModel) { + return + } + setCursorSelectedBase((prev) => (prev === 'auto' ? baseFromSession : prev)) + return + } + lastSyncedCursorModelRef.current = sessionModel + setCursorSelectedBase(baseFromSession) + }, [agentFlavor, props.session.model, cursorPicker]) + + const cursorSelectedBaseValue = useMemo(() => ( + agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' + ? resolveSessionCursorBaseSelectValue(cursorPicker, cursorSelectedBase) + : undefined + ), [agentFlavor, cursorPicker, cursorSelectedBase]) + + const cursorModelEffortOptions = useMemo(() => { + if (agentFlavor !== 'cursor' || !cursorPicker) { + return undefined + } + if (cursorPicker.mode !== 'dual') { + return cursorPicker.effortOptions + } + const baseKey = cursorSelectedBaseValue && cursorSelectedBaseValue !== 'auto' + ? cursorSelectedBaseValue + : cursorPicker.baseKey + return buildCursorEffortPickerOptions(resolveCursorVariantOptions(baseKey ?? null, cursorPicker.catalog)) + }, [agentFlavor, cursorPicker, cursorSelectedBaseValue]) + + const cursorVariantSelectValue = useMemo(() => ( + agentFlavor === 'cursor' && cursorModelEffortOptions + ? resolveSessionCursorVariantSelectValue(props.session.model, cursorModelEffortOptions) + : null + ), [agentFlavor, cursorModelEffortOptions, props.session.model]) + const { abortSession, switchSession, @@ -469,6 +553,47 @@ export function SessionChat(props: { } }, [setModel, props.onRefresh, haptic]) + const handleCursorBaseModelChange = useCallback(async (baseKey: string | null) => { + if (!cursorPicker) { + await handleModelChange(baseKey) + return + } + const plan = resolveSessionCursorModelChange({ + picker: cursorPicker, + sessionModel: props.session.model, + cursorSelectedBase, + kind: cursorPicker.mode === 'flat' ? 'flat' : 'base', + value: baseKey + }) + if (!plan.ok) { + return + } + setCursorSelectedBase(plan.nextSelectedBase) + if (plan.shouldApply) { + await handleModelChange(plan.wireId) + } + }, [cursorPicker, cursorSelectedBase, handleModelChange, props.session.model]) + + const handleCursorEffortChange = useCallback(async (wireId: string | null) => { + if (!cursorPicker) { + await handleModelChange(wireId) + return + } + const plan = resolveSessionCursorModelChange({ + picker: cursorPicker, + sessionModel: props.session.model, + cursorSelectedBase, + kind: 'effort', + value: wireId + }) + if (!plan.ok) { + console.error(plan.reason) + return + } + setCursorSelectedBase(plan.nextSelectedBase) + await handleModelChange(plan.wireId) + }, [cursorPicker, cursorSelectedBase, handleModelChange, props.session.model]) + const handleModelReasoningEffortChange = useCallback(async (modelReasoningEffort: string | null) => { try { await setModelReasoningEffort(modelReasoningEffort) @@ -683,7 +808,13 @@ export function SessionChat(props: { agentFlavor === 'codex' ? codexModelOptions : agentFlavor === 'cursor' - ? cursorModelOptions + ? ( + cursorModelsState.isLoading + || !cursorPicker + || cursorPicker.modelOptions.length === 0 + ? undefined + : cursorPicker.modelOptions + ) : agentFlavor === 'opencode' ? opencodeModelOptions : undefined @@ -703,13 +834,44 @@ export function SessionChat(props: { : undefined } onPermissionModeChange={handlePermissionModeChange} + selectedModelBase={ + agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' + ? cursorSelectedBaseValue + : undefined + } + selectedModelVariant={ + agentFlavor === 'cursor' ? cursorVariantSelectValue : undefined + } + modelEffortOptions={ + agentFlavor === 'cursor' + && cursorPicker?.mode === 'dual' + && cursorModelEffortOptions + && cursorModelEffortOptions.length > 1 + ? cursorModelEffortOptions + : undefined + } onModelChange={ agentFlavor === 'codex' ? (props.session.active && !controlledByUser && !codexModelsState.error ? handleModelChange : undefined) : agentFlavor === 'cursor' - ? (props.session.active && !cursorModelsState.error ? handleModelChange : undefined) + ? (props.session.active + && !controlledByUser + && !cursorModelsState.isLoading + && !cursorModelsState.error + && cursorPicker + && cursorPicker.modelOptions.length > 0 + ? handleCursorBaseModelChange + : undefined) : handleModelChange } + onModelEffortChange={ + agentFlavor === 'cursor' + && props.session.active + && !controlledByUser + && !cursorModelsState.error + ? handleCursorEffortChange + : undefined + } onModelReasoningEffortChange={ (agentFlavor === 'codex' || agentFlavor === 'opencode') && props.session.active && !controlledByUser ? handleModelReasoningEffortChange diff --git a/web/src/components/ToolCard/AskUserQuestionFooter.tsx b/web/src/components/ToolCard/AskUserQuestionFooter.tsx index 161d3e43..7d550d7f 100644 --- a/web/src/components/ToolCard/AskUserQuestionFooter.tsx +++ b/web/src/components/ToolCard/AskUserQuestionFooter.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { isAskUserQuestionToolName, parseAskUserQuestionInput, type AskUserQuestionQuestion } from '@/components/ToolCard/askUserQuestion' +import { isCursorAskQuestionToolName, parseCursorAskQuestionInput } from '@/components/ToolCard/cursorAskQuestion' import { AskUserQuestionOptionBody, askUserQuestionQuoteClassName, @@ -53,15 +54,25 @@ function computeAnswersForQuestion( question: AskUserQuestionQuestion, selectedOptionIndices: number[], otherSelected: boolean, - otherText: string + otherText: string, + useStableIds: boolean ): string[] { const answers: string[] = [] for (const idx of selectedOptionIndices) { const opt = question.options[idx] if (!opt) continue + if (useStableIds) { + const optionId = opt.id?.trim() || opt.label.trim() + if (optionId.length > 0) { + answers.push(optionId) + } + continue + } const label = opt.label.trim() - if (label.length > 0) answers.push(label) + if (label.length > 0) { + answers.push(label) + } } const other = otherText.trim() @@ -82,7 +93,12 @@ export function AskUserQuestionFooter(props: { const { t } = useTranslation() const { haptic } = usePlatform() const permission = props.tool.permission - const parsed = useMemo(() => parseAskUserQuestionInput(props.tool.input), [props.tool.input]) + const useStableQuestionIds = isCursorAskQuestionToolName(props.tool.name) + const parsed = useMemo(() => ( + useStableQuestionIds + ? parseCursorAskQuestionInput(props.tool.input) + : parseAskUserQuestionInput(props.tool.input) + ), [props.tool.name, props.tool.input, useStableQuestionIds]) const questions = parsed.questions const [step, setStep] = useState(0) @@ -137,11 +153,18 @@ export function AskUserQuestionFooter(props: { question, selectedByQuestion[idx] ?? [], otherSelectedByQuestion[idx] ?? false, - otherTextByQuestion[idx] ?? '' + otherTextByQuestion[idx] ?? '', + useStableQuestionIds ) return answers.length > 0 ? answers : null } + const questionAnswerKey = (question: AskUserQuestionQuestion, index: number): string => ( + useStableQuestionIds && question.id?.trim() + ? question.id.trim() + : String(index) + ) + const submit = async () => { if (loading) return @@ -161,7 +184,11 @@ export function AskUserQuestionFooter(props: { setStep(i) return } - answers[String(i)] = a + const q = questions[i] + if (!q) { + continue + } + answers[questionAnswerKey(q, i)] = a } } diff --git a/web/src/components/ToolCard/PermissionFooter.tsx b/web/src/components/ToolCard/PermissionFooter.tsx index 9864e874..873c9c32 100644 --- a/web/src/components/ToolCard/PermissionFooter.tsx +++ b/web/src/components/ToolCard/PermissionFooter.tsx @@ -24,9 +24,11 @@ function isToolAllowedForSession(toolName: string, toolInput: unknown, allowedTo function isCodexSession(metadata: SessionMetadataSummary | null, toolName: string): boolean { return isCodexFamilyFlavor(metadata?.flavor) + || metadata?.flavor === 'cursor' || toolName.startsWith('Codex') || toolName.startsWith('Gemini') || toolName.startsWith('OpenCode') + || toolName.startsWith('Cursor') } function isClaudeSession(metadata: SessionMetadataSummary | null): boolean { @@ -141,6 +143,7 @@ export function PermissionFooter(props: { || toolName === 'NotebookEdit' || toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode' + || toolName === 'CursorCreatePlan' const canAllowForSession = !codex && isPending && !hideAllowForSession const canAllowAllEdits = claude && isPending && isEditTool diff --git a/web/src/components/ToolCard/askUserQuestion.ts b/web/src/components/ToolCard/askUserQuestion.ts index 95940637..0844cb84 100644 --- a/web/src/components/ToolCard/askUserQuestion.ts +++ b/web/src/components/ToolCard/askUserQuestion.ts @@ -1,11 +1,15 @@ import { isObject } from '@hapi/protocol' export type AskUserQuestionOption = { + /** Stable option id from agent protocol (Cursor ACP); falls back to label in UI submit. */ + id?: string label: string description: string | null } export type AskUserQuestionQuestion = { + /** Stable question id from agent protocol (Cursor ACP); falls back to index in UI submit. */ + id?: string header: string | null question: string options: AskUserQuestionOption[] @@ -18,7 +22,9 @@ export type AskUserQuestionQuestionInfo = { } export function isAskUserQuestionToolName(toolName: string): boolean { - return toolName === 'AskUserQuestion' || toolName === 'ask_user_question' + return toolName === 'AskUserQuestion' + || toolName === 'ask_user_question' + || toolName === 'CursorAskQuestion' } export function parseAskUserQuestionInput(input: unknown): { questions: AskUserQuestionQuestion[] } { diff --git a/web/src/components/ToolCard/cursorAskQuestion.test.ts b/web/src/components/ToolCard/cursorAskQuestion.test.ts new file mode 100644 index 00000000..77a27364 --- /dev/null +++ b/web/src/components/ToolCard/cursorAskQuestion.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { isCursorAskQuestionToolName, parseCursorAskQuestionInput } from '@/components/ToolCard/cursorAskQuestion'; +import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion'; + +describe('cursorAskQuestion', () => { + it('is recognized as an ask-question tool', () => { + expect(isCursorAskQuestionToolName('CursorAskQuestion')).toBe(true); + expect(isAskUserQuestionToolName('CursorAskQuestion')).toBe(true); + }); + + it('parses Cursor ask_question payload shape', () => { + const parsed = parseCursorAskQuestionInput({ + toolCallId: 'q-1', + title: 'Choose approach', + questions: [ + { + id: 'approach', + prompt: 'Which approach?', + allowMultiple: false, + options: [ + { id: 'a', label: 'Option A' }, + { id: 'b', label: 'Option B' } + ] + } + ] + }); + + expect(parsed.questions).toHaveLength(1); + expect(parsed.questions[0]).toMatchObject({ + id: 'approach', + header: 'Choose approach', + question: 'Which approach?', + multiSelect: false, + options: [ + { id: 'a', label: 'Option A', description: null }, + { id: 'b', label: 'Option B', description: null } + ] + }); + }); + + it('preserves stable ids used by AskUserQuestionFooter ACP submit', () => { + const parsed = parseCursorAskQuestionInput({ + questions: [ + { + id: 'approach', + prompt: 'Which approach?', + options: [{ id: 'a', label: 'Option A' }] + } + ] + }); + const q = parsed.questions[0]; + expect(q?.id).toBe('approach'); + expect(q?.options[0]?.id).toBe('a'); + // Mirrors AskUserQuestionFooter: question.id + option.id, not index/label. + expect({ [q!.id!]: [q!.options[0]!.id!] }).toEqual({ approach: ['a'] }); + }); + + it('returns empty questions for invalid input', () => { + expect(parseCursorAskQuestionInput(null).questions).toEqual([]); + expect(parseCursorAskQuestionInput({}).questions).toEqual([]); + }); +}); diff --git a/web/src/components/ToolCard/cursorAskQuestion.ts b/web/src/components/ToolCard/cursorAskQuestion.ts new file mode 100644 index 00000000..f15a5dee --- /dev/null +++ b/web/src/components/ToolCard/cursorAskQuestion.ts @@ -0,0 +1,63 @@ +import { isObject } from '@hapi/protocol' +import type { AskUserQuestionQuestion } from '@/components/ToolCard/askUserQuestion' + +export function isCursorAskQuestionToolName(toolName: string): boolean { + return toolName === 'CursorAskQuestion' +} + +export function parseCursorAskQuestionInput(input: unknown): { questions: AskUserQuestionQuestion[] } { + if (!isObject(input)) return { questions: [] } + + const rawQuestions = input.questions + if (!Array.isArray(rawQuestions)) return { questions: [] } + + const requestTitle = typeof input.title === 'string' ? input.title.trim() : '' + + const questions: AskUserQuestionQuestion[] = [] + for (const raw of rawQuestions) { + if (!isObject(raw)) continue + + const question = typeof raw.prompt === 'string' + ? raw.prompt.trim() + : typeof raw.question === 'string' + ? raw.question.trim() + : '' + const header = typeof raw.title === 'string' + ? raw.title.trim() + : typeof raw.header === 'string' + ? raw.header.trim() + : '' + const multiSelect = raw.allowMultiple === true || raw.multiSelect === true + const questionId = typeof raw.id === 'string' && raw.id.trim() + ? raw.id.trim() + : String(questions.length) + + const rawOptions = Array.isArray(raw.options) ? raw.options : [] + const options: AskUserQuestionQuestion['options'] = [] + for (const opt of rawOptions) { + if (!isObject(opt)) continue + const label = typeof opt.label === 'string' + ? opt.label.trim() + : typeof opt.id === 'string' + ? opt.id.trim() + : '' + if (!label) continue + const optionId = typeof opt.id === 'string' && opt.id.trim() + ? opt.id.trim() + : label + options.push({ id: optionId, label, description: null }) + } + + if (!question && options.length === 0) continue + + questions.push({ + id: questionId, + header: header.length > 0 ? header : (requestTitle.length > 0 ? requestTitle : null), + question, + options, + multiSelect + }) + } + + return { questions } +} diff --git a/web/src/components/ToolCard/views/AskUserQuestionView.tsx b/web/src/components/ToolCard/views/AskUserQuestionView.tsx index e0e9219a..6005a75b 100644 --- a/web/src/components/ToolCard/views/AskUserQuestionView.tsx +++ b/web/src/components/ToolCard/views/AskUserQuestionView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import type { ToolViewProps } from '@/components/ToolCard/views/_all' import { parseAskUserQuestionInput } from '@/components/ToolCard/askUserQuestion' +import { isCursorAskQuestionToolName, parseCursorAskQuestionInput } from '@/components/ToolCard/cursorAskQuestion' import { AskUserQuestionOptionBody, askUserQuestionQuoteClassName, @@ -115,7 +116,9 @@ function renderFreeformAnswers( } export function AskUserQuestionView(props: ToolViewProps) { - const parsed = parseAskUserQuestionInput(props.block.tool.input) + const parsed = isCursorAskQuestionToolName(props.block.tool.name) + ? parseCursorAskQuestionInput(props.block.tool.input) + : parseAskUserQuestionInput(props.block.tool.input) const questions = parsed.questions const rawAnswers = props.block.tool.permission?.answers ?? undefined const answers = normalizeAnswers(rawAnswers) diff --git a/web/src/components/ToolCard/views/CursorCreatePlanView.tsx b/web/src/components/ToolCard/views/CursorCreatePlanView.tsx new file mode 100644 index 00000000..610a69f2 --- /dev/null +++ b/web/src/components/ToolCard/views/CursorCreatePlanView.tsx @@ -0,0 +1,20 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { isObject } from '@hapi/protocol' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' + +export function CursorCreatePlanView(props: ToolViewProps) { + const input = props.block.tool.input + if (!isObject(input)) return null + const plan = typeof input.plan === 'string' ? input.plan : null + const overview = typeof input.overview === 'string' ? input.overview : null + if (!plan && !overview) return null + + return ( +
+ {overview ? ( +

{overview}

+ ) : null} + {plan ? : null} +
+ ) +} diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index 8730728f..e3c04d01 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -7,6 +7,7 @@ import { EditView } from '@/components/ToolCard/views/EditView' import { AskUserQuestionView } from '@/components/ToolCard/views/AskUserQuestionView' import { RequestUserInputView } from '@/components/ToolCard/views/RequestUserInputView' import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView' +import { CursorCreatePlanView } from '@/components/ToolCard/views/CursorCreatePlanView' import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView' import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView' import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView' @@ -88,6 +89,8 @@ export const toolViewRegistry: Record = { close_agent: CodexAgentView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, + CursorAskQuestion: AskUserQuestionView, + CursorCreatePlan: CursorCreatePlanView, ask_user_question: AskUserQuestionView, exit_plan_mode: ExitPlanModeView, request_user_input: RequestUserInputView @@ -108,6 +111,8 @@ export const toolFullViewRegistry: Record = { close_agent: CodexAgentView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, + CursorAskQuestion: AskUserQuestionView, + CursorCreatePlan: CursorCreatePlanView, ask_user_question: AskUserQuestionView, exit_plan_mode: ExitPlanModeView, request_user_input: RequestUserInputView diff --git a/web/src/components/ToolCard/views/cursorViews.test.ts b/web/src/components/ToolCard/views/cursorViews.test.ts new file mode 100644 index 00000000..f330c019 --- /dev/null +++ b/web/src/components/ToolCard/views/cursorViews.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all'; + +describe('Cursor ACP tool views', () => { + it('registers CursorAskQuestion and CursorCreatePlan views', () => { + expect(getToolViewComponent('CursorAskQuestion')).toBeDefined(); + expect(getToolFullViewComponent('CursorAskQuestion')).toBeDefined(); + expect(getToolFullViewComponent('CursorCreatePlan')).toBeDefined(); + }); +}); diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index d0814d78..5642ee7f 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -32,6 +32,12 @@ export function useSessionActions( await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) } + const invalidateCursorModels = async () => { + if (!sessionId || agentFlavor !== 'cursor') return + await queryClient.invalidateQueries({ queryKey: queryKeys.sessionCursorModels(sessionId) }) + await queryClient.invalidateQueries({ queryKey: ['machine-cursor-models'] }) + } + const abortMutation = useMutation({ mutationFn: async () => { if (!api || !sessionId) { @@ -98,7 +104,12 @@ export function useSessionActions( } await api.setModel(sessionId, model) }, - onSuccess: () => void invalidateSession(), + onSuccess: () => { + void (async () => { + await invalidateSession() + await invalidateCursorModels() + })() + }, }) const modelReasoningEffortMutation = useMutation({ diff --git a/web/src/hooks/queries/useCursorModels.ts b/web/src/hooks/queries/useCursorModels.ts index c8228d9b..00cbc1b1 100644 --- a/web/src/hooks/queries/useCursorModels.ts +++ b/web/src/hooks/queries/useCursorModels.ts @@ -9,6 +9,7 @@ export function useCursorModels(args: { enabled?: boolean }): { availableModels: CursorModelSummary[] + cliModelSkus: CursorModelSummary[] currentModelId: string | null isLoading: boolean error: string | null @@ -36,6 +37,7 @@ export function useCursorModels(args: { return { availableModels: query.data?.availableModels ?? [], + cliModelSkus: query.data?.cliModelSkus ?? [], currentModelId: query.data?.currentModelId ?? null, isLoading: query.isLoading, error: query.data?.success === false diff --git a/web/src/hooks/queries/useCursorModelsForMachine.ts b/web/src/hooks/queries/useCursorModelsForMachine.ts index 50250803..13186371 100644 --- a/web/src/hooks/queries/useCursorModelsForMachine.ts +++ b/web/src/hooks/queries/useCursorModelsForMachine.ts @@ -9,6 +9,7 @@ export function useCursorModelsForMachine(args: { enabled?: boolean }): { availableModels: CursorModelSummary[] + cliModelSkus: CursorModelSummary[] currentModelId: string | null isLoading: boolean error: string | null @@ -37,6 +38,7 @@ export function useCursorModelsForMachine(args: { return { availableModels: query.data?.availableModels ?? [], + cliModelSkus: query.data?.cliModelSkus ?? [], currentModelId: query.data?.currentModelId ?? null, isLoading: query.isLoading, error: query.data?.success === false diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts index 93125ec3..698afd18 100644 --- a/web/src/lib/codexSlashCommands.test.ts +++ b/web/src/lib/codexSlashCommands.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { getPermissionModesForFlavor } from '@hapi/protocol/modes' import { findCodexCustomPromptExpansion, findUnsupportedCodexBuiltinSlashCommand, @@ -20,11 +21,16 @@ describe('getBuiltinSlashCommands', () => { ])) }) - it('exposes Cursor summarize and clear builtins', () => { + it('exposes Cursor pass-through builtins', () => { expect(getBuiltinSlashCommands('cursor').map((command) => command.name)).toEqual( - expect.arrayContaining(['summarize', 'clear']) + ['compress'] ) }) + + it('includes debug only in Cursor permission modes', () => { + expect(getPermissionModesForFlavor('cursor')).toContain('debug') + expect(getPermissionModesForFlavor('claude')).not.toContain('debug') + }) }) describe('mergeSlashCommands', () => { diff --git a/web/src/lib/cursorCliSkuCatalog.test.ts b/web/src/lib/cursorCliSkuCatalog.test.ts new file mode 100644 index 00000000..6aae4bb7 --- /dev/null +++ b/web/src/lib/cursorCliSkuCatalog.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions' +import { buildCursorPickerState } from '@/lib/cursorPickerState' + +describe('CLI sku variant catalog', () => { + const wires = [ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + ] + const cliSkus = [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }, + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }, + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + ] + + it('adds multiple CLI skus under the same ACP base', () => { + const catalog = appendCliSkusToCatalog(buildCursorModelCatalog(wires), cliSkus) + const gptVariants = catalog.variantsByBase.get('gpt-5.5') ?? [] + expect(gptVariants.length).toBeGreaterThan(3) + expect(gptVariants.some((row) => row.wireId === 'gpt-5.5-high-fast')).toBe(true) + expect(gptVariants.some((row) => row.wireId === 'gpt-5.5[context=272k,reasoning=medium,fast=false]')).toBe(true) + }) + + it('enables dual picker with multi sku variants for gpt-5.5', () => { + const catalog = appendCliSkusToCatalog(buildCursorModelCatalog(wires), cliSkus) + const picker = buildCursorPickerState({ + catalog, + currentWireId: 'gpt-5.5-medium', + defaultValue: 'auto' + }) + expect(picker.mode).toBe('dual') + const variantIds = picker.effortOptions.map((row) => row.value) + expect(variantIds).toContain('gpt-5.5-high-fast') + expect(variantIds).toContain('gpt-5.5-low') + expect(variantIds.length).toBeGreaterThan(2) + }) +}) diff --git a/web/src/lib/cursorModelOptions.test.ts b/web/src/lib/cursorModelOptions.test.ts new file mode 100644 index 00000000..1f943c7b --- /dev/null +++ b/web/src/lib/cursorModelOptions.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + buildCursorEffortPickerOptions, + buildCursorModelCatalog, + buildFlatCursorModelPickerOptions, + cursorCatalogHasMultiVariantBases, + shouldUseCursorDualPickers, + cursorEffortPickerLabel, + cursorModelBaseId, + cursorModelDedupeKey, + cursorModelVariantId, + cursorVariantDisambiguationSuffix, + cursorVariantLabel, + cursorVaryingWireParamKeys, + formatCursorModelPickerLabel, + parseCursorWireParams, + resolveCursorBaseKey, + resolveCursorVariantOptions +} from './cursorModelOptions' + +const acpModels = [ + { modelId: 'default[]', name: 'Auto' }, + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5 Fast' }, + { modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' }, + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'Claude Opus 4.8' }, + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=low,fast=false]', name: 'Claude Opus 4.8' }, +] as const + +describe('raw Cursor ACP model splitting', () => { + it('splits wire ids into raw base and variant parts', () => { + expect(cursorModelBaseId('composer-2.5[fast=true]')).toBe('composer-2.5') + expect(cursorModelDedupeKey('composer-2.5-fast')).toBe('composer-2.5-fast') + expect(cursorModelVariantId('composer-2.5[fast=true]')).toBe('fast=true') + expect(cursorVariantLabel('claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]')).toBe( + 'thinking=true,context=300k,effort=high,fast=false' + ) + }) + + it('parses raw comma-separated wire parameters without changing labels', () => { + expect( + parseCursorWireParams('claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]') + ).toEqual({ + thinking: 'true', + context: '300k', + effort: 'high', + fast: 'false', + }) + expect(cursorVaryingWireParamKeys([ + 'composer-2.5[fast=true]', + 'composer-2.5[fast=false]', + ])).toEqual(['fast']) + }) +}) + +describe('buildCursorModelCatalog', () => { + it('groups exact ACP wire variants by raw base id and sorts model bases', () => { + const catalog = buildCursorModelCatalog([...acpModels]) + expect(catalog.baseOptions.map((o) => o.label)).toEqual([ + 'Default', + 'claude-opus-4-8', + 'composer-2.5', + ]) + expect(resolveCursorVariantOptions('composer-2.5', catalog).map((v) => v.label)).toEqual([ + 'fast=true', + 'fast=false', + ]) + }) + + it('does not merge legacy sku aliases into ACP bases', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }, + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + ]) + expect(catalog.baseOptions.map((o) => o.value)).toEqual([ + null, + 'composer-2.5', + 'composer-2.5-fast', + ]) + }) + + it('resolves current wire to base and variant list', () => { + const catalog = buildCursorModelCatalog([...acpModels], { + currentModel: 'composer-2.5[fast=true]', + }) + expect(resolveCursorBaseKey('composer-2.5[fast=true]', catalog)).toBe('composer-2.5') + expect(resolveCursorVariantOptions('composer-2.5', catalog)).toHaveLength(2) + }) + + it('ignores Cursor-provided display names for picker labels', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'gpt-5.3-codex[reasoning=medium,fast=false]', name: 'Codex 5.3' }, + ], { defaultValue: 'auto' }) + expect(catalog.baseOptions.find((entry) => entry.value === 'gpt-5.3-codex')?.label).toBe('gpt-5.3-codex') + }) +}) + +describe('picker labels and modes', () => { + it('formats flat labels as raw base when a base has one variant', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]', name: 'Claude Opus 4.7' }, + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' }, + ], { defaultValue: 'auto' }) + expect(cursorCatalogHasMultiVariantBases(catalog)).toBe(false) + expect(buildFlatCursorModelPickerOptions(catalog, { defaultValue: 'auto' })).toEqual([ + { value: 'auto', label: 'Default' }, + { + value: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]', + label: 'claude-opus-4-7 · thinking=true,context=300k,effort=xhigh,fast=false' + }, + { value: 'composer-2.5[fast=true]', label: 'composer-2.5 · fast=true' } + ]) + }) + + it('lists a single variant row when a base has one wire', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + ], { defaultValue: 'auto' }) + expect(buildCursorEffortPickerOptions(resolveCursorVariantOptions('gpt-5.5', catalog))).toEqual([ + { + value: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + label: 'context=272k,reasoning=medium,fast=false' + } + ]) + }) + + it('shows raw variant labels when a base has multiple variants', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' }, + ], { defaultValue: 'auto' }) + expect(buildFlatCursorModelPickerOptions(catalog, { defaultValue: 'auto' })).toEqual([ + { value: 'auto', label: 'Default' }, + { value: 'composer-2.5[fast=true]', label: 'fast=true' }, + { value: 'composer-2.5[fast=false]', label: 'fast=false' }, + ]) + expect(buildCursorEffortPickerOptions(resolveCursorVariantOptions('composer-2.5', catalog))).toEqual([ + { value: 'composer-2.5[fast=true]', label: 'fast=true' }, + { value: 'composer-2.5[fast=false]', label: 'fast=false' }, + ]) + }) + + it('enables dual pickers when a base has multiple exact wire ids', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]', name: 'Claude Opus 4.7' }, + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' }, + ], { defaultValue: 'auto' }) + expect(cursorCatalogHasMultiVariantBases(catalog)).toBe(true) + expect(shouldUseCursorDualPickers(catalog, 'composer-2.5[fast=false]')).toBe(true) + }) + + it('uses raw variant suffixes for compatibility helpers', () => { + expect(cursorEffortPickerLabel('claude-opus-4-8[effort=high,fast=false]', [])).toBe('effort=high,fast=false') + expect(cursorVariantDisambiguationSuffix('claude-opus-4-8[effort=high,fast=false]')).toBe('effort=high,fast=false') + expect(formatCursorModelPickerLabel('composer-2.5[fast=true]', 'ignored')).toBe('composer-2.5 · fast=true') + }) +}) diff --git a/web/src/lib/cursorModelOptions.ts b/web/src/lib/cursorModelOptions.ts new file mode 100644 index 00000000..3d71c698 --- /dev/null +++ b/web/src/lib/cursorModelOptions.ts @@ -0,0 +1,336 @@ +import { cursorCliSkuBaseId } from '@hapi/protocol' +import type { CursorModelSummary } from '@/types/api' + +export type CursorModelOption = { value: string | null; label: string } + +export type CursorModelVariantOption = { + wireId: string + label: string + sortKey: string +} + +export type CursorModelCatalog = { + baseOptions: CursorModelOption[] + variantsByBase: Map + wireToBase: Map +} + +/** Base model id before ACP wire suffix, e.g. `composer-2.5[fast=true]` → `composer-2.5`. */ +export function cursorModelBaseId(modelId: string): string { + const trimmed = modelId.trim() + const bracket = trimmed.indexOf('[') + return bracket === -1 ? trimmed : trimmed.slice(0, bracket) +} + +/** Raw ACP variant suffix without brackets, e.g. `x[a=b]` → `a=b`. */ +export function cursorModelVariantId(modelId: string): string { + const trimmed = modelId.trim() + const bracket = trimmed.indexOf('[') + if (bracket === -1) { + return '' + } + const end = trimmed.endsWith(']') ? trimmed.length - 1 : trimmed.length + return trimmed.slice(bracket + 1, end) +} + +/** Key for grouping variants of the same base model. No legacy alias normalization. */ +export function cursorModelDedupeKey(modelId: string): string { + return cursorModelBaseId(modelId) +} + +function isDefaultCursorModelId(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase() + return normalized === 'auto' || normalized === 'default' || normalized === 'default[]' +} + +function normalizeCurrentModel(model?: string | null): string | null { + const trimmed = model?.trim() + if (!trimmed || isDefaultCursorModelId(trimmed)) { + return null + } + return trimmed +} + +export function parseCursorWireParams(modelId: string): Record { + const variant = cursorModelVariantId(modelId) + if (!variant) { + return {} + } + + const params: Record = {} + for (const part of variant.split(',')) { + const segment = part.trim() + if (!segment) continue + const eq = segment.indexOf('=') + if (eq === -1) { + params[segment] = 'true' + continue + } + params[segment.slice(0, eq).trim()] = segment.slice(eq + 1).trim() + } + return params +} + +/** Raw label for a variant row. No fast/effort/reasoning formatting. */ +export function cursorVariantLabel(modelId: string): string { + const variant = cursorModelVariantId(modelId) + if (variant) { + return variant + } + if (modelId.includes('[')) { + return '(default)' + } + return cursorModelBaseId(modelId) +} + +/** Kept for callers/tests; sorting is raw and should not encode semantic effort guesses. */ +export function cursorEffortPickerSortKey(wireId: string): string { + return wireId +} + +/** Param keys that differ across wire ids in the same base model group. */ +export function cursorVaryingWireParamKeys(wireIds: readonly string[]): string[] { + if (wireIds.length <= 1) { + return [] + } + + const parsed = wireIds.map((wireId) => parseCursorWireParams(wireId)) + const keys = new Set() + for (const params of parsed) { + for (const key of Object.keys(params)) { + keys.add(key) + } + } + + const varying: string[] = [] + for (const key of keys) { + const values = new Set(parsed.map((params) => params[key] ?? '')) + if (values.size > 1) { + varying.push(key) + } + } + return varying +} + +/** Raw variant-picker label. */ +export function cursorEffortPickerLabel(wireId: string, _siblingWireIds: readonly string[]): string { + return cursorVariantLabel(wireId) +} + +/** Variant-picker rows for one base model, preserving ACP/catalog order. */ +export function buildCursorEffortPickerOptions( + variants: readonly CursorModelVariantOption[] +): Array<{ value: string; label: string }> { + return variants.map((variant) => ({ + value: variant.wireId, + label: variant.label.trim() || cursorVariantLabel(variant.wireId) + })) +} + +/** Raw suffix for compatibility with older callers/tests. */ +export function cursorVariantDisambiguationSuffix(modelId: string): string { + return cursorVariantLabel(modelId) +} + +/** + * Group ACP wire ids by raw base model. Labels are raw base ids; variant labels are raw suffixes. + * Catalog order follows the input order; only `Default` is prepended. + */ +export function buildCursorModelCatalog( + availableModels: readonly CursorModelSummary[], + options?: { + currentModel?: string | null + /** New-session spawn uses `auto`; active session uses `null` for default. */ + defaultValue?: null | 'auto' + } +): CursorModelCatalog { + const defaultValue = options?.defaultValue === 'auto' ? 'auto' : null + const variantsByBase = new Map() + const wireToBase = new Map() + const baseLabels = new Map() + + const addWire = (rawModelId: string): void => { + const modelId = rawModelId.trim() + if (!modelId || isDefaultCursorModelId(modelId)) { + return + } + + const baseId = cursorModelDedupeKey(modelId) + wireToBase.set(modelId, baseId) + if (!baseLabels.has(baseId)) { + baseLabels.set(baseId, baseId) + } + + const existing = variantsByBase.get(baseId) ?? [] + if (!existing.some((entry) => entry.wireId === modelId)) { + existing.push({ + wireId: modelId, + label: cursorVariantLabel(modelId), + sortKey: modelId + }) + variantsByBase.set(baseId, existing) + } + } + + for (const model of availableModels) { + addWire(model.modelId) + } + + const normalizedCurrent = normalizeCurrentModel(options?.currentModel) + if (normalizedCurrent && isCursorAcpWireModelId(normalizedCurrent) && !wireToBase.has(normalizedCurrent)) { + addWire(normalizedCurrent) + } + + const sortedBaseEntries = [...baseLabels.entries()] + .sort((a, b) => a[1].localeCompare(b[1], undefined, { sensitivity: 'base' })) + + const baseOptions: CursorModelOption[] = [ + { value: defaultValue, label: 'Default' }, + ...sortedBaseEntries.map(([baseId, label]) => ({ value: baseId, label })) + ] + + return { baseOptions, variantsByBase, wireToBase } +} + +/** Add CLI `agent --list-models` skus as variant rows under existing ACP wire bases. */ +export function appendCliSkusToCatalog( + catalog: CursorModelCatalog, + cliSkus: readonly CursorModelSummary[] +): CursorModelCatalog { + if (cliSkus.length === 0) { + return catalog + } + + for (const sku of cliSkus) { + const modelId = sku.modelId.trim() + if (!modelId || isDefaultCursorModelId(modelId) || isCursorAcpWireModelId(modelId)) { + continue + } + + const baseId = cursorCliSkuBaseId(modelId) + const existing = catalog.variantsByBase.get(baseId) + if (!existing || existing.length === 0) { + continue + } + + if (existing.some((entry) => entry.wireId === modelId)) { + continue + } + + existing.push({ + wireId: modelId, + label: sku.name?.trim() && sku.name !== modelId ? sku.name.trim() : modelId, + sortKey: modelId + }) + catalog.wireToBase.set(modelId, baseId) + } + + return catalog +} + +export function resolveCursorBaseKey( + wireId: string | null | undefined, + catalog: CursorModelCatalog +): string | null { + if (!wireId) { + return null + } + return catalog.wireToBase.get(wireId) ?? cursorModelDedupeKey(wireId) +} + +/** + * Variant rows for the picker. When CLI skus exist for a base, hide the raw ACP wire row + * (e.g. `context=272k,reasoning=medium,fast=false`) and show user-facing sku names only. + */ +export function resolveCursorVariantOptions( + baseKey: string | null, + catalog: CursorModelCatalog +): CursorModelVariantOption[] { + if (!baseKey) { + return [] + } + const variants = catalog.variantsByBase.get(baseKey) ?? [] + const cliSkus = variants.filter((entry) => !isCursorAcpWireModelId(entry.wireId)) + if (cliSkus.length > 0) { + return cliSkus + } + return variants +} + +/** True when at least one base model has multiple ACP wire variants. */ +export function cursorCatalogHasMultiVariantBases(catalog: CursorModelCatalog): boolean { + for (const variants of catalog.variantsByBase.values()) { + if (variants.length > 1) { + return true + } + } + return false +} + +export function cursorBaseHasMultipleVariants( + catalog: CursorModelCatalog, + baseKey: string | null | undefined +): boolean { + if (!baseKey || baseKey === 'auto') { + return false + } + return (catalog.variantsByBase.get(baseKey)?.length ?? 0) > 1 +} + +/** ACP wire ids use bracket params; CLI probe slugs (e.g. gpt-5.5-high-fast) are not picker rows. */ +export function isCursorAcpWireModelId(modelId: string): boolean { + const trimmed = modelId.trim() + return trimmed === 'default[]' || trimmed.includes('[') +} + +/** Dual pickers only when at least one base has multiple ACP wire ids. */ +export function shouldUseCursorDualPickers( + catalog: CursorModelCatalog, + _currentWireId?: string | null +): boolean { + return cursorCatalogHasMultiVariantBases(catalog) +} + +/** Flat picker fallback: one row per exact wire id, preserving catalog order. */ +export function buildFlatCursorModelPickerOptions( + catalog: CursorModelCatalog, + options?: { defaultValue?: null | 'auto' } +): Array<{ value: string; label: string }> { + const defaultValue = options?.defaultValue === 'auto' ? 'auto' : null + const rows: Array<{ value: string; label: string }> = [] + + for (const [baseId, variants] of catalog.variantsByBase) { + const baseLabel = catalog.baseOptions.find((entry) => entry.value === baseId)?.label ?? baseId + for (const variant of variants) { + rows.push({ + value: variant.wireId, + label: variants.length === 1 + ? formatCursorModelPickerLabel(variant.wireId) + : cursorVariantLabel(variant.wireId) + }) + } + } + + return [ + { value: defaultValue ?? 'auto', label: 'Default' }, + ...rows + ] +} + +/** @deprecated Use buildCursorModelCatalog for Cursor sessions. */ +export function buildCursorModelOptions( + availableModels: readonly CursorModelSummary[], + options?: { + currentModel?: string | null + defaultValue?: null | 'auto' + } +): CursorModelOption[] { + return buildFlatCursorModelPickerOptions(buildCursorModelCatalog(availableModels, options), options) +} + +/** Raw display label from a wire id. */ +export function formatCursorModelPickerLabel(modelId: string, _name?: string | null): string { + const base = cursorModelDedupeKey(modelId) + const variant = cursorModelVariantId(modelId) + return variant ? `${base} · ${variant}` : base +} diff --git a/web/src/lib/cursorPickerFlatCatalog.test.ts b/web/src/lib/cursorPickerFlatCatalog.test.ts new file mode 100644 index 00000000..c5bcbf8c --- /dev/null +++ b/web/src/lib/cursorPickerFlatCatalog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { buildNewSessionCursorPickerState, shouldShowNewSessionCursorVariantPicker } from '@/components/NewSession/newSessionCursorModels' +import { buildCursorPickerState } from '@/lib/cursorPickerState' + +/** Live ACP catalog shape: one wire id per base family (28 rows). */ +const LIVE_ACP_SAMPLE = [ + 'default[]', + 'composer-2.5[fast=true]', + 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', + 'gpt-5.3-codex[reasoning=medium,fast=false]', +] as const + +describe('flat catalog (one wire per base)', () => { + const models = LIVE_ACP_SAMPLE.map((modelId) => ({ modelId })) + + it('New Session uses flat picker without a useless variant dropdown', () => { + const picker = buildNewSessionCursorPickerState(models, 'auto') + expect(picker.mode).toBe('flat') + expect(shouldShowNewSessionCursorVariantPicker(picker)).toBe(false) + expect(picker.modelOptions.length).toBe(5) + expect(picker.modelOptions.some((row) => row.value === 'gpt-5.5[context=272k,reasoning=medium,fast=false]')).toBe(true) + }) + + it('Session chat flat picker lists exact wire ids', () => { + const picker = buildCursorPickerState({ + catalog: buildNewSessionCursorPickerState(models, 'auto').catalog, + currentWireId: 'composer-2.5[fast=true]', + defaultValue: null + }) + expect(picker.mode).toBe('flat') + expect(picker.showEffortPicker).toBe(false) + expect(picker.modelOptions.find((row) => row.value === 'composer-2.5[fast=true]')).toBeDefined() + }) +}) + +describe('dual catalog (multiple wires per base)', () => { + const models = [ + { modelId: 'composer-2.5[fast=true]' }, + { modelId: 'composer-2.5[fast=false]' }, + ] + + it('shows variant picker only when a base has 2+ wire ids', () => { + const picker = buildNewSessionCursorPickerState(models, 'composer-2.5[fast=true]') + expect(picker.mode).toBe('dual') + expect(shouldShowNewSessionCursorVariantPicker(picker)).toBe(true) + expect(picker.effortOptions).toHaveLength(2) + }) +}) diff --git a/web/src/lib/cursorPickerState.test.ts b/web/src/lib/cursorPickerState.test.ts new file mode 100644 index 00000000..25304827 --- /dev/null +++ b/web/src/lib/cursorPickerState.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + buildCursorCatalogFromSources, + buildCursorPickerState, + mergeCursorModelSummaries, + resolveWireIdForBaseChange +} from '@/lib/cursorPickerState' + +describe('mergeCursorModelSummaries', () => { + it('keeps session wire rows first and ignores duplicate machine rows', () => { + const merged = mergeCursorModelSummaries( + [{ modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' }], + [ + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5 Fast' }, + { modelId: 'composer-2.5[fast=false]', name: 'raw-id' } + ] + ) + expect(merged.map((entry) => entry.modelId)).toEqual([ + 'composer-2.5[fast=false]', + 'composer-2.5[fast=true]', + ]) + const slow = merged.find((entry) => entry.modelId === 'composer-2.5[fast=false]') + expect(slow?.name).toBe('Composer 2.5') + }) + + it('injects current wire when missing from both lists', () => { + const merged = mergeCursorModelSummaries([], [], 'claude-opus-4-8[effort=high,fast=false]') + expect(merged).toEqual([ + { modelId: 'claude-opus-4-8[effort=high,fast=false]' } + ]) + }) + + it('drops CLI probe slugs without bracket wire params', () => { + const merged = mergeCursorModelSummaries( + [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }], + [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'composer-2.5', name: 'Composer 2.5' } + ] + ) + expect(merged.map((entry) => entry.modelId)).toEqual([ + 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + ]) + }) +}) + +describe('buildCursorPickerState', () => { + const dualModels = [ + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' } + ] as const + + it('uses dual mode with raw variant labels for current base', () => { + const catalog = buildCursorCatalogFromSources({ + sessionModels: dualModels, + machineModels: [], + currentWireId: 'composer-2.5[fast=false]', + defaultValue: 'auto' + }) + const picker = buildCursorPickerState({ + catalog, + currentWireId: 'composer-2.5[fast=false]', + defaultValue: 'auto' + }) + expect(picker.mode).toBe('dual') + expect(picker.showEffortPicker).toBe(true) + expect(picker.effortOptions.map((row) => row.label)).toEqual(['fast=true', 'fast=false']) + }) + + it('uses flat mode when each base has only one ACP wire', () => { + const catalog = buildCursorCatalogFromSources({ + sessionModels: [ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' } + ], + defaultValue: null + }) + const picker = buildCursorPickerState({ + catalog, + currentWireId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + defaultValue: null + }) + expect(picker.mode).toBe('flat') + expect(picker.showEffortPicker).toBe(false) + expect(picker.effortOptions).toEqual([]) + expect(picker.modelOptions).toHaveLength(3) + expect(picker.modelOptions.map((row) => row.value).sort()).toEqual([ + 'auto', + 'composer-2.5[fast=true]', + 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + ].sort()) + }) + + it('shows variant picker for current base when only that base has variants', () => { + const catalog = buildCursorCatalogFromSources({ + sessionModels: [ + { modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]', name: 'Claude Opus 4.7' }, + ...dualModels + ], + defaultValue: null + }) + const picker = buildCursorPickerState({ + catalog, + currentWireId: 'composer-2.5[fast=true]', + defaultValue: null + }) + expect(picker.showEffortPicker).toBe(true) + expect(picker.baseKey).toBe('composer-2.5') + }) +}) + +describe('resolveWireIdForBaseChange', () => { + it('does not guess when switching to a base with multiple variants', () => { + const catalog = buildCursorCatalogFromSources({ + sessionModels: [ + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'Opus 4.8' }, + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=low,fast=false]', name: 'Opus 4.8' }, + { modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=high,fast=false]', name: 'Opus 4.7' }, + { modelId: 'claude-opus-4-7[thinking=true,context=300k,effort=low,fast=false]', name: 'Opus 4.7' } + ] + }) + expect(resolveWireIdForBaseChange( + 'claude-opus-4-7', + catalog, + 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]' + )).toBeNull() + }) + + it('applies the only exact wire when a base has one variant', () => { + const catalog = buildCursorCatalogFromSources({ + sessionModels: [ + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' }, + ] + }) + expect(resolveWireIdForBaseChange('composer-2.5', catalog)).toBe('composer-2.5[fast=true]') + expect(resolveWireIdForBaseChange('auto', catalog)).toBe('auto') + }) +}) diff --git a/web/src/lib/cursorPickerState.ts b/web/src/lib/cursorPickerState.ts new file mode 100644 index 00000000..4416fee1 --- /dev/null +++ b/web/src/lib/cursorPickerState.ts @@ -0,0 +1,178 @@ +import type { CursorModelSummary } from '@/types/api' +import { + appendCliSkusToCatalog, + buildCursorEffortPickerOptions, + buildCursorModelCatalog, + buildFlatCursorModelPickerOptions, + cursorModelDedupeKey, + isCursorAcpWireModelId, + resolveCursorBaseKey, + resolveCursorVariantOptions, + shouldUseCursorDualPickers, + type CursorModelCatalog, + type CursorModelOption +} from '@/lib/cursorModelOptions' + +export type CursorPickerMode = 'dual' | 'flat' + +export type CursorPickerOption = { value: string; label: string } + +export type CursorPickerState = { + catalog: CursorModelCatalog + mode: CursorPickerMode + wireId: string | null + baseKey: string | null + modelOptions: CursorPickerOption[] + effortOptions: CursorPickerOption[] + showEffortPicker: boolean +} + +/** Only ACP wire ids (and default[]); never CLI probe slugs without bracket params. */ +export function pickCursorModelsForPicker( + availableModels: readonly CursorModelSummary[] +): CursorModelSummary[] { + return availableModels.filter((model) => isCursorAcpWireModelId(model.modelId)) +} + +/** + * Merge session (ACP) and machine catalogs. Session rows win on duplicate modelId; + * machine rows fill display names and extra SKUs. + */ +export function mergeCursorModelSummaries( + primary: readonly CursorModelSummary[], + secondary: readonly CursorModelSummary[], + currentWireId?: string | null +): CursorModelSummary[] { + const merged = new Map() + + const add = (model: CursorModelSummary) => { + const modelId = model.modelId.trim() + if (!modelId || !isCursorAcpWireModelId(modelId)) { + return + } + if (!merged.has(modelId)) { + merged.set(modelId, { ...model, modelId }) + } + } + + for (const model of primary) { + add(model) + } + for (const model of secondary) { + add(model) + } + + const trimmedCurrent = currentWireId?.trim() + if (trimmedCurrent && isCursorAcpWireModelId(trimmedCurrent) && !merged.has(trimmedCurrent)) { + merged.set(trimmedCurrent, { modelId: trimmedCurrent }) + } + + return [...merged.values()] +} + +export function buildCursorCatalogFromSources(args: { + sessionModels: readonly CursorModelSummary[] + machineModels?: readonly CursorModelSummary[] + cliModelSkus?: readonly CursorModelSummary[] + currentWireId?: string | null + sessionModelFromHub?: string | null + defaultValue?: null | 'auto' +}): CursorModelCatalog { + const wireHint = args.currentWireId + ?? args.sessionModelFromHub + ?? null + const merged = mergeCursorModelSummaries( + args.sessionModels, + args.machineModels ?? [], + wireHint + ) + const injectCurrent = wireHint && isCursorAcpWireModelId(wireHint) ? wireHint : null + const catalog = buildCursorModelCatalog(pickCursorModelsForPicker(merged), { + currentModel: injectCurrent, + defaultValue: args.defaultValue + }) + return appendCliSkusToCatalog(catalog, args.cliModelSkus ?? []) +} + +export function normalizeCursorPickerWireId( + wireId: string | null | undefined, + defaultToken: 'auto' | null = null +): string | null { + const trimmed = wireId?.trim() + if (!trimmed || trimmed === 'auto' || trimmed === 'default' || trimmed === 'default[]') { + return defaultToken + } + return trimmed +} + +export function buildCursorPickerState(args: { + catalog: CursorModelCatalog + currentWireId?: string | null + defaultValue?: null | 'auto' +}): CursorPickerState { + const defaultToken = args.defaultValue === 'auto' ? 'auto' : null + const wireId = normalizeCursorPickerWireId(args.currentWireId, defaultToken) + const baseKey = wireId && wireId !== 'auto' + ? resolveCursorBaseKey(wireId, args.catalog) + : null + + const useDual = shouldUseCursorDualPickers(args.catalog, wireId === 'auto' ? null : wireId) + const variantBaseKey = baseKey && baseKey !== 'auto' ? baseKey : null + const variantsForBase = resolveCursorVariantOptions(variantBaseKey, args.catalog) + const showEffortPicker = Boolean(variantBaseKey && variantsForBase.length > 1) + + const modelOptions: CursorPickerOption[] = useDual + ? args.catalog.baseOptions.map((option) => ({ + value: option.value ?? 'auto', + label: option.label + })) + : buildFlatCursorModelPickerOptions(args.catalog, { + defaultValue: args.defaultValue === 'auto' ? 'auto' : undefined + }).map((option) => ({ + value: option.value ?? 'auto', + label: option.label + })) + + const effortOptions: CursorPickerOption[] = showEffortPicker + ? buildCursorEffortPickerOptions(variantsForBase) + : [] + + return { + catalog: args.catalog, + mode: useDual ? 'dual' : 'flat', + wireId, + baseKey, + modelOptions, + effortOptions, + showEffortPicker + } +} + +/** + * When switching base in dual picker, only apply when the base maps to exactly one wire. + * Multi-variant bases require an explicit variant click; no "closest" matching. + */ +export function resolveWireIdForBaseChange( + baseKey: string, + catalog: CursorModelCatalog, + _currentWireId?: string | null +): string | null { + if (baseKey === 'auto') { + return 'auto' + } + const variants = resolveCursorVariantOptions(baseKey, catalog) + if (variants.length === 1) { + return variants[0].wireId + } + return null +} + +export function resolveCursorBaseFromWire( + wireId: string, + catalog: CursorModelCatalog +): string { + if (wireId === 'auto') { + return 'auto' + } + return resolveCursorBaseKey(wireId, catalog) ?? cursorModelDedupeKey(wireId) +} diff --git a/web/src/lib/cursorPickerUiSim.test.ts b/web/src/lib/cursorPickerUiSim.test.ts new file mode 100644 index 00000000..9b0d7595 --- /dev/null +++ b/web/src/lib/cursorPickerUiSim.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { getModelOptionsForFlavor } from '@/components/AssistantChat/modelOptions' +import { + buildCursorCatalogFromSources, + buildCursorPickerState +} from '@/lib/cursorPickerState' +import { + resolveSessionCursorBaseSelectValue, + resolveSessionCursorModelChange +} from '@/lib/sessionChatCursorModel' + +const liveModels = [ + { modelId: 'default[]', name: 'Auto' }, + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }, + { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'claude-opus-4-8' } +] as const + +describe('cursor picker UI simulation (no browser)', () => { + it('flat model list shows one row per ACP wire with variant suffix in the label', () => { + const wire = 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + const picker = buildCursorPickerState({ + catalog: buildCursorCatalogFromSources({ sessionModels: liveModels, defaultValue: null }), + currentWireId: wire, + defaultValue: null + }) + expect(picker.mode).toBe('flat') + const rendered = getModelOptionsForFlavor('cursor', wire, picker.modelOptions) + expect(rendered).toHaveLength(4) + expect(rendered.some((row) => row.value === wire)).toBe(true) + expect(rendered.find((row) => row.value === wire)?.label).toContain('reasoning=medium') + }) + + it('clicking a base key in dual-style handler still resolves the sole wire for that base', () => { + const picker = buildCursorPickerState({ + catalog: buildCursorCatalogFromSources({ sessionModels: liveModels, defaultValue: null }), + currentWireId: 'composer-2.5[fast=true]', + defaultValue: null + }) + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'composer-2.5[fast=true]', + cursorSelectedBase: 'composer-2.5', + kind: 'base', + value: 'gpt-5.5' + }) + expect(plan).toEqual({ + ok: true, + wireId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + nextSelectedBase: 'gpt-5.5', + shouldApply: true + }) + }) + + it('flat wire selection applies the exact wire id', () => { + const picker = buildCursorPickerState({ + catalog: buildCursorCatalogFromSources({ sessionModels: liveModels, defaultValue: null }), + currentWireId: 'composer-2.5[fast=true]', + defaultValue: null + }) + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'composer-2.5[fast=true]', + cursorSelectedBase: 'auto', + kind: 'flat', + value: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + expect(plan).toMatchObject({ + ok: true, + wireId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + shouldApply: true + }) + }) + + it('highlights session base in dual mode while local base state is still auto', () => { + const catalog = buildCursorCatalogFromSources({ + sessionModels: [ + ...liveModels, + { modelId: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ], + defaultValue: null + }) + const picker = buildCursorPickerState({ + catalog, + currentWireId: 'composer-2.5[fast=true]', + defaultValue: null + }) + expect(picker.mode).toBe('dual') + expect(resolveSessionCursorBaseSelectValue(picker, 'auto')).toBe('composer-2.5') + }) +}) diff --git a/web/src/lib/cursorVariantPicker.test.ts b/web/src/lib/cursorVariantPicker.test.ts new file mode 100644 index 00000000..d9065167 --- /dev/null +++ b/web/src/lib/cursorVariantPicker.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { appendCliSkusToCatalog, buildCursorEffortPickerOptions, buildCursorModelCatalog, resolveCursorVariantOptions } from '@/lib/cursorModelOptions' + +describe('resolveCursorVariantOptions with CLI skus', () => { + it('omits raw ACP wire row when CLI skus exist for the same base', () => { + const catalog = appendCliSkusToCatalog( + buildCursorModelCatalog([ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' } + ]), + [ + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }, + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' } + ] + ) + const variants = resolveCursorVariantOptions('gpt-5.5', catalog) + const options = buildCursorEffortPickerOptions(variants) + expect(options.map((row) => row.value)).toEqual(['gpt-5.5-medium', 'gpt-5.5-high-fast']) + expect(options[0]?.label).toBe('GPT-5.5 1M') + expect(options.some((row) => row.label.includes('context=272k'))).toBe(false) + }) + + it('keeps ACP wire rows when no CLI skus are attached', () => { + const catalog = buildCursorModelCatalog([ + { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'composer-2.5' } + ]) + expect(resolveCursorVariantOptions('composer-2.5', catalog)).toHaveLength(2) + }) +}) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 44391f12..44c585b9 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -197,7 +197,9 @@ export default { 'newSession.model': 'Model', 'newSession.effort': 'Effort', 'newSession.model.optional': 'optional', - 'newSession.model.loadFailed': 'Failed to load Codex models', + 'newSession.model.loadFailed': 'Failed to load models', + 'newSession.model.selectVariant': 'Select variant', + 'newSession.model.cursorUnavailable': 'No Cursor models yet. Start a Cursor session once, or use Default.', 'newSession.opencodeModel.loading': 'Discovering OpenCode models…', 'newSession.opencodeModel.loadFailed': 'Failed to load OpenCode models', 'newSession.opencodeModel.retry': 'Retry', @@ -602,6 +604,7 @@ export default { 'misc.model': 'Model', 'misc.reasoningEffort': 'Reasoning Effort', 'misc.effort': 'Effort', + 'misc.variant': 'Variant', 'misc.loading': 'Loading…', 'misc.loadOlder': 'Load older', 'misc.newMessage': '{n} new message{s}', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 3e1e2a1b..2013fcea 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -199,7 +199,9 @@ export default { 'newSession.model': '模型', 'newSession.effort': '思考强度', 'newSession.model.optional': '可选', - 'newSession.model.loadFailed': '加载 Codex 模型失败', + 'newSession.model.loadFailed': '加载模型失败', + 'newSession.model.selectVariant': '选择变体', + 'newSession.model.cursorUnavailable': '暂无 Cursor 模型列表。请先运行一次 Cursor 会话,或使用默认模型。', 'newSession.opencodeModel.loading': '正在发现 OpenCode 模型…', 'newSession.opencodeModel.loadFailed': '加载 OpenCode 模型失败', 'newSession.opencodeModel.retry': '重试', @@ -604,6 +606,7 @@ export default { 'misc.model': '模型', 'misc.reasoningEffort': '推理强度', 'misc.effort': '思考强度', + 'misc.variant': '变体', 'misc.loading': '加载中…', 'misc.loadOlder': '加载更早的', 'misc.newMessage': '{n} 条新消息', diff --git a/web/src/lib/sessionChatCursorCatalog.test.ts b/web/src/lib/sessionChatCursorCatalog.test.ts new file mode 100644 index 00000000..5298ebdb --- /dev/null +++ b/web/src/lib/sessionChatCursorCatalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { appendCliSkusToCatalog, buildCursorModelCatalog } from '@/lib/cursorModelOptions' +import { + buildSessionCursorPickerState, + resolveSessionCursorVariantSelectValue +} from '@/lib/sessionChatCursorModel' + +describe('in-session cursor catalog with CLI skus', () => { + const sessionWires = [ + { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' } + ] + const cliSkus = [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }, + { modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' } + ] + + it('builds dual picker with multiple gpt-5.5 variants inside a session', () => { + const catalog = appendCliSkusToCatalog(buildCursorModelCatalog(sessionWires), cliSkus) + const picker = buildSessionCursorPickerState({ + sessionModels: sessionWires, + machineModels: [], + cliModelSkus: cliSkus, + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + expect(picker.mode).toBe('dual') + expect(picker.effortOptions.length).toBeGreaterThan(2) + expect(catalog.variantsByBase.get('gpt-5.5')?.some((row) => row.wireId === 'gpt-5.5-high-fast')).toBe(true) + }) + + it('highlights the matching CLI sku when session stores the ACP wire id', () => { + const picker = buildSessionCursorPickerState({ + sessionModels: sessionWires, + machineModels: [], + cliModelSkus: cliSkus, + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + const selected = resolveSessionCursorVariantSelectValue( + 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + picker.effortOptions + ) + expect(selected).toBe('gpt-5.5-medium') + expect(picker.effortOptions.every((row) => !row.label.includes('context=272k'))).toBe(true) + expect(picker.effortOptions.some((row) => row.value === 'gpt-5.5-medium')).toBe(true) + }) +}) diff --git a/web/src/lib/sessionChatCursorModel.test.ts b/web/src/lib/sessionChatCursorModel.test.ts new file mode 100644 index 00000000..98b32f5f --- /dev/null +++ b/web/src/lib/sessionChatCursorModel.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { + buildSessionCursorPickerState, + isCursorEffortWireInCatalog, + resolveSessionCursorBaseSelectValue, + resolveSessionCursorModelChange +} from '@/lib/sessionChatCursorModel' + +const sessionModels = [ + { modelId: 'composer-2.5[fast=true]', name: 'Composer 2.5' }, + { modelId: 'composer-2.5[fast=false]', name: 'Composer 2.5' } +] as const + +describe('resolveSessionCursorModelChange', () => { + const picker = buildSessionCursorPickerState({ + sessionModels, + machineModels: [], + sessionModel: 'composer-2.5[fast=true]', + sessionCurrentModelId: 'composer-2.5[fast=true]' + }) + + it('updates selected base without applying when the base has multiple variants', () => { + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'composer-2.5[fast=true]', + cursorSelectedBase: 'composer-2.5', + kind: 'base', + value: 'composer-2.5' + }) + expect(plan).toEqual({ + ok: true, + wireId: null, + nextSelectedBase: 'composer-2.5', + shouldApply: false + }) + }) + + it('applies a base change when the base has exactly one wire variant', () => { + const singlePicker = buildSessionCursorPickerState({ + sessionModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' }], + machineModels: [], + sessionModel: null, + sessionCurrentModelId: null + }) + const plan = resolveSessionCursorModelChange({ + picker: singlePicker, + sessionModel: null, + cursorSelectedBase: 'auto', + kind: 'base', + value: 'gpt-5.5' + }) + expect(plan).toEqual({ + ok: true, + wireId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + nextSelectedBase: 'gpt-5.5', + shouldApply: true + }) + }) + + it('accepts exact variant wire ids without matching stale session baseKey', () => { + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'composer-2.5[fast=true]', + cursorSelectedBase: 'composer-2.5', + kind: 'effort', + value: 'composer-2.5[fast=false]' + }) + expect(plan).toEqual({ + ok: true, + wireId: 'composer-2.5[fast=false]', + nextSelectedBase: 'composer-2.5', + shouldApply: true + }) + }) + + it('rejects variant wire ids missing from catalog', () => { + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'composer-2.5[fast=true]', + cursorSelectedBase: 'composer-2.5', + kind: 'effort', + value: 'claude-opus-4-8[effort=high]' + }) + expect(plan).toEqual({ ok: false, reason: 'effort wire id not in catalog' }) + }) + + it('uses explicit selected base for dual-mode model row highlight', () => { + expect( + resolveSessionCursorBaseSelectValue(picker, 'composer-2.5') + ).toBe('composer-2.5') + expect( + resolveSessionCursorBaseSelectValue(picker, 'auto') + ).toBe('composer-2.5') + }) + + it('highlights Default when session has no model even if local base is auto', () => { + const defaultPicker = buildSessionCursorPickerState({ + sessionModels, + machineModels: [], + sessionModel: null, + sessionCurrentModelId: null + }) + expect(resolveSessionCursorBaseSelectValue(defaultPicker, 'auto')).toBe('auto') + }) +}) + +describe('CLI sku variants in session picker', () => { + it('accepts CLI sku ids attached to an ACP base', () => { + const picker = buildSessionCursorPickerState({ + sessionModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' }], + machineModels: [], + cliModelSkus: [ + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + { modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' } + ], + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' + }) + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', + cursorSelectedBase: 'gpt-5.5', + kind: 'effort', + value: 'gpt-5.5-high-fast' + }) + expect(plan).toMatchObject({ + ok: true, + wireId: 'gpt-5.5-high-fast', + shouldApply: true + }) + }) +}) + +describe('isCursorEffortWireInCatalog', () => { + it('checks wireToBase membership', () => { + const picker = buildSessionCursorPickerState({ + sessionModels, + machineModels: [], + sessionModel: null, + sessionCurrentModelId: null + }) + expect(isCursorEffortWireInCatalog('composer-2.5[fast=false]', picker.catalog)).toBe(true) + expect(isCursorEffortWireInCatalog('unknown[fast=true]', picker.catalog)).toBe(false) + }) +}) diff --git a/web/src/lib/sessionChatCursorModel.ts b/web/src/lib/sessionChatCursorModel.ts new file mode 100644 index 00000000..25eb0f5c --- /dev/null +++ b/web/src/lib/sessionChatCursorModel.ts @@ -0,0 +1,135 @@ +import { findBestCliSkuForAcpWire, matchCliSkuToAcpWireId } from '@hapi/protocol' +import type { CursorModelCatalog } from '@/lib/cursorModelOptions' +import type { CursorModelSummary } from '@/types/api' +import { + buildCursorCatalogFromSources, + buildCursorPickerState, + resolveCursorBaseFromWire, + resolveWireIdForBaseChange, + type CursorPickerState +} from '@/lib/cursorPickerState' + +export function isCursorEffortWireInCatalog( + wireId: string, + catalog: CursorModelCatalog +): boolean { + return catalog.wireToBase.has(wireId) +} + +export function resolveSessionCursorBaseSelectValue( + picker: CursorPickerState, + cursorSelectedBase: string +): string { + if (picker.mode !== 'dual') { + return picker.wireId ?? 'auto' + } + if (cursorSelectedBase !== 'auto') { + return cursorSelectedBase + } + // Catalog already reflects session.model; local base state may still be initial 'auto'. + if (picker.baseKey && picker.baseKey !== 'auto') { + return picker.baseKey + } + return 'auto' +} + +export function resolveSessionCursorModelChange(args: { + picker: CursorPickerState + sessionModel: string | null | undefined + cursorSelectedBase: string + kind: 'base' | 'effort' | 'flat' + value: string | null +}): { ok: true; wireId: string | null; nextSelectedBase: string; shouldApply: boolean } | { ok: false; reason: string } { + const { picker, sessionModel, cursorSelectedBase, kind, value } = args + + if (kind === 'base') { + if (!value || value === 'auto') { + return { ok: true, wireId: null, nextSelectedBase: 'auto', shouldApply: true } + } + if (value.includes('[')) { + const base = resolveCursorBaseFromWire(value, picker.catalog) + return { ok: true, wireId: value, nextSelectedBase: base, shouldApply: true } + } + const wireId = resolveWireIdForBaseChange(value, picker.catalog, sessionModel) + return { + ok: true, + wireId: wireId === 'auto' ? null : wireId, + nextSelectedBase: value, + shouldApply: wireId !== null + } + } + + if (kind === 'effort') { + if (!value || value === 'auto') { + return { ok: true, wireId: null, nextSelectedBase: cursorSelectedBase, shouldApply: true } + } + if (!isCursorEffortWireInCatalog(value, picker.catalog)) { + return { ok: false, reason: 'effort wire id not in catalog' } + } + const base = resolveCursorBaseFromWire(value, picker.catalog) + return { ok: true, wireId: value, nextSelectedBase: base, shouldApply: true } + } + + // flat picker: value is wire id or auto + if (!value || value === 'auto') { + return { ok: true, wireId: null, nextSelectedBase: 'auto', shouldApply: true } + } + if (!picker.catalog.wireToBase.has(value)) { + return { ok: false, reason: 'model wire id not in catalog' } + } + return { + ok: true, + wireId: value, + nextSelectedBase: resolveCursorBaseFromWire(value, picker.catalog), + shouldApply: true + } +} + +export function resolveSessionCursorVariantSelectValue( + sessionModel: string | null | undefined, + effortOptions: readonly { value: string }[] +): string | null { + if (!sessionModel || effortOptions.length === 0) { + return null + } + + const trimmed = sessionModel.trim() + const exact = effortOptions.find((option) => option.value === trimmed) + if (exact) { + return exact.value + } + + if (trimmed.includes('[')) { + const bestSku = findBestCliSkuForAcpWire( + trimmed, + effortOptions.map((option) => option.value) + ); + if (bestSku) { + return bestSku; + } + } + + return null +} + +export function buildSessionCursorPickerState(args: { + sessionModels: readonly CursorModelSummary[] + machineModels: readonly CursorModelSummary[] + cliModelSkus?: readonly CursorModelSummary[] + sessionModel: string | null | undefined + sessionCurrentModelId: string | null +}): CursorPickerState { + const catalog = buildCursorCatalogFromSources({ + sessionModels: args.sessionModels, + machineModels: args.machineModels, + cliModelSkus: args.cliModelSkus, + currentWireId: args.sessionCurrentModelId ?? args.sessionModel, + sessionModelFromHub: args.sessionModel, + defaultValue: null + }) + return buildCursorPickerState({ + catalog, + currentWireId: args.sessionModel ?? args.sessionCurrentModelId, + defaultValue: null + }) +} diff --git a/web/src/lib/sessionResume.test.ts b/web/src/lib/sessionResume.test.ts index af7ad4d4..637ae965 100644 --- a/web/src/lib/sessionResume.test.ts +++ b/web/src/lib/sessionResume.test.ts @@ -67,6 +67,23 @@ describe('sessionResume', () => { }), 5)).toBe(true) }) + it('resolveAgentSessionIdFromMetadata still returns cursorSessionId regardless of protocol', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'cursor', + cursorSessionId: 'acp-thread-1', + cursorSessionProtocol: 'acp', + })).toBe('acp-thread-1') + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'cursor', + cursorSessionId: 'legacy-thread-1', + cursorSessionProtocol: 'stream-json', + })).toBe('legacy-thread-1') + }) + it('inactiveSessionCanResume rejects inactive sessions with messages but no agent id', () => { expect(inactiveSessionCanResume(makeSession(), 3)).toBe(false) })