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) })