From de8cb274bfd72dcc4c034ff778a4b64fa7c32db1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:13:04 -0400 Subject: [PATCH 1/5] fix(desktop): send Cursor Cloud model params and keep Cursor's agent name Problem: cloud launches dropped the chosen reasoning/fast-mode params, Send to Cloud ignored CLI drafts, and ADE kept renaming cloud chats while Cursor already named them. Cause: ADE sent only model.id, several title writers treated cloud chats like local sessions, and Cursor one-shots called Agent.create in the host. Change and boundary: fail-closed verified model.params on both create paths, restore the cloud agents panel, route Cursor one-shots through the SDK worker pool, and make Cursor the name authority. Does not bring cloud agents local, and does not fix the 1.2.69 hook.sock path (#1195) or Codex one-shots blocked by a dead local unityMCP. Verification: desktop typecheck and lint clean; 21 affected test files 1879 passed / 1 skipped; CLI typecheck clean; swiftc -parse on touched iOS files. Co-authored-by: Cursor --- .../sync/syncRemoteCommandService.test.ts | 69 +++ .../services/sync/syncRemoteCommandService.ts | 19 +- apps/ade-cli/src/tuiClient/app.tsx | 20 +- .../tuiClient/cursorCloudChatRename.test.ts | 16 + .../src/tuiClient/cursorCloudChatRename.ts | 17 + .../main/services/adeActions/registry.test.ts | 53 ++ .../src/main/services/adeActions/registry.ts | 22 +- .../services/ai/aiIntegrationService.test.ts | 126 ++++- .../main/services/ai/aiIntegrationService.ts | 41 +- .../services/ai/providerTaskRunner.test.ts | 160 +++--- .../main/services/ai/providerTaskRunner.ts | 104 ++-- .../services/chat/agentChatService.test.ts | 386 +++++++++++++ .../main/services/chat/agentChatService.ts | 304 ++++++++-- .../services/chat/cursorCloudConversation.ts | 31 + .../services/chat/cursorCloudFleetService.ts | 2 - .../chat/cursorModelsDiscovery.test.ts | 232 ++++++++ .../services/chat/cursorModelsDiscovery.ts | 208 ++++++- .../services/chat/cursorSdkPolicy.test.ts | 8 + .../src/main/services/chat/cursorSdkPolicy.ts | 17 + .../main/services/chat/cursorSdkPool.test.ts | 283 ++++++++++ .../src/main/services/chat/cursorSdkPool.ts | 246 +++++++- .../main/services/chat/cursorSdkProtocol.ts | 12 +- .../src/main/services/chat/cursorSdkWorker.ts | 82 ++- .../chat/sessionMetadataService.test.ts | 32 ++ .../services/chat/sessionMetadataService.ts | 34 +- .../main/services/chat/sessionNaming.test.ts | 41 ++ .../src/main/services/chat/sessionNaming.ts | 31 +- .../src/main/services/ipc/registerIpc.ts | 10 +- .../components/app/CursorCloudFleetModal.tsx | 1 - .../components/app/commandPaletteThreads.tsx | 26 +- .../chat/AgentChatComposer.test.tsx | 128 +++++ .../components/chat/AgentChatComposer.tsx | 180 ++++-- .../components/chat/AgentChatPane.test.tsx | 528 +++++++++++++++++- .../components/chat/AgentChatPane.tsx | 294 +++++----- .../chat/CursorCloudInlineLaunch.test.tsx | 64 --- .../chat/CursorCloudInlineLaunch.tsx | 447 --------------- .../chat/CursorCloudSecretsPicker.tsx | 35 +- .../components/chat/DraftMachinePicker.tsx | 12 +- .../chat/draftModelControls.test.ts | 117 ++++ .../components/chat/draftModelControls.ts | 59 ++ .../chat/useCursorCloudDraftState.ts | 42 +- .../useCursorCloudModelEligibility.test.ts | 111 ++++ .../chat/useCursorCloudModelEligibility.ts | 122 ++++ .../components/chat/useLaneGitRemote.test.ts | 253 +++++++++ .../components/chat/useLaneGitRemote.ts | 123 ++++ .../terminals/SessionContextMenu.test.tsx | 11 + .../terminals/SessionContextMenu.tsx | 47 +- .../components/terminals/TerminalsPage.tsx | 9 +- .../terminals/sessionLifecycleActions.ts | 7 +- .../src/renderer/lib/cursorCloudUtils.test.ts | 92 ++- .../src/renderer/lib/cursorCloudUtils.ts | 74 ++- .../desktop/src/renderer/lib/sessions.test.ts | 10 + apps/desktop/src/renderer/lib/sessions.ts | 21 +- .../src/shared/cursorCloudNaming.test.ts | 42 ++ apps/desktop/src/shared/cursorCloudNaming.ts | 53 ++ apps/desktop/src/shared/types/chat.ts | 10 + apps/desktop/src/shared/types/config.ts | 20 +- apps/ios/ADE/Models/RemoteModels.swift | 6 + apps/ios/ADE/Services/SyncService.swift | 7 +- .../CursorCloudAgentDetailScreen.swift | 3 +- .../Views/CursorCloud/CursorCloudModels.swift | 11 + .../Work/WorkChatHeaderAndMessageViews.swift | 8 +- .../ADE/Views/Work/WorkRootComponents.swift | 10 +- .../Views/Work/WorkRootScreen+Actions.swift | 7 + .../WorkSessionDestinationView+Actions.swift | 10 + .../Work/WorkSessionDestinationView.swift | 5 +- .../WorkSessionSettingsSheet+Actions.swift | 13 +- .../Views/Work/WorkSessionSettingsSheet.swift | 19 +- docs/features/ade-code/README.md | 2 +- docs/features/chat/README.md | 8 +- docs/features/chat/agent-routing.md | 24 + docs/features/chat/composer-and-ui.md | 37 ++ docs/perf/work-tab-action-inventory.md | 25 +- 73 files changed, 4674 insertions(+), 1065 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/cursorCloudChatRename.test.ts create mode 100644 apps/ade-cli/src/tuiClient/cursorCloudChatRename.ts delete mode 100644 apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.test.tsx delete mode 100644 apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx create mode 100644 apps/desktop/src/renderer/components/chat/draftModelControls.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/draftModelControls.ts create mode 100644 apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.ts create mode 100644 apps/desktop/src/renderer/components/chat/useLaneGitRemote.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/useLaneGitRemote.ts create mode 100644 apps/desktop/src/shared/cursorCloudNaming.test.ts create mode 100644 apps/desktop/src/shared/cursorCloudNaming.ts diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index ffc9f389dc..52e9d3da54 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1103,6 +1103,35 @@ describe("createSyncRemoteCommandService", () => { expect(restoreCancelledQueue).toHaveBeenCalledTimes(1); }); + it("forwards only a Cursor Cloud reasoning effort the caller actually sent", async () => { + const openCursorCloudChat = vi.fn(async (_args: Record) => ( + { sessionId: "chat-1", session: {} } + )); + const { service } = createService({ agentChatService: { openCursorCloudChat } }); + + await service.execute(makePayload("ai.openCursorCloudChat", { + cloudAgentId: "agt_1", + laneId: "lane-1", + reasoningEffort: "high", + })); + // An omitted control must leave the session default alone: forwarding it as + // null clears the reasoning effort on every mobile, web, and relay call. + await service.execute(makePayload("ai.openCursorCloudChat", { + cloudAgentId: "agt_1", + laneId: "lane-1", + })); + + expect(openCursorCloudChat.mock.calls[0]?.[0]).toEqual({ + cloudAgentId: "agt_1", + laneId: "lane-1", + reasoningEffort: "high", + }); + expect(openCursorCloudChat.mock.calls[1]?.[0]).toEqual({ + cloudAgentId: "agt_1", + laneId: "lane-1", + }); + }); + it("routes all Codex recovery actions through the mobile sync command", async () => { const recoverCodexTurn = vi.fn(async (args) => ({ action: args.action, @@ -1929,6 +1958,46 @@ describe("createSyncRemoteCommandService", () => { })); }); + it("refuses work.updateSessionMeta title writes when Cursor owns the chat name", async () => { + const updateMeta = vi.fn(); + const getSessionSummary = vi.fn().mockResolvedValue({ + sessionId: "cloud-session-1", + cursorCloudAgentId: "cloud-agent-1", + }); + const { service } = createService({ + sessionService: { updateMeta }, + agentChatService: { getSessionSummary }, + }); + + await expect(service.execute(makePayload("work.updateSessionMeta", { + sessionId: "cloud-session-1", + title: "ADE-owned title", + manuallyNamed: true, + }))).rejects.toThrow("agent names are managed by Cursor"); + expect(updateMeta).not.toHaveBeenCalled(); + }); + + it("still pins a Cursor Cloud chat through work.updateSessionMeta", async () => { + const updateMeta = vi.fn(); + const getSessionSummary = vi.fn().mockResolvedValue({ + sessionId: "cloud-session-1", + cursorCloudAgentId: "cloud-agent-1", + }); + const { service } = createService({ + sessionService: { updateMeta }, + agentChatService: { getSessionSummary }, + }); + + await expect(service.execute(makePayload("work.updateSessionMeta", { + sessionId: "cloud-session-1", + pinned: true, + }))).resolves.toEqual({ ok: true }); + expect(updateMeta).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "cloud-session-1", + pinned: true, + })); + }); + it("delegates PR merge contexts to the injected service", async () => { const getMergeContexts = vi.fn().mockResolvedValue({ "pr-1": { prId: "pr-1", mergeable: true } }); const { service } = createService({ diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 40ad961dd5..22b6266593 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -8,6 +8,7 @@ import { } from "../../../../desktop/src/shared/types/chat"; import { runWithAbortSignal } from "./abortSignal"; import { projectAttachmentsDir } from "../../../../desktop/src/shared/chatAttachmentStagingFs"; +import { assertCursorCloudRenameAllowed } from "../../../../desktop/src/shared/cursorCloudNaming"; import type { AttachmentUploadRegistry, AttachmentUploadTicket } from "./attachmentUploadService"; import type { AgentChatCreateArgs, @@ -4206,7 +4207,14 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio args.sessionDeltaService?.getSessionDelta(parseSessionIdArgs(payload, "work.getSessionDelta").sessionId) ?? null); register("work.listSessions", { viewerAllowed: true }, async (payload) => listRemoteWorkSessions(args, parseListSessionsArgs(payload))); register("work.updateSessionMeta", { viewerAllowed: true, queueable: true }, async (payload) => { - args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload)); + const parsed = parseUpdateSessionMetaArgs(payload); + await assertCursorCloudRenameAllowed( + args.agentChatService + ? (sessionId) => args.agentChatService!.getSessionSummary(sessionId) + : null, + parsed, + ); + args.sessionService.updateMeta(parsed); return { ok: true }; }); // --------------------------------------------------------------------- @@ -5536,15 +5544,20 @@ function registerMiscRemoteCommands({ args, register }: RemoteCommandRegistratio return { ok: true }; }); register("ai.openCursorCloudChat", { viewerAllowed: true, queueable: false }, async (payload) => { - const agentName = asTrimmedString(payload.agentName); const sessionId = asTrimmedString(payload.sessionId); const modelId = asTrimmedString(payload.modelId); + const reasoningEffort = asTrimmedString(payload.reasoningEffort); + const fastMode = asOptionalBoolean(payload.fastMode); return requireService(args.agentChatService, "Agent chat service not available.").openCursorCloudChat({ cloudAgentId: requireString(payload.cloudAgentId, "ai.openCursorCloudChat requires cloudAgentId."), laneId: requireString(payload.laneId, "ai.openCursorCloudChat requires laneId."), - ...(agentName ? { agentName } : {}), ...(sessionId ? { sessionId } : {}), ...(modelId ? { modelId } : {}), + // `asTrimmedString` returns null, never undefined, so this has to test + // for null: forwarding it would clear the session's reasoning effort on + // every mobile, web, and relay call that omits the field. + ...(reasoningEffort !== null ? { reasoningEffort } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), }); }); register("ai.watchCursorCloudMirror", { viewerAllowed: true, queueable: false }, async (payload) => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 4c7f1d0fd8..65026d9e9d 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -384,6 +384,7 @@ import { formatSystemDetails, CURSOR_CLOUD_PANE_NOTE, } from "./rightPaneFormatters"; +import { cursorCloudRenameBlockedReason } from "./cursorCloudChatRename"; import { buildFeedbackDraftInput, buildFeedbackEnvironment, @@ -7523,6 +7524,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, focusDetails(); return; } + const blocked = cursorCloudRenameBlockedReason(session); + if (blocked) { + addNotice(blocked, "error"); + return; + } openForm({ kind: "form", title: "Rename chat", @@ -7532,7 +7538,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, { name: "title", label: "Title", required: true, initialValue: session.title ?? "" }, ], }); - }, [activeSession, focusDetails, openForm, sessions]); + }, [activeSession, addNotice, focusDetails, openForm, sessions]); const openFeedbackForm = useCallback(() => { // Seed the multiline feedback form's serializable state (feedbackForm.ts) @@ -11007,6 +11013,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setRightPane({ kind: "details", title: "Rename chat", body: "No active chat is selected." }); return; } + const renameTarget = sessions.find((entry) => entry.sessionId === sessionId) ?? activeSession; + const blocked = cursorCloudRenameBlockedReason(renameTarget); + if (blocked) { + addNotice(blocked, "error"); + return; + } if (!args) { openChatRenameForm(sessionId); return; @@ -12560,6 +12572,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (!targetSessionId) return; const title = requireField("title", "Title"); if (!title) return; + const renameTarget = sessions.find((entry) => entry.sessionId === targetSessionId) ?? activeSession; + const blocked = cursorCloudRenameBlockedReason(renameTarget); + if (blocked) { + addNotice(blocked, "error"); + return; + } await renameChat(conn, targetSessionId, title); setRightOpen(false); setRightPane({ kind: "empty" }); diff --git a/apps/ade-cli/src/tuiClient/cursorCloudChatRename.test.ts b/apps/ade-cli/src/tuiClient/cursorCloudChatRename.test.ts new file mode 100644 index 0000000000..f9a761d8bc --- /dev/null +++ b/apps/ade-cli/src/tuiClient/cursorCloudChatRename.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE } from "../../../desktop/src/shared/cursorCloudNaming"; +import { cursorCloudRenameBlockedReason } from "./cursorCloudChatRename"; + +describe("cursorCloudRenameBlockedReason", () => { + it("returns the shared blocked sentence for a Cursor Cloud chat", () => { + expect(cursorCloudRenameBlockedReason({ cursorCloudAgentId: "cloud-agent-1" })) + .toBe(CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE); + }); + + it("lets a local chat rename proceed", () => { + expect(cursorCloudRenameBlockedReason({ cursorCloudAgentId: null })).toBeNull(); + expect(cursorCloudRenameBlockedReason({ cursorCloudAgentId: " " })).toBeNull(); + expect(cursorCloudRenameBlockedReason(null)).toBeNull(); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/cursorCloudChatRename.ts b/apps/ade-cli/src/tuiClient/cursorCloudChatRename.ts new file mode 100644 index 0000000000..077b1842a3 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/cursorCloudChatRename.ts @@ -0,0 +1,17 @@ +import { + CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE, + cursorOwnsSessionName, +} from "../../../desktop/src/shared/cursorCloudNaming"; + +/** + * ADE Code's copy of the Cursor-owns-name rule. Returns the blocked sentence + * when Rename must not open, otherwise null so the form / hotkey / slash + * command can proceed. + */ +export function cursorCloudRenameBlockedReason( + session: { cursorCloudAgentId?: string | null } | null | undefined, +): string | null { + return cursorOwnsSessionName(session?.cursorCloudAgentId) + ? CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE + : null; +} diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index c6a765b863..b11f97d7c2 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1703,6 +1703,59 @@ describe("runtime session actions", () => { expect(runtime.sessionDeltaService?.getSessionDelta).toHaveBeenCalledWith("session-1"); }); + it("refuses session.updateMeta title writes when Cursor owns the chat name", async () => { + const updateMeta = vi.fn(); + const getSessionSummary = vi.fn().mockResolvedValue({ + sessionId: "cloud-session-1", + cursorCloudAgentId: "cloud-agent-1", + }); + const runtime = { + sessionService: { + get: vi.fn(), + list: vi.fn(), + updateMeta, + }, + agentChatService: { getSessionSummary }, + } as unknown as Parameters[0]; + const sessionService = getAdeActionDomainServices(runtime).session as { + updateMeta: (args: unknown) => Promise; + } & Record; + + await expect(sessionService.updateMeta({ + sessionId: "cloud-session-1", + title: "ADE-owned title", + })).rejects.toThrow("agent names are managed by Cursor"); + expect(updateMeta).not.toHaveBeenCalled(); + }); + + it("still pins a Cursor Cloud chat through session.updateMeta", async () => { + const updateMeta = vi.fn().mockReturnValue({ id: "cloud-session-1", pinned: true }); + const getSessionSummary = vi.fn().mockResolvedValue({ + sessionId: "cloud-session-1", + cursorCloudAgentId: "cloud-agent-1", + }); + const runtime = { + sessionService: { + get: vi.fn(), + list: vi.fn(), + updateMeta, + }, + agentChatService: { getSessionSummary }, + } as unknown as Parameters[0]; + const sessionService = getAdeActionDomainServices(runtime).session as { + updateMeta: (args: unknown) => Promise; + } & Record; + + await expect(sessionService.updateMeta({ + sessionId: "cloud-session-1", + pinned: true, + })).resolves.toEqual({ id: "cloud-session-1", pinned: true }); + expect(updateMeta).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "cloud-session-1", + pinned: true, + })); + }); + // The sync remote-command path honours `dismissPendingInput` for a single // session. This bulk action never has, and used to drop the key silently — so // the same argument meant "dismiss the prompt" over sync and nothing at all diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 2e66f3bbdc..1bc2de69c3 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -92,6 +92,7 @@ import type { ListLanesArgs, SessionSettleOverride, SessionWakeReason, + UpdateSessionMetaArgs, PrAgentPermissionMode, PrAiResolutionContext, PrAiResolutionEventPayload, @@ -141,6 +142,7 @@ import { mapPermissionModeForModelFamily } from "../prs/resolverUtils"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot } from "../shared/utils"; import { parseLinearGraphQLInput } from "../cto/linearGraphQLInput"; import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; +import { assertCursorCloudRenameAllowed } from "../../../shared/cursorCloudNaming"; import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession"; import { parseSettleOverrideArg, @@ -2118,6 +2120,20 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { if (!sessionService) return null; return { ...(sessionService as unknown as OpaqueService), + async updateMeta(args?: unknown) { + // Preload prefers this runtime action over IPC, so the IPC rename guard + // never runs in a connected desktop. Override the spread `updateMeta`. + const record = (args && typeof args === "object" && !Array.isArray(args) + ? args + : {}) as UpdateSessionMetaArgs; + await assertCursorCloudRenameAllowed( + runtime.agentChatService + ? (sessionId) => runtime.agentChatService!.getSessionSummary(sessionId) + : null, + record, + ); + return sessionService.updateMeta(record); + }, async list(args?: ListSessionsArgs | null) { return listSessionsWithChatProjection(runtime, args ?? {}); }, @@ -2964,16 +2980,18 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { openCursorCloudChat: (args?: { cloudAgentId?: string; laneId?: string; - agentName?: string; sessionId?: string; modelId?: string; + reasoningEffort?: string | null; + fastMode?: boolean | null; }) => requireService(runtime.agentChatService, "Agent chat service not available.").openCursorCloudChat({ cloudAgentId: requireNonEmptyString(args?.cloudAgentId, "cloudAgentId"), laneId: requireNonEmptyString(args?.laneId, "laneId"), - ...(args?.agentName ? { agentName: args.agentName } : {}), ...(args?.sessionId ? { sessionId: args.sessionId } : {}), ...(args?.modelId ? { modelId: args.modelId } : {}), + ...(args?.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args?.fastMode !== undefined ? { fastMode: args.fastMode } : {}), }), watchCursorCloudMirror: (args?: { sessionId?: string; watching?: boolean }) => { if (typeof args?.watching !== "boolean") { diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index fee6771a31..f83301b4df 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -11,6 +11,7 @@ const mockState = vi.hoisted(() => ({ markCursorModelCachesStale: vi.fn(), discoverCursorCliModelDescriptors: vi.fn(), discoverCursorSdkModelDescriptors: vi.fn(), + verifyExplicitCursorModelSelection: vi.fn(), probeCursorSdkModelDiscovery: vi.fn(), getApiKeyStoreStatus: vi.fn(), initModelsDevService: vi.fn(), @@ -54,11 +55,17 @@ vi.mock("./localModelDiscovery", () => ({ inspectLocalProvider: (...args: unknown[]) => mockState.inspectLocalProvider(...args), })); -vi.mock("../chat/cursorModelsDiscovery", () => ({ +vi.mock("../chat/cursorModelsDiscovery", async (importOriginal) => ({ + // The failure-message builder stays real: these tests assert the exact + // sentence a rejected cloud launch shows, and that sentence is the contract. + describeCursorSdkModelSelectionFailure: + (await importOriginal()) + .describeCursorSdkModelSelectionFailure, clearCursorCliModelsCache: (...args: unknown[]) => mockState.clearCursorCliModelsCache(...args), markCursorModelCachesStale: (...args: unknown[]) => mockState.markCursorModelCachesStale(...args), discoverCursorCliModelDescriptors: (...args: unknown[]) => mockState.discoverCursorCliModelDescriptors(...args), discoverCursorSdkModelDescriptors: (...args: unknown[]) => mockState.discoverCursorSdkModelDescriptors(...args), + verifyExplicitCursorModelSelection: (...args: unknown[]) => mockState.verifyExplicitCursorModelSelection(...args), probeCursorSdkModelDiscovery: (...args: unknown[]) => mockState.probeCursorSdkModelDiscovery(...args), })); @@ -112,6 +119,9 @@ vi.mock("../opencode/openCodeBinaryManager", () => ({ })); import { createDynamicCursorCliModelDescriptor, getLocalProviderDefaultEndpoint } from "../../../shared/modelRegistry"; +// The real builder, kept real by the module mock above: these tests assert the +// exact sentence a rejected cloud launch shows. +import { describeCursorSdkModelSelectionFailure } from "../chat/cursorModelsDiscovery"; import { createAiIntegrationService, missingFeatureModelMessage } from "./aiIntegrationService"; type ServiceFactoryOptions = { @@ -293,6 +303,9 @@ beforeEach(() => { createDynamicCursorCliModelDescriptor("auto", "Auto"), createDynamicCursorCliModelDescriptor("composer-2", "Composer 2"), ]); + // Null is "the caller chose no control", the state of every test that does + // not set a reasoning effort or a fast mode of its own. + mockState.verifyExplicitCursorModelSelection.mockResolvedValue(null); mockState.probeCursorSdkModelDiscovery.mockResolvedValue({ rows: [ { id: "auto", displayName: "Auto" }, @@ -851,4 +864,115 @@ describe("aiIntegrationService", () => { expect(cloud).not.toHaveProperty("webhook"); expect(send).toHaveBeenCalled(); }); + + it("passes the selected Cursor model params to both cloud create and send", async () => { + const { service } = makeService({ + availability: { claude: false, codex: false, cursor: true, droid: false }, + }); + mockState.detectAllAuth.mockResolvedValue([ + { type: "api-key", provider: "cursor", key: "crsr_test", source: "store" }, + ]); + const send = vi.fn().mockResolvedValue({ id: "run-1", status: "RUNNING" }); + const create = vi.fn().mockResolvedValue({ agentId: "agt_1", send }); + cursorCloudMocks.loadCursorSdk.mockResolvedValue({ Agent: { create } }); + cursorCloudMocks.resolveCursorCloudCreateCloudExtras.mockReturnValue({ + sessionId: "sess-1", + laneId: "lane-1", + projectId: "proj-1", + linearIssueId: null, + envVars: {}, + extras: {}, + }); + const modelParams = [ + { id: "reasoning_effort", value: "xhigh" }, + { id: "speed", value: "standard" }, + ]; + mockState.verifyExplicitCursorModelSelection.mockResolvedValue(modelParams); + + await service.createCursorCloudRun({ + promptText: "Use the exact selected model settings.", + repoUrl: "https://github.com/acme/project.git", + modelId: "grok-4.6", + reasoningEffort: "xhigh", + fastMode: false, + }); + + // One call that owns both the catalog probe and the resolve, so the caller + // cannot depend on an ordering it has no way to state. + expect(mockState.verifyExplicitCursorModelSelection).toHaveBeenCalledWith("crsr_test", { + modelSdkId: "grok-4.6", + reasoningEffort: "xhigh", + fastMode: false, + }); + expect(create.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ + model: { id: "grok-4.6", params: modelParams }, + })); + expect(send.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + model: { id: "grok-4.6", params: modelParams }, + })); + }); + + it("fails closed instead of letting Cursor choose a different cloud variant", async () => { + const { service } = makeService({ + availability: { claude: false, codex: false, cursor: true, droid: false }, + }); + mockState.detectAllAuth.mockResolvedValue([ + { type: "api-key", provider: "cursor", key: "crsr_test", source: "store" }, + ]); + const create = vi.fn(); + cursorCloudMocks.loadCursorSdk.mockResolvedValue({ Agent: { create } }); + cursorCloudMocks.resolveCursorCloudCreateCloudExtras.mockReturnValue({ + sessionId: "sess-1", + laneId: "lane-1", + projectId: "proj-1", + linearIssueId: null, + envVars: {}, + extras: {}, + }); + mockState.verifyExplicitCursorModelSelection.mockRejectedValue(new Error( + describeCursorSdkModelSelectionFailure("grok-4.6", { + status: "partial", + params: [], + unmet: ["reasoning"], + }), + )); + + await expect(service.createCursorCloudRun({ + promptText: "Do not silently change my settings.", + repoUrl: "https://github.com/acme/project.git", + modelId: "grok-4.6", + reasoningEffort: "xhigh", + fastMode: false, + })).rejects.toThrow("could not verify the selected model settings (reasoning effort)"); + expect(create).not.toHaveBeenCalled(); + }); + + it("names the cause when the Cursor catalog itself could not be loaded", async () => { + const { service } = makeService({ + availability: { claude: false, codex: false, cursor: true, droid: false }, + }); + mockState.detectAllAuth.mockResolvedValue([ + { type: "api-key", provider: "cursor", key: "crsr_test", source: "store" }, + ]); + const create = vi.fn(); + cursorCloudMocks.loadCursorSdk.mockResolvedValue({ Agent: { create } }); + mockState.verifyExplicitCursorModelSelection.mockRejectedValue(new Error( + describeCursorSdkModelSelectionFailure("grok-4.6", { + status: "catalog-unavailable", + reason: "request timed out", + }), + )); + + await expect(service.createCursorCloudRun({ + promptText: "Do not blame my selection for a network fault.", + repoUrl: "https://github.com/acme/project.git", + modelId: "grok-4.6", + reasoningEffort: "xhigh", + fastMode: false, + })).rejects.toThrow("Could not load Cursor's model catalog (request timed out). Try again."); + expect(create).not.toHaveBeenCalled(); + // A launch the model check refuses must leave no trace, so the lane's + // remembered secret names are never written. + expect(cursorCloudMocks.resolveCursorCloudCreateCloudExtras).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 5f3099520c..8a51fcbbd0 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -17,6 +17,7 @@ import type { CursorCloudCreateRunResult, CursorCloudListAgentsResult, CursorCloudListRunsResult, + CursorCloudModelParameter, CursorCloudRepository, CursorCloudRunSummary, CursorAgentUsage, @@ -78,6 +79,7 @@ import { import { inspectLocalProvider } from "./localModelDiscovery"; import { discoverCursorSdkModelDescriptors, + verifyExplicitCursorModelSelection, clearCursorCliModelsCache, markCursorModelCachesStale, probeCursorSdkModelDiscovery, @@ -533,14 +535,27 @@ function normalizeCursorCloudAgent(raw: unknown): CursorCloudAgentSummary { }; } -function normalizeCursorCloudRun(raw: unknown, fallbackAgentId?: string | null): CursorCloudRunSummary { +function readCursorCloudModelParams(value: unknown): CursorCloudModelParameter[] | undefined { + if (!Array.isArray(value)) return undefined; + const params = value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const id = readString(entry.id); + const parameterValue = readString(entry.value); + return id && parameterValue ? [{ id, value: parameterValue }] : []; + }); + return params.length ? params : undefined; +} + +function normalizeCursorCloudRun(raw: unknown, fallbackAgentId?: string | null, fallbackModelParams?: CursorCloudModelParameter[]): CursorCloudRunSummary { const record = isRecord(raw) ? raw : {}; const model = isRecord(record.model) ? record.model : {}; + const modelParams = readCursorCloudModelParams(model.params) ?? fallbackModelParams; return { runId: readString(record.id) ?? readString(record.runId) ?? "", agentId: readString(record.agentId) ?? fallbackAgentId ?? "", status: readString(record.status) ?? "unknown", modelId: readString(model.id) ?? readString(record.modelId), + ...(modelParams?.length ? { modelParams } : {}), durationMs: readNumber(record.durationMs), result: record.result, git: record.git, @@ -1276,6 +1291,17 @@ export function createAiIntegrationService(args: { const idempotencyKey = args.idempotencyKey?.trim() || undefined; const apiKey = await requireCursorCloudApiKey(); const { Agent } = await loadCursorSdk(); + const modelId = args.modelId?.trim() || ""; + // Before `resolveCursorCloudCreateCloudExtras`, which persists the lane's + // remembered secret names: a launch this check rejects must leave no trace. + // A launch that chose no controls sends no params and lets Cursor decide. + const modelParams: CursorCloudModelParameter[] | undefined = modelId + ? (await verifyExplicitCursorModelSelection(apiKey, { + modelSdkId: modelId, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, + })) ?? undefined + : undefined; const launch = resolveCursorCloudCreateCloudExtras({ projectRoot, db, @@ -1287,11 +1313,13 @@ export function createAiIntegrationService(args: { secretNames: args.secretNames, rememberSecretNames: args.rememberSecretNames === true, }); + const modelSelection = modelId + ? { id: modelId, ...(modelParams?.length ? { params: modelParams } : {}) } + : undefined; const agent = await Agent.create({ apiKey, ...(idempotencyKey ? { idempotencyKey } : {}), - ...(args.modelId?.trim() ? { model: { id: args.modelId.trim() } } : {}), - ...(args.agentName?.trim() ? { name: args.agentName.trim() } : {}), + ...(modelSelection ? { model: modelSelection } : {}), cloud: { repos: [{ url: repoUrl, @@ -1304,17 +1332,16 @@ export function createAiIntegrationService(args: { ...launch.extras, }, }); - const modelId = args.modelId?.trim() || ""; const run = await agent.send(promptText, { ...(idempotencyKey ? { idempotencyKey } : {}), - ...(modelId ? { model: { id: modelId } } : {}), + ...(modelSelection ? { model: modelSelection } : {}), }); // Cursor names its own agents; use that name when the SDK hands one back so callers can // adopt it (chat titles) instead of ADE's placeholder. const sdkAgentName = readCursorSdkAgentName(agent); const agentSummary: CursorCloudAgentSummary = { agentId: agent.agentId, - name: args.agentName?.trim() || sdkAgentName || "Cursor cloud agent", + name: sdkAgentName || "Cursor cloud agent", summary: promptText.slice(0, 180), status: "running", archived: false, @@ -1323,7 +1350,7 @@ export function createAiIntegrationService(args: { }; return { agent: agentSummary, - run: normalizeCursorCloudRun(run, agent.agentId), + run: normalizeCursorCloudRun(run, agent.agentId, modelParams), }; }; diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts index 0c1ecaf703..856af52c65 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts @@ -14,9 +14,9 @@ const resolveCodexExecutableMock = vi.fn(() => ({ path: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd", source: "path", })); -const cursorAgentCreateMock = vi.fn(); -const cursorAgentResumeMock = vi.fn(); -const cursorAgentSendMock = vi.fn(); +const cursorLocalPromptMock = vi.fn(); +const assertCursorSdkSupportedMock = vi.fn(); +const getApiKeyMock = vi.fn((_provider: string): string | null => null); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); @@ -34,13 +34,18 @@ vi.mock("./codexExecutable", () => ({ resolveCodexExecutable: () => resolveCodexExecutableMock(), })); +// The real store reads the OS credential store, so a developer machine with a +// Cursor key stored would silently skip the missing-key branch. +vi.mock("./apiKeyStore", () => ({ + getApiKey: (provider: string) => getApiKeyMock(provider), +})); + vi.mock("./cursorSdkLoader", () => ({ - loadCursorSdk: async () => ({ - Agent: { - create: (...args: unknown[]) => cursorAgentCreateMock(...args), - resume: (...args: unknown[]) => cursorAgentResumeMock(...args), - }, - }), + assertCursorSdkSupportedOnThisPlatform: (...args: unknown[]) => assertCursorSdkSupportedMock(...args), +})); + +vi.mock("../chat/cursorSdkPool", () => ({ + runCursorSdkLocalPrompt: (...args: unknown[]) => cursorLocalPromptMock(...args), })); import { makeCodexCompatibleJsonSchema, runProviderTask } from "./providerTaskRunner"; @@ -121,9 +126,10 @@ afterEach(() => { spawnMock.mockReset(); resolveClaudeCodeExecutableMock.mockClear(); resolveCodexExecutableMock.mockClear(); - cursorAgentCreateMock.mockReset(); - cursorAgentResumeMock.mockReset(); - cursorAgentSendMock.mockReset(); + cursorLocalPromptMock.mockReset(); + assertCursorSdkSupportedMock.mockReset(); + getApiKeyMock.mockReset(); + getApiKeyMock.mockReturnValue(null); }); describe("runProviderTask", () => { @@ -249,15 +255,8 @@ describe("runProviderTask", () => { } }); - it("does not map Cursor full-auto onto local.force and omits default tools", async () => { - cursorAgentSendMock.mockResolvedValue({ - wait: async () => ({ status: "finished", result: "ok" }), - cancel: async () => {}, - }); - cursorAgentCreateMock.mockResolvedValue({ - agentId: "agent-1", - send: cursorAgentSendMock, - }); + it("routes every Cursor task through the SDK worker pool with no policy of its own", async () => { + cursorLocalPromptMock.mockResolvedValue({ text: "ok", agentId: "agent-1" }); const result = await runProviderTask({ cwd: "/tmp/lane", @@ -274,29 +273,26 @@ describe("runProviderTask", () => { }); expect(result.text).toBe("ok"); - expect(cursorAgentCreateMock).toHaveBeenCalledTimes(1); - const createOptions = cursorAgentCreateMock.mock.calls[0]![0] as Record; - expect(createOptions.mode).toBe("agent"); - expect(createOptions.mode).not.toBe("auto"); - expect(createOptions.tools).toBeUndefined(); - expect(createOptions.local).toMatchObject({ - cwd: "/tmp/lane", - sandboxOptions: { enabled: false }, - autoReview: false, + expect(result.sessionId).toBe("agent-1"); + expect(assertCursorSdkSupportedMock).toHaveBeenCalledTimes(1); + expect(cursorLocalPromptMock).toHaveBeenCalledTimes(1); + const call = cursorLocalPromptMock.mock.calls[0]![0] as Record; + expect(call).toMatchObject({ + projectRoot: "/tmp/lane", + workspacePath: "/tmp/lane", + apiKey: "cursor-test-key", + modelSdkId: "composer-2", + promptText: "Ship the change.", + feature: "unit-test", + timeoutMs: 120_000, }); - expect(createOptions.local.force).toBeUndefined(); - expect(cursorAgentSendMock.mock.calls[0]![1]?.local?.force).toBeUndefined(); + // A one-shot is a tool-less text task and the pool denies every tool call + // it makes, so `permissionMode` decides nothing: the pool owns the policy. + expect(call.policy).toBeUndefined(); }); - it("applies Cursor Auto-review for middle-trust edit tasks", async () => { - cursorAgentSendMock.mockResolvedValue({ - wait: async () => ({ status: "finished", result: "ok" }), - cancel: async () => {}, - }); - cursorAgentCreateMock.mockResolvedValue({ - agentId: "agent-2", - send: cursorAgentSendMock, - }); + it("passes no policy for a middle-trust edit task either", async () => { + cursorLocalPromptMock.mockResolvedValue({ text: "ok", agentId: "agent-2" }); await runProviderTask({ cwd: "/tmp/lane", @@ -312,30 +308,17 @@ describe("runProviderTask", () => { projectConfig: {} as any, }); - const createOptions = cursorAgentCreateMock.mock.calls[0]![0] as Record; - expect(createOptions.mode).toBe("agent"); - expect(createOptions.local.autoReview).toBe(true); - // Middle-trust maps to Cursor "agent", where ADE has no sandbox opinion: an - // explicit false would make the SDK skip the user's ~/.cursor/sandbox.json. - expect(createOptions.local.sandboxOptions).toBeUndefined(); - expect(createOptions.tools).toBeUndefined(); + const call = cursorLocalPromptMock.mock.calls[0]![0] as Record; + expect(call.policy).toBeUndefined(); }); - it("retries Cursor sandbox ConfigurationError without crashing", async () => { - cursorAgentSendMock.mockResolvedValue({ - wait: async () => ({ status: "finished", result: "ok" }), - cancel: async () => {}, + it("runs a read-only Cursor task in plan mode and parses its structured output", async () => { + cursorLocalPromptMock.mockResolvedValue({ + text: '```json\n{"chatTitle":"Fix the namer"}\n```', + agentId: "agent-3", }); - cursorAgentCreateMock - .mockRejectedValueOnce(new Error( - "Local SDK sandboxing was requested, but sandboxing is not supported in this environment. Disable local.sandboxOptions.enabled or remove ~/.cursor/sandbox.json to run without sandboxing.", - )) - .mockResolvedValueOnce({ - agentId: "agent-3", - send: cursorAgentSendMock, - }); - await runProviderTask({ + const result = await runProviderTask({ cwd: "/tmp/lane", descriptor: { family: "cursor", @@ -343,22 +326,55 @@ describe("runProviderTask", () => { providerModelId: "composer-2", } as any, prompt: "What does this file do?", + system: "Be concise.", + jsonSchema: { type: "object" }, feature: "unit-test", permissionMode: "read-only", auth: [{ type: "api-key", provider: "cursor", key: "cursor-test-key" }] as any, projectConfig: {} as any, }); - expect(cursorAgentCreateMock).toHaveBeenCalledTimes(2); - expect(cursorAgentCreateMock.mock.calls[0]![0]).toMatchObject({ - mode: "plan", - tools: ["read", "grep", "glob", "ls"], - local: { sandboxOptions: { enabled: true }, autoReview: false }, - }); - expect(cursorAgentCreateMock.mock.calls[1]![0]).toMatchObject({ - mode: "plan", - tools: ["read", "grep", "glob", "ls"], - local: { sandboxOptions: { enabled: false }, autoReview: false }, - }); + expect(result.structuredOutput).toEqual({ chatTitle: "Fix the namer" }); + const call = cursorLocalPromptMock.mock.calls[0]![0] as Record; + expect(call.policy).toBeUndefined(); + // System prompt and the schema instruction are folded into one prompt: the + // pool helper sends a single message, it has no system-prompt channel. + expect(call.promptText.startsWith("Be concise.\n\nWhat does this file do?")).toBe(true); + expect(call.promptText).toContain("Return only valid JSON matching this schema:"); + }); + + it("surfaces a Cursor worker failure instead of swallowing it", async () => { + cursorLocalPromptMock.mockRejectedValue(new Error("Cursor SDK task failed.")); + + await expect(runProviderTask({ + cwd: "/tmp/lane", + descriptor: { + family: "cursor", + isCliWrapped: false, + providerModelId: "composer-2", + } as any, + prompt: "Name this chat.", + feature: "unit-test", + permissionMode: "read-only", + auth: [{ type: "api-key", provider: "cursor", key: "cursor-test-key" }] as any, + projectConfig: {} as any, + })).rejects.toThrow("Cursor SDK task failed."); + }); + + it("never reaches the worker pool without a Cursor API key", async () => { + await expect(runProviderTask({ + cwd: "/tmp/lane", + descriptor: { + family: "cursor", + isCliWrapped: false, + providerModelId: "composer-2", + } as any, + prompt: "Name this chat.", + feature: "unit-test", + permissionMode: "read-only", + auth: [] as any, + projectConfig: {} as any, + })).rejects.toThrow("Cursor tasks require a Cursor API key."); + expect(cursorLocalPromptMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index 152c70f0fa..a18536b079 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -14,9 +14,8 @@ import { getApiKey } from "./apiKeyStore"; import { parseStructuredOutput } from "./utils"; import { runOpenCodeTextPrompt } from "../opencode/openCodeRuntime"; import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; -import { loadCursorSdk } from "./cursorSdkLoader"; -import { isCursorSdkSandboxUnsupportedError } from "../chat/cursorSdkErrors"; -import { buildCursorSdkLocalRunOptions, resolveCursorSdkPolicy } from "../chat/cursorSdkPolicy"; +import { assertCursorSdkSupportedOnThisPlatform } from "./cursorSdkLoader"; +import { runCursorSdkLocalPrompt } from "../chat/cursorSdkPool"; import { codexReasoningEffortFlags, resolveCodexCliModelForLaunch } from "../../../shared/cliLaunch"; export type ProviderTaskRunnerArgs = { @@ -355,6 +354,22 @@ async function runCodexTask(args: ProviderTaskRunnerArgs): Promise { const prompt = appendStructuredOutputInstruction(args.prompt, args.jsonSchema); const combinedPrompt = args.system?.trim() @@ -367,77 +382,22 @@ async function runCursorTask(args: ProviderTaskRunnerArgs): Promise AI Providers."); } - const { Agent } = await loadCursorSdk(); - const policy = resolveCursorSdkPolicy({ - cursorModeId: - args.permissionMode === "full-auto" - ? "full-auto" - : args.permissionMode === "read-only" - ? "ask" - : "agent", + // The pool forks a worker before it can report an unsupported platform, so + // keep the win32-arm64 blocker on the near side of the fork. + assertCursorSdkSupportedOnThisPlatform(); + const result = await runCursorSdkLocalPrompt({ + projectRoot: args.cwd, + workspacePath: args.cwd, + apiKey, + modelSdkId: args.descriptor.providerModelId, + promptText: combinedPrompt, + feature: args.feature, + timeoutMs: args.timeoutMs ?? 120_000, }); - let sandboxSupported = true; - const buildOptions = () => { - const local = buildCursorSdkLocalRunOptions(policy, { sandboxSupported }); - return { - apiKey, - model: { id: args.descriptor.providerModelId }, - name: `ADE ${args.feature}`, - mode: local.mode, - ...(local.tools ? { tools: local.tools } : {}), - ...(local.disallowedTools ? { disallowedTools: local.disallowedTools } : {}), - local: { - cwd: args.cwd, - // See CursorSdkSandboxDirective: an explicit `false` makes the SDK skip - // the user's ~/.cursor/sandbox.json, so absence is not the same as off. - ...(local.sandboxDirective === "inherit" - ? {} - : { sandboxOptions: { enabled: local.sandboxDirective === "enable" } }), - autoReview: local.autoReview, - }, - }; - }; - const createOrResume = async () => { - const options = buildOptions(); - return args.sessionId?.trim() - ? await Agent.resume(args.sessionId.trim(), options) - : await Agent.create(options); - }; - let agent; - try { - agent = await createOrResume(); - } catch (error) { - if (!sandboxSupported || !isCursorSdkSandboxUnsupportedError(error)) throw error; - sandboxSupported = false; - agent = await createOrResume(); - } - const run = await agent.send(combinedPrompt, { - model: { id: args.descriptor.providerModelId }, - }); - const timeoutMs = args.timeoutMs ?? 120_000; - let timeoutHandle: ReturnType | null = null; - const result = await Promise.race([ - run.wait(), - new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - run.cancel().catch(() => {}); - reject(new Error(`Cursor SDK task timed out after ${timeoutMs}ms.`)); - }, timeoutMs); - }), - ]).finally(() => { - if (timeoutHandle) clearTimeout(timeoutHandle); - }); - if (result.status === "error") { - throw new Error(result.result?.trim() || "Cursor SDK task failed."); - } - if (result.status === "cancelled") { - throw new Error("Cursor SDK task was cancelled."); - } - const text = (result.result ?? "").trim(); return { - text, - structuredOutput: args.jsonSchema ? parseStructuredOutput(text) : null, - sessionId: agent.agentId, + text: result.text, + structuredOutput: args.jsonSchema ? parseStructuredOutput(result.text) : null, + sessionId: result.agentId, }; } diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 74ca6b0a90..3f1cb64678 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -44641,6 +44641,161 @@ it("fails a cleanly ended OpenCode event stream and clears active child sessions expect(sessionService.get(session.id)?.manuallyNamed).toBe(false); }); + it("uses Cursor's remote name and hydrates a reopened cloud chat", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const events: AgentChatEventEnvelope[] = []; + const { service, sessionService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud chat.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + + mockState.cursorSdkCloudResponses.set("cloud.agent.get", { + name: "Plugin platform linear parity", + }); + mockState.cursorSdkCloudResponses.set("cloud.runs.list", { + items: [{ + runId: "cloud-run-1", + status: "finished", + model: { id: "grok-4.6" }, + }], + }); + mockState.cursorSdkCloudResponses.set("cloud.run.conversation", { + turns: [{ + type: "agentConversationTurn", + turn: { + userMessage: { text: "Remote prompt" }, + steps: [{ type: "assistantMessage", message: { text: "Remote answer" } }], + }, + }], + }); + + await service.openCursorCloudChat({ + cloudAgentId: "cloud-agent-1", + laneId: "lane-1", + sessionId: session.id, + }); + + expect(sessionService.get(session.id)?.title).toBe("Plugin platform linear parity"); + expect(events.some((event) => event.event.type === "text" && event.event.text === "Remote answer")).toBe(true); + expect(mockState.cursorSdkCloudRequests.map((request) => request.type)).toEqual( + expect.arrayContaining(["cloud.agent.get", "cloud.runs.list", "cloud.run.conversation"]), + ); + }); + + it("re-reads Cursor's name on the first visible turn while the ADE title is still a default", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const events: AgentChatEventEnvelope[] = []; + const { service, sessionService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud chat.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + const agentGetCalls = () => mockState.cursorSdkCloudRequests.filter((request) => request.type === "cloud.agent.get").length; + // A launched cloud chat keeps ADE's default title until Cursor names it; this test + // session was auto-titled from its prompt because it started as a local chat. + sessionService.updateMeta({ sessionId: session.id, title: "Cursor Chat" }); + + // First hydrate: Cursor has not named the agent yet and the run has no visible turn. + mockState.cursorSdkCloudResponses.set("cloud.agent.get", {}); + mockState.cursorSdkCloudResponses.set("cloud.runs.list", { + items: [{ runId: "cloud-run-1", status: "running", model: { id: "grok-4.6" } }], + }); + mockState.cursorSdkCloudResponses.set("cloud.run.conversation", { turns: [] }); + await service.openCursorCloudChat({ cloudAgentId: "cloud-agent-1", laneId: "lane-1", sessionId: session.id }); + const readsAfterFirstHydrate = agentGetCalls(); + expect(readsAfterFirstHydrate).toBeGreaterThan(0); + expect(sessionService.get(session.id)?.title ?? "").not.toBe("Named after the first turn"); + + // Next tick, well inside the 60 s TTL: the run now has a visible turn and Cursor has a + // name. The title is still a default, so this tick re-reads the name without polling. + mockState.cursorSdkCloudResponses.set("cloud.agent.get", { name: "Named after the first turn" }); + mockState.cursorSdkCloudResponses.set("cloud.run.conversation", { + turns: [{ + type: "agentConversationTurn", + turn: { + userMessage: { text: "Remote prompt" }, + steps: [{ type: "assistantMessage", message: { text: "Remote answer" } }], + }, + }], + }); + await service.openCursorCloudChat({ cloudAgentId: "cloud-agent-1", laneId: "lane-1", sessionId: session.id }); + expect(agentGetCalls()).toBe(readsAfterFirstHydrate + 1); + expect(sessionService.get(session.id)?.title).toBe("Named after the first turn"); + + // Once named, later ticks inside the TTL do not read the name again. + await service.openCursorCloudChat({ cloudAgentId: "cloud-agent-1", laneId: "lane-1", sessionId: session.id }); + expect(agentGetCalls()).toBe(readsAfterFirstHydrate + 1); + }); + + it("rejects ADE title writes after a chat becomes a Cursor Cloud agent", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Promote this chat.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + + await expect(service.updateSession({ + sessionId: session.id, + title: "ADE-owned title", + manuallyNamed: true, + })).rejects.toThrow("agent names are managed by Cursor"); + await expect(service.regenerateSessionMetadata({ + sessionId: session.id, + fields: ["title"], + })).rejects.toThrow("agent names are managed by Cursor"); + }); + it("uses cloud.followup with the durable agentId on subsequent cloud sends", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; const events: AgentChatEventEnvelope[] = []; @@ -44770,6 +44925,237 @@ it("fails a cleanly ended OpenCode event stream and clears active child sessions expect(summary?.cursorRuntime).toBe("cloud"); }); + it("refuses to create a cloud agent it cannot give the chosen model settings", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + // A catalog whose model DOES declare a reasoning control, but has no + // value for the effort the session chose. Cursor would silently pick one + // of the other values, so the create has to fail instead. + cursorModelsListMock.mockResolvedValue([{ + id: "composer-2", + displayName: "Composer 2", + parameters: [{ + id: "reasoning_effort", + displayName: "Reasoning effort", + values: [{ value: "high", displayName: "High" }], + }], + }]); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + reasoningEffort: "xhigh", + } as any); + + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud agent.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + + const errorEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "error" && event.sessionId === session.id, + ); + expect(errorEvent.event.message).toContain("could not verify the selected model settings"); + // Cursor Cloud substitutes its own default variant when `params` are + // omitted, so a create it cannot express must not be dispatched at all. + expect(mockState.cursorSdkCloudRequests.some((r) => r.type === "cloud.send.stream")).toBe(false); + }); + + it("creates a cloud agent on a model that declares no reasoning control", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + // Cursor's row for this model has no reasoning parameter at all, so the + // effort left on the session by a previously chosen model is + // inapplicable, not unmet. There is no variant Cursor could substitute. + cursorModelsListMock.mockResolvedValue([{ + id: "composer-2.5", + displayName: "Composer 2.5", + parameters: [{ + id: "speed", + displayName: "Speed", + values: [ + { value: "standard", displayName: "Standard" }, + { value: "fast", displayName: "Fast" }, + ], + }], + }]); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2.5", + modelId: "cursor/composer-2.5", + reasoningEffort: "xhigh", + } as any); + + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud agent.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + + expect(events.some((event) => event.event.type === "error")).toBe(false); + const sent = mockState.cursorSdkCloudRequests.find((r) => r.type === "cloud.send.stream"); + expect(sent).toBeTruthy(); + }); + + it("creates the cloud agent with the verified model params", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + cursorModelsListMock.mockResolvedValue([{ + id: "composer-2", + displayName: "Composer 2", + parameters: [{ + id: "reasoning_effort", + displayName: "Reasoning effort", + values: [{ value: "high", displayName: "High" }], + }], + }]); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + reasoningEffort: "high", + } as any); + + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud agent.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + + const sent = mockState.cursorSdkCloudRequests.find((r) => r.type === "cloud.send.stream"); + expect(sent?.payload.modelParams).toEqual([{ id: "reasoning_effort", value: "high" }]); + }); + + it("treats a session that never chose a tier as having no tier opinion", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + // A catalog whose only service tier is "fast": there is no standard value + // to express. A session that never chose a tier must not be verified as + // having asked for one, on either cloud create path. + cursorModelsListMock.mockResolvedValue([{ + id: "composer-2", + displayName: "Composer 2", + parameters: [ + { + id: "reasoning_effort", + displayName: "Reasoning effort", + values: [{ value: "high", displayName: "High" }], + }, + { + id: "speed", + displayName: "Speed", + values: [{ value: "fast", displayName: "Fast" }], + }, + ], + }]); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + reasoningEffort: "high", + } as any); + + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud agent.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + + const sent = mockState.cursorSdkCloudRequests.find((r) => r.type === "cloud.send.stream"); + expect(sent?.payload.modelParams).toEqual([{ id: "reasoning_effort", value: "high" }]); + }); + + it("stops refetching a terminal cloud run that keeps reading back empty", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await service.sendMessage({ + sessionId: session.id, + text: "Create the cloud chat.", + runtime: "cloud", + cloudOverrides: { repoUrl: "https://github.com/example/repo.git" }, + } as any, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "done" && event.sessionId === session.id, + ); + + // A run that ended in error with no visible turns never produces one. + mockState.cursorSdkCloudResponses.set("cloud.runs.list", { + items: [{ runId: "cloud-run-empty", status: "error" }], + }); + mockState.cursorSdkCloudResponses.set("cloud.run.conversation", { turns: [] }); + mockState.cursorSdkCloudRequests.length = 0; + + for (let attempt = 0; attempt < 5; attempt += 1) { + await service.openCursorCloudChat({ + cloudAgentId: "cloud-agent-1", + laneId: "lane-1", + sessionId: session.id, + }); + } + + const conversationReads = mockState.cursorSdkCloudRequests.filter((r) => ( + r.type === "cloud.run.conversation" && r.payload.runId === "cloud-run-empty" + )); + expect(conversationReads).toHaveLength(3); + // The remote name is read on the first hydrate of the session and then at + // most once a minute, not on every one of the five refreshes. + expect(mockState.cursorSdkCloudRequests.filter((r) => r.type === "cloud.agent.get")).toHaveLength(1); + }); + it("includes the cursorSdkSystemPrompt directive in the first cloud send promptText", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; process.env.ADE_CURSOR_PROMPT_INJECT = "1"; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1f8f38f31d..598a74d7c4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -590,6 +590,7 @@ import { type TranscriptHistoryPageRead, } from "./chatTranscriptHistoryPager"; import { extractLeadingSlashCommand, isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; +import { CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE, cursorOwnsSessionName } from "../../../shared/cursorCloudNaming"; import { isManualCompactCommand } from "../../../shared/contextCompaction"; import { deriveDeterministicAutoLaneIdentity, @@ -666,6 +667,9 @@ import { cloudConversationHasTurns, CURSOR_CLOUD_CONVERSATION_RETRY_ATTEMPTS, CURSOR_CLOUD_CONVERSATION_RETRY_MS, + CURSOR_CLOUD_EMPTY_TERMINAL_READ_LIMIT, + CURSOR_CLOUD_PLACEHOLDER_NAME_READ_LIMIT, + CURSOR_CLOUD_REMOTE_NAME_READ_TTL_MS, flattenCloudConversationMessages, fingerprintAlreadyHydrated, isCloudRunStillLive, @@ -699,7 +703,9 @@ import { discoverCursorSdkModelDescriptors, mergeCursorModelDescriptorSources, resolveCachedCursorModelAvailability, + resolveCursorSdkModelSelectionFromCache, resolveCursorSdkModelSelectionParams, + verifyExplicitCursorModelSelection, } from "./cursorModelsDiscovery"; import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; import { @@ -4023,10 +4029,11 @@ function cursorCatalogSupportsFastMode( function cachedCursorSdkParamsSupportFastMode(session: AgentChatSession): boolean { if (session.provider !== "cursor") return false; const modelSdkId = resolveCursorRuntimeModelSdkId(session); - return Boolean(resolveCursorSdkModelSelectionParams({ - modelSdkId, - fastMode: true, - })?.length); + // Only a fully expressed selection answers "this model has a fast tier". A + // partial resolve is the case where the tier is exactly what ADE could not + // express, so its params must not read as support. + const selection = resolveCursorSdkModelSelectionFromCache({ modelSdkId, fastMode: true }); + return selection.status === "ok" && selection.params.length > 0; } function sessionSupportsFastMode( @@ -12041,6 +12048,12 @@ export function createAgentChatService(args: { return false; }; + /** True while the session still carries an ADE default title ("Cursor Chat", "Cursor cloud agent", ...). */ + const sessionTitleIsDefault = (managed: ManagedChatSession): boolean => { + const current = sessionService.get(managed.session.id)?.title ?? ""; + return normalizeRuntimeSessionTitle(managed, current) === null; + }; + const normalizeRuntimeSessionTitle = (managed: ManagedChatSession, rawTitle: unknown): string | null => { const title = sanitizeAutoTitle(extractRuntimeTitle(rawTitle) ?? ""); if (!title) return null; @@ -12055,11 +12068,20 @@ export function createAgentChatService(args: { return sanitizeAutoTitle(sessionService.get(managed.session.id)?.title ?? ""); }; + /** + * The single writer of a session's title metadata. + * + * Cursor owns the name of a cloud chat, so the rule is enforced here rather + * than at each caller: a title writer added later inherits the protection + * instead of having to remember it. Cursor's own name arrives through + * `adoptCursorCloudSessionTitle`, which writes past this guard. + */ const persistSessionTitleMetadata = ( managed: ManagedChatSession, rawTitle: string, manuallyNamed: boolean, ): string | null => { + if (cursorOwnsSessionName(managed.session.cursorCloudAgentId)) return null; const title = rawTitle.trim(); if (!title) return null; @@ -12164,11 +12186,46 @@ export function createAgentChatService(args: { return appliedTitle; }; + const adoptCursorCloudSessionTitle = ( + managed: ManagedChatSession, + rawTitle: unknown, + source: string, + ): string | null => { + if (managed.deleted) return null; + const title = normalizeRuntimeSessionTitle(managed, rawTitle); + if (!title) return null; + + const currentTitle = sessionService.get(managed.session.id)?.title ?? null; + if (currentTitle?.trim() !== title) { + sessionService.updateMeta({ sessionId: managed.session.id, title, manuallyNamed: false }); + managed.sessionMetadataTitleRevision += 1; + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + title, + manuallyNamed: false, + }); + } + managed.manuallyNamed = false; + managed.runtimeTitleAdopted = true; + managed.autoTitleStage = "initial"; + logger.info("agent_chat.runtime_title_adopted", { + sessionId: managed.session.id, + provider: managed.session.provider, + source, + titleLength: title.length, + }); + persistChatState(managed); + return title; + }; + const adoptRuntimeSessionTitle = ( managed: ManagedChatSession, rawTitle: unknown, source: string, ): string | null => { + if (cursorOwnsSessionName(managed.session.cursorCloudAgentId)) { + return adoptCursorCloudSessionTitle(managed, rawTitle, source); + } if (managed.deleted) return null; if (sessionIsManuallyNamed(managed)) return null; const title = normalizeRuntimeSessionTitle(managed, rawTitle); @@ -12238,6 +12295,7 @@ export function createAgentChatService(args: { args: { stage: "initial" | "final"; latestUserText?: string | null; summary?: string | null } ): Promise => { if (managed.deleted) return; + if (cursorOwnsSessionName(managed.session.cursorCloudAgentId)) return; const config = resolveChatConfig(); if (!config.titleGenerationEnabled) return; if (sessionIsManuallyNamed(managed)) return; @@ -12348,9 +12406,16 @@ export function createAgentChatService(args: { }; let sessionMetadataRegenerator: ReturnType | null = null; - const regenerateSessionMetadata = ( + const regenerateSessionMetadata = async ( args: AgentChatRegenerateSessionMetadataArgs, ): Promise => { + // `async`, so `ensureManagedSession` rejects for an unknown session id like + // every other failure here rather than throwing at the call site. + const managed = ensureManagedSession(args.sessionId); + const requestedFields = args.fields ?? ["title", "laneName", "statusLine"]; + if (cursorOwnsSessionName(managed.session.cursorCloudAgentId) && requestedFields.includes("title")) { + throw new Error(CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE); + } sessionMetadataRegenerator ??= createSessionMetadataRegenerator({ ensureManagedSession, getSession: (sessionId) => { @@ -37197,6 +37262,26 @@ export function createAgentChatService(args: { fastMode: session.fastMode === true, }); + /** + * Resolve the model params for a NEW cloud agent, or refuse to create it. + * + * `verifyExplicitCursorModelSelection` owns the fail-closed rule that both + * cloud create paths obey, so this supplies only the fallback for a session + * that chose neither control: its ordinary best-effort params. A followup to + * an existing agent is unaffected, because its variant is already fixed. + */ + const requireCursorCloudCreateModelParams = async ( + session: Pick, + modelSdkId: string, + apiKey: string, + ): Promise | undefined> => ( + await verifyExplicitCursorModelSelection(apiKey, { + modelSdkId, + reasoningEffort: session.reasoningEffort, + fastMode: session.fastMode ?? null, + }) + ) ?? resolveCursorSdkModelParamsForSession(session, modelSdkId); + const cursorModelParamsForLog = ( modelParams?: Array<{ id: string; value: string }>, ): Array<{ id: string; value: string }> | undefined => @@ -39640,19 +39725,7 @@ export function createAgentChatService(args: { } const { promptText, images } = await buildCursorWorkerPrompt(cloudComposed, args.resolvedAttachments); - const cloudLogModelParams = runtime.modelSdkId - ? resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId) - : undefined; persistChatState(managed); - logger.info("agent_chat.cursor_cloud_prompt_start", { - sessionId: managed.session.id, - turnId, - isFollowUp, - hasAgentId: Boolean(managed.session.cursorCloudAgentId), - ...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}), - ...(cloudLogModelParams?.length ? { modelParams: cursorModelParamsForLog(cloudLogModelParams) } : {}), - imageCount: images.length, - }); if (args.onDispatched) { args.onDispatched(); @@ -39668,10 +39741,21 @@ export function createAgentChatService(args: { try { let result: unknown; const sdkMode = cursorSdkModeForPolicy(runtime.sdkPolicy ?? resolveCursorSdkPolicy(managed.session)); - if (isFollowUp && managed.session.cursorCloudAgentId) { - const modelParams = runtime.modelSdkId + const modelParams = runtime.modelSdkId + ? (isFollowUp ? resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId) - : undefined; + : await requireCursorCloudCreateModelParams(managed.session, runtime.modelSdkId, apiKey)) + : undefined; + logger.info("agent_chat.cursor_cloud_prompt_start", { + sessionId: managed.session.id, + turnId, + isFollowUp, + hasAgentId: Boolean(managed.session.cursorCloudAgentId), + ...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}), + ...(modelParams?.length ? { modelParams: cursorModelParamsForLog(modelParams) } : {}), + imageCount: images.length, + }); + if (isFollowUp && managed.session.cursorCloudAgentId) { const payload: CursorSdkCloudFollowupPayload = { apiKey, agentId: managed.session.cursorCloudAgentId, @@ -39688,10 +39772,6 @@ export function createAgentChatService(args: { ); } else { const repoUrl = await resolveCloudRepoUrl(managed, args.cloudOverrides); - const manualAgentName = manualSessionTitleForRuntime(managed); - const modelParams = runtime.modelSdkId - ? resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId) - : undefined; let linearIssueId = args.cloudOverrides?.linearIssueId?.trim() || null; if (!linearIssueId) { try { @@ -39731,7 +39811,6 @@ export function createAgentChatService(args: { projectId: launch.projectId, linearIssueId: launch.linearIssueId, ...(Object.keys(launch.envVars).length > 0 ? { envVars: launch.envVars } : {}), - ...(manualAgentName ? { agentName: manualAgentName } : {}), ...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}), ...(modelParams?.length ? { modelParams } : {}), ...(args.cloudOverrides?.startingRef ? { startingRef: args.cloudOverrides.startingRef } : {}), @@ -39765,7 +39844,7 @@ export function createAgentChatService(args: { const innerResult = "result" in startedRecord ? startedRecord.result : startedRecord; const resultRecord = asRecord(innerResult) ?? asRecord(startedRecord) ?? null; const resultStatus = typeof resultRecord?.status === "string" ? resultRecord.status : ""; - adoptRuntimeSessionTitle(managed, startedRecord, "cursor_cloud_agent_info"); + adoptCursorCloudSessionTitle(managed, startedRecord, "cursor_cloud_agent_info"); if (runStartedAgentId) { managed.session.cursorCloudAgentId = runStartedAgentId; @@ -40297,6 +40376,34 @@ export function createAgentChatService(args: { const cursorCloudHydrateInFlight = new Set(); const cursorCloudHydratedRunIds = new Map>(); + /** When this session last read its agent's remote name, per session id. */ + const cursorCloudRemoteNameReadAt = new Map(); + /** Empty conversation reads of a terminal run, per session id and run id. */ + const cursorCloudEmptyRunReads = new Map>(); + /** Event-driven name reads made while the title was still a default, per session id. */ + const cursorCloudPlaceholderNameReads = new Map(); + + /** + * Forget one session's cloud hydration state. + * + * `cursorCloudHydrateInFlight` is deliberately absent: that marker has to + * survive a teardown until the request it guards settles. + */ + const forgetCursorCloudHydrationState = (sessionId: string): void => { + cursorCloudHydratedRunIds.delete(sessionId); + cursorCloudRemoteNameReadAt.delete(sessionId); + cursorCloudEmptyRunReads.delete(sessionId); + cursorCloudPlaceholderNameReads.delete(sessionId); + }; + + /** Forget every session's cloud hydration state, for a whole-service dispose. */ + const clearAllCursorCloudHydrationState = (): void => { + cursorCloudHydrateInFlight.clear(); + cursorCloudHydratedRunIds.clear(); + cursorCloudRemoteNameReadAt.clear(); + cursorCloudEmptyRunReads.clear(); + cursorCloudPlaceholderNameReads.clear(); + }; type CursorCloudLatestRun = { runId: string; @@ -40347,6 +40454,41 @@ export function createAgentChatService(args: { const hydrateTurnId = randomUUID(); let emittedVisible = false; try { + // Cursor owns the agent name. Read it on the first hydrate of a session + // and then at most once a minute: a watched mirror ticks every three + // seconds during an active run, and a rename on cursor.com is not worth + // twenty extra API calls a minute per chat. + const readRemoteName = async (): Promise => { + // Stamped before the request, so a failing read is rate-limited too. + cursorCloudRemoteNameReadAt.set(managed.session.id, Date.now()); + try { + const remoteAgent = await runCursorSdkCloudRequest({ + projectRoot, + workspacePath, + apiKey, + type: "cloud.agent.get", + payload: { agentId }, + logger, + }); + const remoteName = extractRuntimeTitle(remoteAgent); + if (remoteName) adoptCursorCloudSessionTitle(managed, remoteName, "cursor_cloud_agent"); + } catch (error) { + logger.warn("agent_chat.cursor_cloud_agent_info_failed", { + sessionId: managed.session.id, + agentId, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const lastRemoteNameReadAt = cursorCloudRemoteNameReadAt.get(managed.session.id); + let remoteNameReadThisPass = false; + if ( + lastRemoteNameReadAt === undefined + || Date.now() - lastRemoteNameReadAt >= CURSOR_CLOUD_REMOTE_NAME_READ_TTL_MS + ) { + remoteNameReadThisPass = true; + await readRemoteName(); + } let runs: CursorCloudLatestRun[] = []; const conversationByRunId = new Map(); for (let attempt = 0; attempt < CURSOR_CLOUD_CONVERSATION_RETRY_ATTEMPTS; attempt += 1) { @@ -40399,15 +40541,58 @@ export function createAgentChatService(args: { } const hydrated = cursorCloudHydratedRunIds.get(managed.session.id) ?? new Set(); + const hydratedBefore = new Set(hydrated); + const emptyReads = cursorCloudEmptyRunReads.get(managed.session.id) ?? new Map(); for (const run of [...runs].reverse()) { const conversation = conversationByRunId.get(run.runId); if (!conversation) continue; - if (hydrateCursorCloudConversationEvents(managed, conversation, { turnId: hydrateTurnId })) { + const hasConversationTurns = cloudConversationHasTurns(conversation); + if (hasConversationTurns && hydrateCursorCloudConversationEvents(managed, conversation, { turnId: hydrateTurnId })) { emittedVisible = true; } - if (!isCloudRunStillLive(run.status)) hydrated.add(run.runId); + if (isCloudRunStillLive(run.status)) continue; + // Do not permanently mark an empty or unrecognised response as + // hydrated on the first read. Cursor can return a run before its + // conversation is materialized, and the next presence-gated refresh + // must be allowed to fetch it again once the VM has produced the + // transcript. Bound that retry: a terminal run that reads empty a few + // times in a row (an ERROR run with no visible turns never gets one) + // would otherwise be refetched on every mirror tick forever. + if (hasConversationTurns) { + hydrated.add(run.runId); + emptyReads.delete(run.runId); + continue; + } + const attempts = (emptyReads.get(run.runId) ?? 0) + 1; + if (attempts >= CURSOR_CLOUD_EMPTY_TERMINAL_READ_LIMIT) { + hydrated.add(run.runId); + emptyReads.delete(run.runId); + } else { + emptyReads.set(run.runId, attempts); + } } cursorCloudHydratedRunIds.set(managed.session.id, hydrated); + cursorCloudEmptyRunReads.set(managed.session.id, emptyReads); + + // Cursor names the agent shortly after its first run produces output, + // which is usually after the read above. No polling: while the ADE title + // is still a default, re-read the name only on the tick that yields the + // first visible turn or sees a run reach a terminal status, and stop + // after a few attempts. The TTL rule above still applies afterwards. + const runReachedTerminalThisPass = runs.some((run) => ( + !isCloudRunStillLive(run.status) && !hydratedBefore.has(run.runId) && hydrated.has(run.runId) + )); + if ( + !remoteNameReadThisPass + && (emittedVisible || runReachedTerminalThisPass) + && sessionTitleIsDefault(managed) + ) { + const placeholderReads = cursorCloudPlaceholderNameReads.get(managed.session.id) ?? 0; + if (placeholderReads < CURSOR_CLOUD_PLACEHOLDER_NAME_READ_LIMIT) { + cursorCloudPlaceholderNameReads.set(managed.session.id, placeholderReads + 1); + await readRemoteName(); + } + } if (emittedVisible) { flushBufferedReasoning(managed); @@ -40531,9 +40716,10 @@ export function createAgentChatService(args: { const openCursorCloudChat = async (args: { cloudAgentId: string; laneId: string; - agentName?: string | null; sessionId?: string | null; modelId?: string | null; + reasoningEffort?: string | null; + fastMode?: boolean | null; }): Promise<{ sessionId: string; session: AgentChatSession }> => { const trimmedAgent = args.cloudAgentId.trim(); const trimmedLane = args.laneId.trim(); @@ -40572,6 +40758,7 @@ export function createAgentChatService(args: { } } + const existedBefore = Boolean(managed); if (!managed) { const requestedModel = typeof args.modelId === "string" ? args.modelId.trim() : ""; const sdkId = requestedModel.replace(/^cursor\//, "") || "composer-2"; @@ -40580,6 +40767,8 @@ export function createAgentChatService(args: { provider: "cursor", model: sdkId, modelId: `cursor/${sdkId}`, + ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args.fastMode !== undefined && args.fastMode !== null ? { fastMode: args.fastMode } : {}), ...(requestedId ? { sessionId: requestedId } : {}), }); managed = managedSessions.get(created.id) ?? null; @@ -40588,14 +40777,13 @@ export function createAgentChatService(args: { managed.session.cursorCloudAgentId = trimmedAgent; managed.session.cursorRuntime = "cloud"; - if (typeof args.agentName === "string" && args.agentName.trim()) { - adoptRuntimeSessionTitle(managed, args.agentName.trim(), "cursor_cloud_agent"); - } persistChatState(managed); // New launches return the ADE session immediately so the renderer can - // leave the draft pane. Reopening an existing empty cloud chat waits for - // hydrate so Retry/backfill does not time out on a fire-and-forget fetch. + // leave the draft pane; the launcher pre-assigns the session id, so "new" + // means the session did not exist before this call, not that no id was + // given. Reopening an existing empty cloud chat waits for hydrate so + // Retry/backfill does not time out on a fire-and-forget fetch. const hydratePromise = attachAndHydrateCursorCloudChat({ managed, agentId: trimmedAgent, @@ -40607,9 +40795,9 @@ export function createAgentChatService(args: { agentId: trimmedAgent, error: error instanceof Error ? error.message : String(error), }); - if (requestedId) throw error; + if (existedBefore) throw error; }); - if (requestedId) await hydratePromise; + if (existedBefore) await hydratePromise; return { sessionId: managed.session.id, session: managed.session }; }; @@ -45222,6 +45410,7 @@ export function createAgentChatService(args: { eventHistoryBySession.delete(sessionId); transcriptHistoryCacheBySession.delete(sessionId); resolvedTranscriptPathBySession.delete(sessionId); + forgetCursorCloudHydrationState(sessionId); }; const countActiveForLane = (laneId: string): number => { @@ -47053,6 +47242,7 @@ export function createAgentChatService(args: { ]); teardownRuntime(managed, "ended_session"); managedSessions.delete(trimmedSessionId); + forgetCursorCloudHydrationState(trimmedSessionId); } else { clearSubagentSnapshots(trimmedSessionId); } @@ -47115,21 +47305,31 @@ export function createAgentChatService(args: { sessionService.unarchiveSession(trimmedSessionId); }; - const disposeAll = async (): Promise => { - // First, before anything that can throw. The dispose tail is wrapped in a - // swallow by both hosts, so a rejection further down would leave this - // service registered — and a half-disposed service keeps counting runtimes - // that no longer exist, permanently shrinking the process budget. + /** + * The teardown every dispose path runs first. + * + * First, before anything that can throw. The dispose tail is wrapped in a + * swallow by both hosts, so a rejection further down would leave this service + * registered — and a half-disposed service keeps counting runtimes that no + * longer exist, permanently shrinking the process budget. + * + * One function, because `disposeAll` and `forceDisposeAll` ran identical + * copies and every new piece of per-service state had to be remembered twice. + */ + const beginDispose = (): void => { runtimeBudget.unregister(runtimeBudgetParticipant); hostSleepChips.dispose(); clearInterval(sessionCleanupTimer); clearCursorCloudMirrorWatches(); - cursorCloudHydrateInFlight.clear(); - cursorCloudHydratedRunIds.clear(); + clearAllCursorCloudHydrationState(); scheduledWorkScheduler?.dispose(); autoResume.forgetAll(); for (const recovery of cancelledQueueRecoveries.values()) clearTimeout(recovery.timer); cancelledQueueRecoveries.clear(); + }; + + const disposeAll = async (): Promise => { + beginDispose(); for (const sessionId of [...managedSessions.keys()]) { try { await disposeManagedSession({ sessionId }, "detached"); @@ -47142,20 +47342,7 @@ export function createAgentChatService(args: { }; const forceDisposeAll = (): void => { - // First, before anything that can throw. The dispose tail is wrapped in a - // swallow by both hosts, so a rejection further down would leave this - // service registered — and a half-disposed service keeps counting runtimes - // that no longer exist, permanently shrinking the process budget. - runtimeBudget.unregister(runtimeBudgetParticipant); - hostSleepChips.dispose(); - clearInterval(sessionCleanupTimer); - clearCursorCloudMirrorWatches(); - cursorCloudHydrateInFlight.clear(); - cursorCloudHydratedRunIds.clear(); - scheduledWorkScheduler?.dispose(); - autoResume.forgetAll(); - for (const recovery of cancelledQueueRecoveries.values()) clearTimeout(recovery.timer); - cancelledQueueRecoveries.clear(); + beginDispose(); for (const sessionId of [...sessionTurnCollectors.keys()]) { rejectActiveSessionTurnCollector(sessionId, `Chat session '${sessionId}' was closed during shutdown.`); } @@ -47320,6 +47507,9 @@ export function createAgentChatService(args: { }: AgentChatUpdateSessionArgs): Promise => { const fastMode = requestedFastModeArg ?? requestedLegacyFastModeArg; const managed = ensureManagedSession(sessionId); + if (cursorOwnsSessionName(managed.session.cursorCloudAgentId) && (title !== undefined || manuallyNamed !== undefined)) { + throw new Error(CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE); + } const chatConfig = resolveChatConfig(); const isIdentitySession = Boolean(managed.session.identityKey); const identityPinned = isPrimaryPinnedIdentity(managed.session.identityKey); diff --git a/apps/desktop/src/main/services/chat/cursorCloudConversation.ts b/apps/desktop/src/main/services/chat/cursorCloudConversation.ts index 0bbd2b27b2..658cdd3e1a 100644 --- a/apps/desktop/src/main/services/chat/cursorCloudConversation.ts +++ b/apps/desktop/src/main/services/chat/cursorCloudConversation.ts @@ -9,6 +9,37 @@ export const CURSOR_CLOUD_CONVERSATION_RETRY_ATTEMPTS = 8; export const CURSOR_CLOUD_CONVERSATION_RETRY_MS = 2_000; +/** + * How often a watched cloud chat re-reads its agent's remote name. + * + * Cursor owns that name, but the mirror ticks every three seconds during an + * active run and a rename on cursor.com is not worth an API call per tick. + */ +export const CURSOR_CLOUD_REMOTE_NAME_READ_TTL_MS = 60_000; + +/** + * How many times a terminal run may read back an empty conversation before ADE + * stops asking. + * + * A run that ends in ERROR with no visible turns never produces one, so an + * unbounded retry refetches it on every mirror tick for the life of the + * session. A few attempts still cover the real case the retry exists for: a run + * that reports terminal before its VM has written the transcript. + */ +export const CURSOR_CLOUD_EMPTY_TERMINAL_READ_LIMIT = 3; + +/** + * How many event-driven name reads a still-unnamed cloud chat may make on top + * of the TTL rule. + * + * Cursor names an agent shortly after its first run produces output, which is + * usually after ADE's first read. Rather than poll, the mirror re-reads the name + * only while the ADE title is still a default and only when a tick yields the + * first visible turn or a run reaches a terminal status, and stops after this + * many extra reads. + */ +export const CURSOR_CLOUD_PLACEHOLDER_NAME_READ_LIMIT = 3; + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record diff --git a/apps/desktop/src/main/services/chat/cursorCloudFleetService.ts b/apps/desktop/src/main/services/chat/cursorCloudFleetService.ts index 5f00cbe748..94c600843c 100644 --- a/apps/desktop/src/main/services/chat/cursorCloudFleetService.ts +++ b/apps/desktop/src/main/services/chat/cursorCloudFleetService.ts @@ -40,7 +40,6 @@ type FleetServiceDeps = { openCursorCloudChat: (args: { cloudAgentId: string; laneId: string; - agentName?: string | null; }) => Promise<{ sessionId: string }>; cancelCursorCloudRun: (args: { agentId: string; runId: string }) => Promise; /** Single-agent read for agents beyond the first list page. */ @@ -471,7 +470,6 @@ export function createCursorCloudFleetService(deps: FleetServiceDeps) { const opened = await deps.openCursorCloudChat({ cloudAgentId: id, laneId: lane.id, - agentName: agent.name, }); sessionId = opened.sessionId; } catch (error) { diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts index 2f016defb8..b70635a191 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts @@ -38,9 +38,13 @@ import { markCursorModelCachesStale, mergeCursorModelDescriptorSources, parseCursorCliModelsStdout, + describeCursorSdkModelSelectionFailure, probeCursorSdkModelDiscovery, + resolveCursorSdkModelSelection, + resolveCursorSdkModelSelectionFromCache, resolveCursorSdkModelSelectionParams, resolveCachedCursorModelAvailability, + verifyExplicitCursorModelSelection, } from "./cursorModelsDiscovery"; beforeEach(() => { @@ -492,6 +496,234 @@ describe("parseCursorCliModelsStdout", () => { })).toEqual([{ id: "speed", value: "fast" }]); }); + it("keeps a known model with no parameterized controls valid", async () => { + cursorModelsListMock.mockResolvedValue([ + { id: "grok-4.6", displayName: "Grok 4.6" }, + ]); + + await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); + + expect(resolveCursorSdkModelSelectionParams({ + modelSdkId: "grok-4.6", + fastMode: false, + })).toEqual([]); + }); + + it("reports a value a control the model DOES declare cannot express as partial", async () => { + cursorModelsListMock.mockResolvedValue([ + { + id: "grok-4.6", + displayName: "Grok 4.6", + // A bare snake-case id and no display name. The classifier still reads + // it as a reasoning control, so the requested value is unmet, not + // inapplicable. + parameters: [{ + id: "reasoning_effort", + values: [{ value: "high" }], + }], + }, + { + id: "grok-4.6-tiered", + displayName: "Grok 4.6 Tiered", + parameters: [{ + id: "service_tier", + values: [{ value: "standard" }, { value: "fast" }], + }], + }, + ]); + + await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); + + expect(resolveCursorSdkModelSelectionFromCache({ + modelSdkId: "grok-4.6", + reasoningEffort: "xhigh", + fastMode: false, + })).toEqual({ status: "partial", params: [], unmet: ["reasoning"] }); + // The local chat path still sends whatever resolved: dropping every param + // because one control could not be expressed loses the others too. + expect(resolveCursorSdkModelSelectionParams({ + modelSdkId: "grok-4.6", + reasoningEffort: "xhigh", + fastMode: false, + })).toEqual([]); + // The same for a bare tier id: `service_tier` alone classifies as a tier + // control, so the chosen tier resolves to a real value. + expect(resolveCursorSdkModelSelectionFromCache({ + modelSdkId: "grok-4.6-tiered", + fastMode: false, + })).toEqual({ status: "ok", params: [{ id: "service_tier", value: "standard" }] }); + }); + + it("treats a control the model declares no parameter for as inapplicable, not unmet", async () => { + cursorModelsListMock.mockResolvedValue([ + { + id: "composer-2.5", + displayName: "Composer 2.5", + // Cursor's real row for this model declares a speed control and no + // reasoning control at all. + parameters: [{ + id: "speed", + displayName: "Speed", + values: [ + { value: "standard", displayName: "Standard" }, + { value: "fast", displayName: "Fast" }, + ], + }], + }, + { + id: "grok-4.6", + displayName: "Grok 4.6", + // The mirror case: a reasoning control and no service tier control. + parameters: [{ + id: "reasoning_effort", + displayName: "Reasoning effort", + values: [{ value: "high", displayName: "High" }], + }], + }, + ]); + + await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); + + // A stale reasoning effort left on the draft by a previously selected model + // cannot block this one: there is no variant Cursor could silently pick. + expect(resolveCursorSdkModelSelectionFromCache({ + modelSdkId: "composer-2.5", + reasoningEffort: "xhigh", + fastMode: true, + })).toEqual({ status: "ok", params: [{ id: "speed", value: "fast" }] }); + expect(resolveCursorSdkModelSelectionFromCache({ + modelSdkId: "composer-2.5", + reasoningEffort: "xhigh", + fastMode: null, + })).toEqual({ status: "ok", params: [] }); + // Fast mode on a model with no service tier control is inapplicable too. + expect(resolveCursorSdkModelSelectionFromCache({ + modelSdkId: "grok-4.6", + reasoningEffort: "high", + fastMode: true, + })).toEqual({ status: "ok", params: [{ id: "reasoning_effort", value: "high" }] }); + }); + + it("lets a cloud create launch a model that declares no reasoning control", async () => { + cursorModelsListMock.mockResolvedValue([{ + id: "composer-2.5", + displayName: "Composer 2.5", + parameters: [{ + id: "speed", + displayName: "Speed", + values: [ + { value: "standard", displayName: "Standard" }, + { value: "fast", displayName: "Fast" }, + ], + }], + }]); + + // The fail-closed cloud path verifies the same way: it returns the params it + // could express instead of refusing the launch over an inapplicable control. + await expect(resolveCursorSdkModelSelection("crsr_test", { + modelSdkId: "composer-2.5", + reasoningEffort: "xhigh", + fastMode: false, + })).resolves.toEqual({ status: "ok", params: [{ id: "speed", value: "standard" }] }); + await expect(verifyExplicitCursorModelSelection("crsr_test", { + modelSdkId: "composer-2.5", + reasoningEffort: "xhigh", + fastMode: false, + })).resolves.toEqual([{ id: "speed", value: "standard" }]); + }); + + it("tells an unlisted model apart from a catalog it could not load", async () => { + expect(resolveCursorSdkModelSelectionFromCache({ modelSdkId: "composer-2" })).toEqual({ + status: "catalog-unavailable", + reason: expect.any(String), + }); + + cursorModelsListMock.mockResolvedValue([{ id: "composer-2", displayName: "Composer 2" }]); + await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); + + expect(resolveCursorSdkModelSelectionFromCache({ modelSdkId: "composer-2" })) + .toEqual({ status: "ok", params: [] }); + expect(resolveCursorSdkModelSelectionFromCache({ modelSdkId: "gpt-9" })) + .toEqual({ status: "unknown-model" }); + }); + + it("probes and resolves in one call, and names a probe failure as its reason", async () => { + cursorModelsListMock.mockResolvedValue([ + { + id: "composer-2", + displayName: "Composer 2", + parameters: [{ + id: "reasoning_effort", + displayName: "Reasoning effort", + values: [{ value: "high" }], + }], + }, + ]); + + await expect(resolveCursorSdkModelSelection("crsr_test", { + modelSdkId: "composer-2", + reasoningEffort: "high", + })).resolves.toEqual({ status: "ok", params: [{ id: "reasoning_effort", value: "high" }] }); + + clearCursorCliModelsCache(); + cursorModelsListMock.mockRejectedValue(new Error("SDK model listing failed")); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 503 }))); + + const failed = await resolveCursorSdkModelSelection("crsr_test", { + modelSdkId: "composer-2", + reasoningEffort: "high", + }); + // The async resolver supplies the probe's own reason. The cache-only + // resolver cannot: it takes no API key, so it never speaks for one. + expect(failed).toEqual({ status: "catalog-unavailable", reason: expect.any(String) }); + if (failed.status === "ok") throw new Error("unreachable"); + expect(describeCursorSdkModelSelectionFailure("composer-2", failed)) + .toMatch(/^Could not load Cursor's model catalog \(.+\)\. Try again\.$/); + expect(resolveCursorSdkModelSelectionFromCache({ modelSdkId: "composer-2" })).toEqual({ + status: "catalog-unavailable", + reason: "Cursor's model catalog has not loaded yet.", + }); + }); + + it("verifies a cloud create only when the caller chose a control", async () => { + cursorModelsListMock.mockResolvedValue([{ + id: "composer-2", + displayName: "Composer 2", + parameters: [{ + id: "speed", + displayName: "Speed", + values: [{ value: "fast", displayName: "Fast" }], + }], + }]); + + // Nothing chosen: no verification, and the caller supplies its own fallback. + await expect(verifyExplicitCursorModelSelection("crsr_test", { modelSdkId: "composer-2" })) + .resolves.toBeNull(); + // An absent fast mode is no tier opinion, so a catalog with no standard + // value still verifies. Both cloud create paths read it the same way. + await expect(verifyExplicitCursorModelSelection("crsr_test", { + modelSdkId: "composer-2", + reasoningEffort: null, + fastMode: true, + })).resolves.toEqual([{ id: "speed", value: "fast" }]); + await expect(verifyExplicitCursorModelSelection("crsr_test", { + modelSdkId: "composer-2", + fastMode: false, + })).rejects.toThrow("could not verify the selected model settings (standard speed)"); + }); + + it("names the cause of every selection a fail-closed caller refuses", () => { + expect(describeCursorSdkModelSelectionFailure("composer-2", { status: "unknown-model" })) + .toBe("Cursor Cloud does not list model composer-2. Refresh Cursor models."); + expect(describeCursorSdkModelSelectionFailure("composer-2", { + status: "partial", + params: [], + unmet: ["fast"], + })).toBe( + "Cursor Cloud could not verify the selected model settings (fast mode). Refresh Cursor models and try again.", + ); + }); + it("does not let standard tier variants overwrite selected Cursor reasoning params", async () => { cursorModelsListMock.mockResolvedValue([ { diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index c417096a39..f6d14a8580 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -368,14 +368,26 @@ function normalizeCursorAliasList(value: unknown, canonicalId?: string): string[ return out.length ? out : undefined; } +/** + * The text the parameter classifiers match against. + * + * `_`, `-` and `.` become spaces so the word-boundary tests see the words in a + * snake- or kebab-case id. Without that, `\b` finds no boundary inside + * `reasoning_effort` or `service_tier`, and a row that carries only an id and + * no display name is classified as neither a reasoning nor a tier control. + */ +function cursorParameterClassifierHaystack( + parameter: Pick, +): string { + return `${parameter.id} ${parameter.displayName ?? ""}`.toLowerCase().replace(/[_\-.]+/g, " "); +} + function isReasoningParameterLike(parameter: Pick): boolean { - const hay = `${parameter.id} ${parameter.displayName ?? ""}`.toLowerCase(); - return /\b(reason|reasoning|thinking|think|effort)\b/.test(hay); + return /\b(reason|reasoning|thinking|think|effort)\b/.test(cursorParameterClassifierHaystack(parameter)); } function isServiceTierParameterLike(parameter: Pick): boolean { - const hay = `${parameter.id} ${parameter.displayName ?? ""}`.toLowerCase(); - return /\b(speed|service|tier|mode|latency)\b/.test(hay); + return /\b(speed|service|tier|mode|latency)\b/.test(cursorParameterClassifierHaystack(parameter)); } function normalizeCursorReasoningValue(value: unknown): string | null { @@ -1011,19 +1023,96 @@ export function mergeCursorModelDescriptorSources(args: { ); } -export function resolveCursorSdkModelSelectionParams(args: { +/** + * A model control the verified catalog cannot express for the chosen model. + * + * Reported only when the model DECLARES a parameter of that class and the + * requested value maps onto none of its values. A model that declares no + * parameter of the class at all leaves the control inapplicable, which is not + * an unmet control: Cursor has no variant to silently substitute, so the + * selection stays `ok`. + */ +export type CursorSdkModelSelectionUnmetControl = "reasoning" | "fast" | "standard"; + +/** + * What ADE could make of a Cursor model selection. + * + * The four outcomes are deliberately distinct, because the callers want + * different things from them. A local chat send is best-effort: it sends + * whatever params resolved, on `ok` and on `partial` alike. A cloud create + * fails closed on anything but `ok`, because Cursor Cloud silently substitutes + * its own default variant when `params` are omitted — and the error it shows + * has to name the real cause rather than blame the user's selection for a + * network fault. + */ +export type CursorSdkModelSelectionResult = + | { status: "ok"; params: CursorModelParameterValue[] } + | { + status: "partial"; + params: CursorModelParameterValue[]; + unmet: CursorSdkModelSelectionUnmetControl[]; + } + | { status: "unknown-model" } + | { status: "catalog-unavailable"; reason: string }; + +const CURSOR_SDK_UNMET_CONTROL_LABELS: Record = { + reasoning: "reasoning effort", + fast: "fast mode", + standard: "standard speed", +}; + +/** + * The error a fail-closed caller shows for a selection it cannot use. + * + * Each outcome names its own cause: a cold or failed catalog is ADE's problem + * to retry, an unlisted model is a stale picker, and a partial resolve is the + * one case where the user's own control is what could not be expressed. + */ +export function describeCursorSdkModelSelectionFailure( + modelSdkId: string, + selection: Exclude, +): string { + if (selection.status === "catalog-unavailable") { + return `Could not load Cursor's model catalog (${selection.reason}). Try again.`; + } + if (selection.status === "unknown-model") { + return `Cursor Cloud does not list model ${modelSdkId.trim() || "(unnamed)"}. Refresh Cursor models.`; + } + const controls = selection.unmet.map((entry) => CURSOR_SDK_UNMET_CONTROL_LABELS[entry]).join(" and "); + return `Cursor Cloud could not verify the selected model settings (${controls}). Refresh Cursor models and try again.`; +} + +export type CursorSdkModelSelectionInput = { modelSdkId: string; reasoningEffort?: string | null; fastMode?: boolean | null; -}): CursorModelParameterValue[] | undefined { +}; + +/** + * Resolve a model selection against the catalog already in memory. + * + * Cache-only and synchronous, for the local chat path, which resolves params on + * every send and must never block one on a network fetch. Use + * `resolveCursorSdkModelSelection` where the catalog has to be verified first. + */ +export function resolveCursorSdkModelSelectionFromCache( + args: CursorSdkModelSelectionInput, +): CursorSdkModelSelectionResult { const modelSdkId = args.modelSdkId.trim(); - if (!modelSdkId || !sdkCached?.models.length) return undefined; + if (!modelSdkId) return { status: "unknown-model" }; + if (!sdkCached?.models.length) { + // A fixed reason, not `sdkLastFailure`: this resolver takes no API key, so + // it cannot tell whether the last recorded failure belongs to the key the + // caller is asking about. `resolveCursorSdkModelSelection` has the probe + // result and supplies the real cause. + return { status: "catalog-unavailable", reason: "Cursor's model catalog has not loaded yet." }; + } const normalizedModelSdkId = modelSdkId.toLowerCase(); const row = sdkCached.models.find((entry) => entry.id.trim().toLowerCase() === normalizedModelSdkId || (entry.aliases ?? []).some((alias) => alias.trim().toLowerCase() === normalizedModelSdkId), ); - if (!row) return undefined; + if (!row) return { status: "unknown-model" }; const reasoning = normalizeCursorMetadataText(args.reasoningEffort); const wantsFast = args.fastMode === true; const wantsStandard = args.fastMode === false; @@ -1110,7 +1199,108 @@ export function resolveCursorSdkModelSelectionParams(args: { } const params = [...out.entries()].map(([id, value]) => ({ id, value })); - return params.length ? params : undefined; + // A control the model defines no parameter for is INAPPLICABLE, not unmet. + // Cursor has no variant to substitute for a class the row never declares, so + // there is nothing to enforce and nothing the user could pick differently. + // Only a class the model DOES declare, whose requested value ADE cannot map + // onto one of its values, is unmet. A stale draft carrying a reasoning effort + // from a previously selected model must not block a model such as + // `composer-2.5`, whose catalog row has no reasoning parameter at all. + const unmet: CursorSdkModelSelectionUnmetControl[] = []; + if ( + reasoning + && reasoningParameterIds.size > 0 + && !params.some((param) => reasoningParameterIds.has(param.id)) + ) { + unmet.push("reasoning"); + } + if ( + wantsFast + && serviceTierParameterIds.size > 0 + && !params.some((param) => serviceTierParameterIds.has(param.id)) + ) { + unmet.push("fast"); + } + if ( + wantsStandard + && serviceTierParameterIds.size > 0 + && !params.some((param) => serviceTierParameterIds.has(param.id)) + ) { + unmet.push("standard"); + } + if (unmet.length) return { status: "partial", params, unmet }; + // An explicitly-known model with no parameterized controls is still a valid + // selection. The empty array lets callers distinguish it from a model that + // was not present in the verified SDK catalog. + return { status: "ok", params }; +} + +/** + * Best-effort params for a model selection, from the catalog already in memory. + * + * Returns the params ADE could resolve on `ok` and on `partial`, and `undefined` + * only when the catalog cannot answer for this model at all. Sending the params + * that did resolve beats sending none: a model whose tier control ADE cannot + * express still honours the reasoning effort the user picked. + */ +export function resolveCursorSdkModelSelectionParams( + args: CursorSdkModelSelectionInput, +): CursorModelParameterValue[] | undefined { + const selection = resolveCursorSdkModelSelectionFromCache(args); + return selection.status === "ok" || selection.status === "partial" ? selection.params : undefined; +} + +/** + * Verify the Cursor model catalog, then resolve a model selection against it. + * + * The probe and the resolve live together here because both read `sdkCached`, + * the module's own cache. A caller that probed for the side effect and then + * called the resolver depended on an ordering it could not state, and could not + * tell a cold cache from a model the catalog cannot express. + */ +export async function resolveCursorSdkModelSelection( + apiKey: string | null | undefined, + args: CursorSdkModelSelectionInput, +): Promise { + const probe = await probeCursorSdkModelDiscovery(apiKey); + if (probe.failureKind) { + return { + status: "catalog-unavailable", + reason: probe.errorMessage?.trim() || probe.failureKind, + }; + } + return resolveCursorSdkModelSelectionFromCache(args); +} + +/** + * Verify a model selection for a cloud CREATE, or refuse the launch. + * + * The single owner of the fail-closed rule that both cloud create paths obey. + * Cursor Cloud silently substitutes its own default variant when `params` are + * omitted, so a create that cannot express the user's chosen controls must fail + * with the cause named rather than quietly run a different model. + * + * Returns null when the user chose neither control. A null or absent `fastMode` + * is no tier opinion at all, not a request for the standard tier: the composer + * always sends a boolean, so an absent one means the caller never asked. + * + * Callers supply their own fallback for the null case, because they differ: + * a cloud launch sends no params, and a chat send falls back to its session's + * best-effort params. + * + * @throws the sentence from `describeCursorSdkModelSelectionFailure`. + */ +export async function verifyExplicitCursorModelSelection( + apiKey: string | null | undefined, + args: CursorSdkModelSelectionInput, +): Promise { + const hasExplicitSelection = args.reasoningEffort != null || args.fastMode != null; + if (!hasExplicitSelection) return null; + const selection = await resolveCursorSdkModelSelection(apiKey, args); + if (selection.status !== "ok") { + throw new Error(describeCursorSdkModelSelectionFailure(args.modelSdkId, selection)); + } + return selection.params; } /** diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index 2421ca1620..0b1f1af5c0 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -7,6 +7,7 @@ import { buildCursorSdkLocalRunOptions, cursorProjectSlugForPath, cursorSdkLocalAgentMode, + CURSOR_SDK_ONESHOT_POLICY, CURSOR_SDK_READONLY_TOOLS, denyCursorHook, evaluateCursorSdkHook, @@ -16,6 +17,13 @@ import { import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; describe("Cursor SDK policy", () => { + it("runs every one-shot under the read-only ask policy", () => { + // A one-shot is a tool-less text task, so its policy is fixed rather than + // derived from a caller's permission mode. + expect(CURSOR_SDK_ONESHOT_POLICY).toEqual(resolveCursorSdkPolicy({ cursorModeId: "ask" })); + expect(CURSOR_SDK_ONESHOT_POLICY.fullAuto).toBe(false); + }); + it("maps Cursor modes to ADE permission policies", () => { expect(resolveCursorSdkPolicy({ cursorModeId: "ask" })).toMatchObject({ chatMode: "ask", diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts index d4c16a0dde..71e8810290 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts @@ -188,6 +188,23 @@ export function resolveCursorSdkPolicy(session: CursorSessionModeInput): CursorS }; } +/** + * The one policy every ADE one-shot Cursor prompt runs under. + * + * A one-shot is a tool-less text task — a title, a lane name, a status line, a + * summary, a commit message, a pull request description — and + * `runCursorSdkLocalPrompt` denies every tool call it makes. Deriving a policy + * from the caller's permission mode therefore decided nothing except the SDK + * chat mode, and it told a `full-auto` caller's model it had tools that the + * bridge then refused. One fixed read-only policy states what actually happens. + * + * It is constant on purpose: the warm one-shot worker is shared across + * features, and a pooled worker keeps the policy it was created with. + */ +export const CURSOR_SDK_ONESHOT_POLICY: CursorSdkPermissionPolicy = Object.freeze( + resolveCursorSdkPolicy({ cursorModeId: "ask" }), +); + /** * Ambient Cursor setting layers an agent may load (`local.settingSources`). * diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts index 4c42bddb4a..7bb3ef8c39 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts @@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { acquireCursorSdkConnection, buildCursorSdkPaths, + CURSOR_SDK_LOCAL_ONESHOT_MAX_WORKERS, + CURSOR_SDK_ONESHOT_AGENT_NAME, buildCursorSdkWorkerEnv, cleanupCursorSdkRuntimePaths, CURSOR_SDK_REPLACE_WAIT_MS, @@ -14,9 +16,11 @@ import { MAX_CURSOR_SDK_SOCKET_PATH_BYTES, poisonCursorSdkConnection, releaseCursorSdkConnection, + runCursorSdkLocalPrompt, releaseCursorSdkConnectionAfterIdle, resolveCursorSdkUserHome, } from "./cursorSdkPool"; +import { CURSOR_SDK_ONESHOT_POLICY } from "./cursorSdkPolicy"; import { buildPackagedRuntimeNodeModulePaths } from "../runtime/packagedNodePath"; const forkMock = vi.hoisted(() => vi.fn()); @@ -240,6 +244,113 @@ class FailingSendChild extends FakeSdkChild { } } +/** Answers `send` with a terminal run result instead of an empty object. */ +class OneShotSdkChild extends FakeSdkChild { + constructor(private readonly runResult: unknown = { status: "finished", result: " named it " }) { + super(); + } + + override send(message: { type?: string; requestId?: string; payload?: unknown }): boolean { + if (message.type === "send" && message.requestId) { + this.sent.push(message); + const requestId = message.requestId; + queueMicrotask(() => { + this.emit("message", { type: "response", requestId, ok: true, result: this.runResult }); + }); + return true; + } + return super.send(message); + } +} + +/** Never answers `send`, so the one-shot deadline is the only way out. */ +class StalledSendChild extends FakeSdkChild { + cancelCount = 0; + + override send(message: { type?: string; requestId?: string }): boolean { + if (message.type === "send") { + this.sent.push(message); + return true; + } + if (message.type === "cancel" && message.requestId) { + this.cancelCount += 1; + const requestId = message.requestId; + queueMicrotask(() => { + this.emit("message", { type: "response", requestId, ok: true, result: null }); + }); + return true; + } + return super.send(message); + } +} + +/** Reports how many `send` requests were in flight at the same moment. */ +class OverlapCountingChild extends FakeSdkChild { + inFlight = 0; + maxInFlight = 0; + + override send(message: { type?: string; requestId?: string }): boolean { + if (message.type === "send" && message.requestId) { + this.sent.push(message); + this.inFlight += 1; + this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); + const requestId = message.requestId; + setTimeout(() => { + this.inFlight -= 1; + this.emit("message", { + type: "response", + requestId, + ok: true, + result: { status: "finished", result: "done" }, + }); + }, 20).unref?.(); + return true; + } + return super.send(message); + } +} + +function sentMessagesOfType( + child: FakeSdkChild, + type: string, +): Array<{ type?: string; payload?: Record }> { + return child.sent.filter((message): message is { type?: string; payload?: Record } => ( + Boolean(message && typeof message === "object" && (message as { type?: string }).type === type) + )); +} + +/** Answers `send` with a rejection, the way a worker reports its own fault. */ +class RejectingSendChild extends FakeSdkChild { + override send(message: { type?: string; requestId?: string }): boolean { + if (message.type === "send" && message.requestId) { + this.sent.push(message); + const requestId = message.requestId; + queueMicrotask(() => { + this.emit("message", { + type: "response", + requestId, + ok: false, + error: "Cursor SDK worker is not initialized.", + }); + }); + return true; + } + return super.send(message); + } +} + +function oneShotArgs(workspacePath: string) { + return { + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath, + apiKey: "cursor-test-key", + modelSdkId: "grok-4.6", + promptText: "Name this chat.", + feature: "session_title", + timeoutMs: 5_000, + }; +} + afterEach(() => { forkMock.mockReset(); for (const dir of tempDirs.splice(0)) { @@ -977,6 +1088,178 @@ describe("Cursor SDK pool paths", () => { releaseCursorSdkConnection(poolKey, third.generation); }); + + it("runs a one-shot local prompt on a pooled worker and starts a fresh conversation", async () => { + const child = new OneShotSdkChild(); + forkMock.mockReturnValue(child); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-${Date.now()}-${Math.random()}`); + + const result = await runCursorSdkLocalPrompt(oneShotArgs(workspacePath)); + + expect(result.text).toBe("named it"); + expect(result.agentId).toBe("agent-1"); + const init = sentMessagesOfType(child, "init")[0]?.payload; + expect(init).toMatchObject({ + modelSdkId: "grok-4.6", + apiKey: "cursor-test-key", + sessionId: "oneshot:session_title", + laneRoot: workspacePath, + // Fixed, both of them: the warm worker is shared across features and + // keeps the policy and the name it was created with. + agentName: CURSOR_SDK_ONESHOT_AGENT_NAME, + policy: CURSOR_SDK_ONESHOT_POLICY, + }); + const send = sentMessagesOfType(child, "send")[0]?.payload; + expect(send).toMatchObject({ + promptText: "Name this chat.", + modelSdkId: "grok-4.6", + resetConversation: true, + }); + }); + + it("keeps the one-shot worker warm across back-to-back prompts", async () => { + const child = new OneShotSdkChild(); + forkMock.mockReturnValue(child); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-warm-${Date.now()}-${Math.random()}`); + + await runCursorSdkLocalPrompt(oneShotArgs(workspacePath)); + await runCursorSdkLocalPrompt({ ...oneShotArgs(workspacePath), modelSdkId: "composer-2" }); + + expect(forkMock).toHaveBeenCalledTimes(1); + const sends = sentMessagesOfType(child, "send"); + expect(sends).toHaveLength(2); + // The worker applies a per-send model, so a second candidate model does not + // need a second worker. + expect(sends[1]?.payload?.modelSdkId).toBe("composer-2"); + expect(sends[1]?.payload?.resetConversation).toBe(true); + }); + + it("serializes concurrent one-shot prompts on the same workspace", async () => { + const child = new OverlapCountingChild(); + forkMock.mockReturnValue(child); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-race-${Date.now()}-${Math.random()}`); + + await Promise.all([ + runCursorSdkLocalPrompt(oneShotArgs(workspacePath)), + runCursorSdkLocalPrompt(oneShotArgs(workspacePath)), + runCursorSdkLocalPrompt(oneShotArgs(workspacePath)), + ]); + + expect(child.maxInFlight).toBe(1); + expect(sentMessagesOfType(child, "send")).toHaveLength(3); + expect(forkMock).toHaveBeenCalledTimes(1); + }); + + it("maps an errored one-shot run onto a thrown error", async () => { + forkMock.mockReturnValue(new OneShotSdkChild({ status: "error", result: "Cursor is out of credits." })); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-error-${Date.now()}-${Math.random()}`); + + await expect(runCursorSdkLocalPrompt(oneShotArgs(workspacePath))) + .rejects.toThrow("Cursor is out of credits."); + }); + + it("maps a cancelled one-shot run onto a thrown error", async () => { + forkMock.mockReturnValue(new OneShotSdkChild({ status: "cancelled", result: "" })); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-cancelled-${Date.now()}-${Math.random()}`); + + await expect(runCursorSdkLocalPrompt(oneShotArgs(workspacePath))) + .rejects.toThrow("Cursor SDK task was cancelled."); + }); + + it("cancels and discards the worker when a one-shot prompt times out", async () => { + const stalled = new StalledSendChild(); + const replacement = new OneShotSdkChild(); + forkMock.mockReturnValueOnce(stalled).mockReturnValueOnce(replacement); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-timeout-${Date.now()}-${Math.random()}`); + + await expect(runCursorSdkLocalPrompt({ ...oneShotArgs(workspacePath), timeoutMs: 20 })) + .rejects.toThrow("Cursor SDK task timed out after 20ms."); + expect(stalled.cancelCount).toBe(1); + + // A worker that missed its deadline is still streaming: the next one-shot + // must not inherit it. + const result = await runCursorSdkLocalPrompt(oneShotArgs(workspacePath)); + expect(result.text).toBe("named it"); + expect(forkMock).toHaveBeenCalledTimes(2); + }); + + it("discards the worker when a one-shot send rejects", async () => { + const broken = new RejectingSendChild(); + const replacement = new OneShotSdkChild(); + forkMock.mockReturnValueOnce(broken).mockReturnValueOnce(replacement); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-reject-${Date.now()}-${Math.random()}`); + + await expect(runCursorSdkLocalPrompt(oneShotArgs(workspacePath))) + .rejects.toThrow("Cursor SDK worker is not initialized."); + expect(broken.disposeCount).toBe(1); + + // A worker whose send rejected reported a fault of its own, and its process + // stays alive through all of them: the pool's liveness check would keep + // handing the same broken worker out. + const result = await runCursorSdkLocalPrompt(oneShotArgs(workspacePath)); + expect(result.text).toBe("named it"); + expect(forkMock).toHaveBeenCalledTimes(2); + }); + + it("reports a terminal one-shot error from the run's error detail", async () => { + forkMock.mockReturnValue(new OneShotSdkChild({ + status: "error", + result: "Here is the partial answer", + error: { message: "Cursor stream failed: NGHTTP2_ENHANCE_YOUR_CALM" }, + })); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-detail-${Date.now()}-${Math.random()}`); + + await expect(runCursorSdkLocalPrompt(oneShotArgs(workspacePath))) + .rejects.toThrow("Cursor stream failed: NGHTTP2_ENHANCE_YOUR_CALM"); + }); + + it("shares one warm worker across two spellings of the same workspace path", async () => { + const child = new OneShotSdkChild(); + forkMock.mockReturnValue(child); + const workspacePath = path.join(os.tmpdir(), `ADE-Oneshot-Case-${Date.now()}`); + + await runCursorSdkLocalPrompt(oneShotArgs(workspacePath)); + await runCursorSdkLocalPrompt(oneShotArgs(workspacePath.toLowerCase())); + + // Only where the filesystem itself folds case; Linux is case-sensitive and + // two spellings really are two workspaces there. + const expectedWorkers = process.platform === "linux" ? 2 : 1; + expect(forkMock).toHaveBeenCalledTimes(expectedWorkers); + }); + + it("forks a fresh worker when the Cursor API key rotates", async () => { + const first = new OneShotSdkChild(); + const second = new OneShotSdkChild(); + forkMock.mockReturnValueOnce(first).mockReturnValueOnce(second); + const workspacePath = path.join(os.tmpdir(), `ade-oneshot-key-${Date.now()}-${Math.random()}`); + + await runCursorSdkLocalPrompt(oneShotArgs(workspacePath)); + await runCursorSdkLocalPrompt({ ...oneShotArgs(workspacePath), apiKey: "cursor-rotated-key" }); + + expect(forkMock).toHaveBeenCalledTimes(2); + expect(sentMessagesOfType(second, "init")[0]?.payload?.apiKey).toBe("cursor-rotated-key"); + }); + + it("caps the warm one-shot workers and releases the least recently used idle one", async () => { + const children = [new OneShotSdkChild(), new OneShotSdkChild(), new OneShotSdkChild()]; + forkMock.mockImplementation(() => children.shift() ?? new OneShotSdkChild()); + const [oldest, middle] = [children[0]!, children[1]!]; + const stamp = `${Date.now()}-${Math.random()}`; + const workspaces = [0, 1, 2].map((index) => path.join(os.tmpdir(), `ade-oneshot-lru-${stamp}-${index}`)); + + expect(CURSOR_SDK_LOCAL_ONESHOT_MAX_WORKERS).toBe(2); + await runCursorSdkLocalPrompt(oneShotArgs(workspaces[0]!)); + await runCursorSdkLocalPrompt(oneShotArgs(workspaces[1]!)); + expect(oldest.disposeCount).toBe(0); + + // The third distinct workspace is over the cap, so the idle worker that ran + // longest ago is released rather than kept warm alongside the other two. + await runCursorSdkLocalPrompt(oneShotArgs(workspaces[2]!)); + expect(forkMock).toHaveBeenCalledTimes(3); + expect(oldest.disposeCount).toBe(1); + expect(middle.disposeCount).toBe(0); + }); + it("preserves structured Cursor SDK worker error metadata on rejected requests", async () => { const child = new FailingSendChild(); forkMock.mockReturnValue(child); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index f92d732ce4..84843ddede 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -6,6 +6,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; import { buildPackagedRuntimeNodeModulePaths } from "../runtime/packagedNodePath"; +import { pathKey } from "../shared/pathCompare"; +import { CURSOR_SDK_ONESHOT_POLICY } from "./cursorSdkPolicy"; import { terminateChildProcessTree } from "../shared/utils"; import type { CursorSdkCloudArtifactDescriptor, @@ -1028,6 +1030,9 @@ function clearCursorSdkIdleTimer(entry: CursorSdkPoolEntry): void { function disposeCursorSdkPoolEntry(poolKey: string, entry: CursorSdkPoolEntry): void { clearCursorSdkIdleTimer(entry); pools.delete(poolKey); + // The one-shot LRU stamp lives exactly as long as the entry it ranks. A + // delete of an absent key is free, so this needs no prefix guard. + localOneShotLastUsedAt.delete(poolKey); trackDepartingCursorSdkWorker(poolKey, entry.pooled.waitForExit()); entry.pooled.dispose(); cleanupCursorSdkRuntimePaths(entry); @@ -1158,7 +1163,8 @@ type CursorSdkCloudOneShotType = Extract< } >["type"]; -export const CURSOR_SDK_CLOUD_ONESHOT_IDLE_MS = 60_000; +/** How long a one-shot worker (cloud request or local prompt) stays warm. */ +export const CURSOR_SDK_ONESHOT_IDLE_MS = 60_000; export async function runCursorSdkCloudRequest( args: { @@ -1196,6 +1202,242 @@ export async function runCursorSdkCloudRequest( try { return await pooled.request(args.type, { apiKey: args.apiKey ?? null, ...args.payload }); } finally { - releaseCursorSdkConnectionAfterIdle(poolKey, generation, CURSOR_SDK_CLOUD_ONESHOT_IDLE_MS); + releaseCursorSdkConnectionAfterIdle(poolKey, generation, CURSOR_SDK_ONESHOT_IDLE_MS); } } + +/** + * The SDK agent name every local one-shot worker is created with. + * + * Fixed rather than per-feature: one warm worker serves every feature that runs + * a one-shot on a workspace, and a pooled worker keeps the name it was created + * with. + */ +export const CURSOR_SDK_ONESHOT_AGENT_NAME = "ADE one-shot"; + +/** Pool-key prefix for the warm workers that run ADE's one-off local prompts. */ +const CURSOR_SDK_LOCAL_ONESHOT_PREFIX = "local-oneshot:"; + +/** + * How many warm one-shot workers this process keeps at once. + * + * Each one is a forked Node process holding a Cursor SDK agent, kept alive for + * `CURSOR_SDK_ONESHOT_IDLE_MS` after its last prompt. One worker per lane + * worktree with no cap meant ten active lanes naming their chats forked ten + * processes at once, so the set is bounded and the idle least-recently-used + * worker is released to make room. A busy worker is never evicted. + */ +export const CURSOR_SDK_LOCAL_ONESHOT_MAX_WORKERS = 2; + +/** Last acquire/release time per one-shot pool key, for the LRU choice above. */ +const localOneShotLastUsedAt = new Map(); + +/** + * The pool key for a local one-shot worker. + * + * `pathKey` folds the case of the workspace path, so two spellings of one + * Windows worktree share a worker instead of forking two. The API key is + * hashed into the key because a warm worker keeps the key it was created with: + * a rotated key has to fork a fresh worker rather than keep authenticating + * with the old one. + */ +function cursorSdkLocalOneShotPoolKey(workspacePath: string, apiKey?: string | null): string { + return `${CURSOR_SDK_LOCAL_ONESHOT_PREFIX}${pathKey(workspacePath)}:${hashKey(apiKey?.trim() || "").slice(0, 8)}`; +} + +function touchLocalOneShotPoolKey(poolKey: string): void { + if (!poolKey.startsWith(CURSOR_SDK_LOCAL_ONESHOT_PREFIX)) return; + localOneShotLastUsedAt.set(poolKey, Date.now()); +} + +/** + * Release idle one-shot workers until `poolKey` can be added under the cap. + * + * Best-effort, like every other budget in the app: when every other worker is + * busy this yields rather than tearing down a live run. + */ +function enforceLocalOneShotWorkerBudget(poolKey: string): void { + if (pools.has(poolKey)) return; + for (;;) { + const candidates = [...pools.entries()].filter(([key]) => ( + key !== poolKey && key.startsWith(CURSOR_SDK_LOCAL_ONESHOT_PREFIX) + )); + if (candidates.length < CURSOR_SDK_LOCAL_ONESHOT_MAX_WORKERS) return; + let lruKey: string | null = null; + let lruEntry: CursorSdkPoolEntry | null = null; + let lruAt = Number.POSITIVE_INFINITY; + for (const [key, entry] of candidates) { + if (entry.ref > 0) continue; + const at = localOneShotLastUsedAt.get(key) ?? 0; + if (at >= lruAt) continue; + lruKey = key; + lruEntry = entry; + lruAt = at; + } + if (!lruKey || !lruEntry) return; + disposeCursorSdkPoolEntry(lruKey, lruEntry); + } +} + +/** + * Serializes the one-shot local prompts that share a pool key. + * + * A worker holds exactly one `currentRun`, and a `resetConversation` send + * replaces the agent underneath it, so two overlapping one-shots on the same + * workspace would cancel and mis-attribute each other's run. Chat never queues + * here: its pool keys carry the session id and it owns its worker outright. + */ +const cursorSdkLocalPromptQueues = new Map>(); + +function runCursorSdkLocalPromptQueued(poolKey: string, run: () => Promise): Promise { + const prior = cursorSdkLocalPromptQueues.get(poolKey) ?? Promise.resolve(); + const next = prior.then(run, run); + const tracked: Promise = next.catch(() => undefined).finally(() => { + if (cursorSdkLocalPromptQueues.get(poolKey) === tracked) { + cursorSdkLocalPromptQueues.delete(poolKey); + } + }); + cursorSdkLocalPromptQueues.set(poolKey, tracked); + return next; +} + +export type CursorSdkLocalPromptResult = { + text: string; + agentId: string | null; +}; + +function readCursorSdkRunStatus( + result: unknown, +): { status: string; text: string; errorMessage: string } { + const record = result && typeof result === "object" ? result as Record : {}; + const status = typeof record.status === "string" ? record.status : ""; + const text = typeof record.result === "string" ? record.result.trim() : ""; + // `RunResult.error` carries the terminal failure detail while `result` keeps + // whatever partial text the model produced, so an error must be reported from + // `error.message` rather than from the partial answer. + const errorRecord = record.error && typeof record.error === "object" + ? record.error as Record + : null; + const errorMessage = typeof errorRecord?.message === "string" ? errorRecord.message.trim() : ""; + return { status, text, errorMessage }; +} + +/** + * Run one self-contained local prompt on a pooled Cursor SDK worker. + * + * This is the only Cursor path for ADE's one-off model calls — titles, status + * lines, lane names, summaries, commit messages, PR descriptions. It exists so + * those calls get what chat already gets: a forked worker rather than the SDK + * inside the host process, the sandbox-unsupported fallback, agent retries, + * trimmed setting sources, a throwaway state root, and an agent that is closed + * instead of leaked. + * + * The worker stays warm for a short idle window, so a naming chain of three + * candidate models forks Node once. Each prompt still starts a fresh agent, so + * no one-shot ever sees another one-shot's conversation. + * + * Every one-shot runs under `CURSOR_SDK_ONESHOT_POLICY` and answers as + * `CURSOR_SDK_ONESHOT_AGENT_NAME`. Both are fixed because the worker is shared: + * a pooled worker keeps the policy and the name it was created with, so neither + * can be a per-call argument. + */ +export async function runCursorSdkLocalPrompt(args: { + projectRoot: string; + workspacePath: string; + apiKey?: string | null; + modelSdkId: string; + modelParams?: CursorSdkModelParameterValue[]; + promptText: string; + feature: string; + timeoutMs: number; + logger?: Logger; +}): Promise { + // One worker per workspace and API key. The model rides on the send rather + // than the pool key, because the worker applies + // `CursorSdkSendPrompt.modelSdkId` to the run it starts — the same mechanism + // a chat model switch uses. + const poolKey = cursorSdkLocalOneShotPoolKey(args.workspacePath, args.apiKey); + return await runCursorSdkLocalPromptQueued(poolKey, async () => { + enforceLocalOneShotWorkerBudget(poolKey); + const { pooled, generation } = await acquireCursorSdkConnection({ + poolKey, + projectRoot: args.projectRoot, + workspacePath: args.workspacePath, + modelSdkId: args.modelSdkId, + ...(args.modelParams?.length ? { modelParams: args.modelParams } : {}), + apiKey: args.apiKey, + // A fixed name, because the warm worker is shared by every feature that + // runs a one-shot on this workspace. + agentName: CURSOR_SDK_ONESHOT_AGENT_NAME, + sessionId: `oneshot:${args.feature}`, + cleanupStateRoot: true, + policy: CURSOR_SDK_ONESHOT_POLICY, + logger: args.logger, + }); + // A one-shot answers from its prompt: ADE hands the model every excerpt it + // needs. Deny tool calls with a reason the model can act on, rather than + // leaving the bridge unset and returning the pool's "ADE is not ready" + // default, which reads as a transient fault the model may retry. + pooled.bridge.onHookRequest = async () => ({ + permission: "deny" as const, + user_message: "ADE one-shot tasks do not run tools.", + agent_message: "Tools are unavailable for this task. Answer from the prompt text alone.", + }); + let timeoutHandle: ReturnType | null = null; + /** Set by both failure causes below; the teardown reads it once. */ + let discardWorker = false; + try { + const sendPromise = pooled.sendPrompt({ + promptText: args.promptText, + modelSdkId: args.modelSdkId, + ...(args.modelParams?.length ? { modelParams: args.modelParams } : {}), + resetConversation: true, + }); + // The timeout wins the race and the send keeps running until the cancel + // or the dispose lands. Claim its eventual rejection now, or it surfaces + // as an unhandled rejection after this function has already returned. + sendPromise.catch(() => undefined); + let raw: unknown; + try { + raw = await Promise.race([ + sendPromise, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + discardWorker = true; + pooled.cancel().catch(() => {}); + reject(new Error(`Cursor SDK task timed out after ${args.timeoutMs}ms.`)); + }, args.timeoutMs); + }), + ]); + } catch (error) { + // The send itself rejected rather than returning a run result. That is + // the worker reporting its own fault — a failed agent reset, a closed + // IPC channel — and the process stays alive through all of them, so the + // pool's liveness check would keep handing the same broken worker out. + discardWorker = true; + throw error; + } + const { status, text, errorMessage } = readCursorSdkRunStatus(raw); + if (status === "error") { + throw new Error(errorMessage || text || "Cursor SDK task failed."); + } + if (status === "cancelled") { + throw new Error("Cursor SDK task was cancelled."); + } + return { text, agentId: pooled.agentId }; + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + pooled.bridge.onHookRequest = null; + if (discardWorker) { + // Timed out: the run is still streaming inside a worker that already + // missed its deadline, and reusing it would hand the next one-shot a + // worker mid-cancel. Rejected: the worker reported a fault of its own. + // Either way the next one-shot has to fork a fresh worker. + poisonCursorSdkConnection(poolKey, generation); + } else { + touchLocalOneShotPoolKey(poolKey); + releaseCursorSdkConnectionAfterIdle(poolKey, generation, CURSOR_SDK_ONESHOT_IDLE_MS); + } + } + }); +} diff --git a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts index 9ed71a9747..77f841e2c5 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts @@ -132,6 +132,17 @@ export type CursorSdkSendPrompt = { forceExpireActiveRun?: boolean; idempotencyKey?: string | null; mode?: CursorSdkAgentMode; + /** + * Start this prompt on a brand-new local agent instead of continuing the + * worker's current conversation. + * + * Chat never sets this: a chat turn is a follow-up by definition. One-shot + * callers (titles, status lines, commit messages, PR descriptions) share one + * warm worker per workspace, so without a reset every later one-shot would + * carry the previous one-shot's prompt and answer in its context. The worker + * closes the previous agent before it creates the replacement. + */ + resetConversation?: boolean; }; export type CursorSdkCloudRepoOverride = { @@ -148,7 +159,6 @@ export type CursorSdkCloudSendStreamPayload = { modelParams?: CursorSdkModelParameterValue[]; idempotencyKey?: string | null; mode?: CursorSdkAgentMode; - agentName?: string | null; repoUrl: string; startingRef?: string | null; prUrl?: string | null; diff --git a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts index 836a8ad89c..c94dd9db43 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts @@ -81,6 +81,14 @@ const reportedRequests = new Set(); let unhandledExitScheduled = false; let sandboxSupported = true; let lastLocalPermissionFingerprint: string | null = null; +/** + * Whether the current local agent has already been given a prompt. + * + * A `resetConversation` send only has to replace the agent when there is a + * conversation to leave behind. The agent `init` just created is already empty, + * so the first one-shot on a fresh worker skips the close/create round trip. + */ +let localAgentPrompted = false; function asCursorSdkRunStoreLike(store: unknown): CursorSdkRunStoreLike | null { return store && typeof store === "object" ? store as CursorSdkRunStoreLike : null; @@ -259,6 +267,48 @@ function buildLocalAgentOptions(init: CursorSdkWorkerInit): AgentOptionsWithAdeM }; } +/** + * Release an SDK agent, whichever teardown method this SDK build exposes. + * + * `agent?.[Symbol.asyncDispose]?.()` evaluates to `undefined` when the method + * is absent instead of throwing, so a catch-fallback can never reach `close()`. + * Test for the method instead. + */ +async function closeSdkAgent(target: SdkAgent | null): Promise { + if (!target) return; + try { + if (typeof (target as { [Symbol.asyncDispose]?: unknown })[Symbol.asyncDispose] === "function") { + await target[Symbol.asyncDispose]!(); + return; + } + target.close?.(); + } catch { + // The replacement matters more than an orderly close of the old agent. + } +} + +/** + * Close the current local agent and create an empty replacement. + * + * `createOrResumeLocalAgent` resumes whatever id it can find, so both the live + * agent and the init's resume id have to be cleared first, or the "new" agent + * is the old thread again. The permission fingerprint is cleared with them so + * `applyLocalAgentOptions` does not short-circuit on an unchanged policy. + * + * `localAgentPrompted` is the fourth piece of that same state, so it is cleared + * here rather than by the caller: a second caller would otherwise leave the + * flag set and the next `resetConversation` send would reset a fresh agent. + */ +async function resetLocalAgent(): Promise { + const previous = agent; + agent = null; + localAgentPrompted = false; + lastLocalPermissionFingerprint = null; + if (initState) initState.agentId = null; + await closeSdkAgent(previous); + await applyLocalAgentOptions(); +} + async function createOrResumeLocalAgent(options: AgentOptionsWithAdeMode): Promise { if (!localAgentPlatform || !initState) { throw new Error("Cursor SDK worker is not initialized."); @@ -535,10 +585,21 @@ async function cursorSdkSendMessage( } async function sendPrompt(payload: CursorSdkSendPrompt): Promise { - if (!agent || !initState) throw new Error("Cursor SDK worker is not initialized."); + if (!initState) throw new Error("Cursor SDK worker is not initialized."); + if (payload.resetConversation && localAgentPrompted) { + await resetLocalAgent(); + } + // `applyLocalAgentOptions` already means "create the agent if it is missing, + // otherwise reuse it", so this one call also rebuilds an agent that a failed + // reset left null on an otherwise healthy worker. Its failure is only fatal + // when no agent survives it, and then the caller gets the real cause: a + // generic "not initialized" is a fault no pool eviction rule can act on, and + // it is what wedged the warm one-shot worker. + let applyError: unknown = null; try { await applyLocalAgentOptions(); } catch (error) { + applyError = error; post({ type: "log", level: "warn", @@ -546,7 +607,11 @@ async function sendPrompt(payload: CursorSdkSendPrompt): Promise { detail: { error: errorMessage(error) }, }); } - if (!agent) throw new Error("Cursor SDK worker is not initialized."); + if (!agent) { + throw applyError instanceof Error + ? applyError + : new Error("Cursor SDK worker is not initialized."); + } const message = await cursorSdkSendMessage(payload.promptText, payload.images); const mode = payload.mode ?? cursorSdkLocalAgentMode(initState.policy); const idempotencyKey = trimIdempotencyKey(payload.idempotencyKey); @@ -561,6 +626,7 @@ async function sendPrompt(payload: CursorSdkSendPrompt): Promise { local: { force: payload.forceExpireActiveRun === true }, }; currentRun = await agent.send(message, sendOptions); + localAgentPrompted = true; const runModelParams = normalizeCursorModelParams(payload.modelParams ?? initState.modelParams); const sdkRequestId = cursorRunRequestId(currentRun); post({ @@ -717,20 +783,13 @@ async function dispose(): Promise { resolve(denyCursorHook("Cursor SDK worker disposed before tool approval completed.")); } hookWaiters.clear(); - try { - await agent?.[Symbol.asyncDispose]?.(); - } catch { - try { - agent?.close(); - } catch { - // ignore - } - } + await closeSdkAgent(agent); agent = null; localAgentPlatform = null; localAgentStore = null; sandboxSupported = true; lastLocalPermissionFingerprint = null; + localAgentPrompted = false; if (hookServer) { await new Promise((resolve) => hookServer!.close(() => resolve())).catch(() => {}); hookServer = null; @@ -772,7 +831,6 @@ function buildCloudCreateOptions(payload: CursorSdkCloudSendStreamPayload): Agen const options: AgentOptionsWithAdeMode = { apiKey: payload.apiKey?.trim() || undefined, - name: payload.agentName?.trim() || undefined, cloud, }; const idempotencyKey = trimIdempotencyKey(payload.idempotencyKey); diff --git a/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts b/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts index 1dca053854..d30b1b81fd 100644 --- a/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts +++ b/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts @@ -169,6 +169,38 @@ describe("createSessionMetadataRegenerator", () => { expect(renameLane).toHaveBeenCalled(); }); + it("reports the model failure that forced a deterministic name", async () => { + const { regenerate } = createHarness({ + summary: "Wired project aiSummary into RAG excerpts so Cmd+K answers from the overview", + runPrompt: vi.fn(async () => { + throw new Error("Local SDK sandboxing was requested, but sandboxing is not supported in this environment."); + }), + }); + + const result = await regenerate({ sessionId: "sess-1" }); + expect(result.usedDeterministicFallback).toBe(true); + expect(result.generationError).toContain("sandboxing is not supported"); + }); + + it("reports no generation error when a model answered", async () => { + const { regenerate } = createHarness(); + + const result = await regenerate({ sessionId: "sess-1" }); + expect(result.generationError).toBeNull(); + expect(result.usedDeterministicFallback).toBe(false); + }); + + it("reports the deterministic fallback with no error when no model was available", async () => { + const { regenerate } = createHarness({ + summary: "Wired project aiSummary into RAG excerpts so Cmd+K answers from the overview", + resolveModelCandidates: async () => [], + }); + + const result = await regenerate({ sessionId: "sess-1" }); + expect(result.usedDeterministicFallback).toBe(true); + expect(result.generationError).toBeNull(); + }); + it("sends the full thread, latest assistant paragraphs, lane threads, and git work in one call", async () => { const { regenerate, runPrompt } = createHarness({ conversation: [ diff --git a/apps/desktop/src/main/services/chat/sessionMetadataService.ts b/apps/desktop/src/main/services/chat/sessionMetadataService.ts index 5c15bbe8c5..ccb62fb653 100644 --- a/apps/desktop/src/main/services/chat/sessionMetadataService.ts +++ b/apps/desktop/src/main/services/chat/sessionMetadataService.ts @@ -123,6 +123,19 @@ export function createSessionMetadataRegenerator ({ + sessionId, + applied, + skipped, + modelId: selectedModelId, + generationError, + usedDeterministicFallback, + }); const notifyOutcome = (outcome: "completed" | "partial" | "failed"): void => { try { @@ -179,11 +192,12 @@ export function createSessionMetadataRegenerator; - selectedModelId: string | null; - attemptCount: number; - } = { result: null, selectedModelId: null, attemptCount: 0 }; + let generated: Awaited> = { + result: null, + selectedModelId: null, + attemptCount: 0, + lastFailure: null, + }; if (candidateModelIds.length) { const prompt = buildSessionMetadataPrompt({ provider: managed.session.provider, @@ -225,6 +239,7 @@ export function createSessionMetadataRegenerator { expect(onFailure).not.toHaveBeenCalled(); }); + it("reports the last attempt failure so the caller can name the real cause", async () => { + const candidates = buildNamingModelCandidates({ + availableModels: ALL_MODELS, + preferred: [OPENAI_MODELS[0]?.id, ANTHROPIC_MODELS[0]?.id], + }); + + const { result, lastFailure } = await runNamingAcrossProviders(candidates, { + run: async (descriptor) => { + throw new Error(`no route for ${descriptor.id}`); + }, + onFailure: vi.fn(), + }); + + expect(result).toBeNull(); + expect(lastFailure).toEqual({ + modelId: candidates[1], + error: `no route for ${candidates[1]}`, + }); + }); + + it("clears the last failure once a later candidate answers", async () => { + let attempts = 0; + const { result, lastFailure } = await runNamingAcrossProviders( + buildNamingModelCandidates({ + availableModels: ALL_MODELS, + preferred: [OPENAI_MODELS[0]?.id, ANTHROPIC_MODELS[0]?.id], + }), + { + run: async () => { + attempts += 1; + if (attempts === 1) throw new Error("transient"); + return "Second Model Wins"; + }, + onFailure: vi.fn(), + }, + ); + + expect(result).toBe("Second Model Wins"); + expect(lastFailure).toBeNull(); + }); + it("gives up after three attempts instead of walking the whole registry", async () => { const attempted: string[] = []; const { result, attemptCount } = await runNamingAcrossProviders( diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts index 54181b3d8c..0b6c1f9e8f 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -415,7 +415,7 @@ export async function runSessionMetadataGeneration(args: { normalizeStatusLine: (value: string) => string | null; shouldStop?: () => boolean; onFailure: (failure: NamingAttemptFailure) => void; -}): Promise<{ result: GeneratedSessionMetadata | null; attemptCount: number; selectedModelId: string | null }> { +}): Promise> { // Walk the caller's setting-then-session candidates only. Cursor Grok (and // other non-schema models) often return unusable JSON; the next candidate // still gets a turn. ADE already holds the transcript excerpt. @@ -527,6 +527,24 @@ export type NamingAttemptFailure = { error: unknown; }; +/** + * The last attempt that threw, in a shape a caller can show to the user. + * + * Naming swallows every attempt error and falls back to a deterministic slug, + * so without this the UI could only say "nothing changed" and had to guess why. + */ +export type NamingLastFailure = { + modelId: string; + error: string; +}; + +export type NamingRunOutcome = { + result: T | null; + attemptCount: number; + selectedModelId: string | null; + lastFailure: NamingLastFailure | null; +}; + /** * Walk the candidate chain until one model returns a usable result. A * provider-level failure condemns every remaining model behind that provider. @@ -541,10 +559,11 @@ export async function runNamingAcrossProviders( run: (descriptor: ModelDescriptor) => Promise; onFailure: (failure: NamingAttemptFailure) => void; }, -): Promise<{ result: T | null; attemptCount: number; selectedModelId: string | null }> { +): Promise> { const exhaustedProviders = new Set(); let attemptCount = 0; let selectedModelId: string | null = null; + let lastFailure: NamingLastFailure | null = null; for (const candidateModelId of candidateModelIds) { if (attemptCount >= MAX_NAMING_ATTEMPTS) break; @@ -559,14 +578,18 @@ export async function runNamingAcrossProviders( const result = await options.run(descriptor); if (options.shouldStop?.()) break; if (result !== null) { - return { result, attemptCount, selectedModelId }; + return { result, attemptCount, selectedModelId, lastFailure: null }; } } catch (error) { const providerLevelFailure = isProviderLevelNamingFailure(error); if (providerLevelFailure) exhaustedProviders.add(provider); + lastFailure = { + modelId: descriptor.id, + error: error instanceof Error ? error.message : String(error), + }; options.onFailure({ descriptor, provider, providerLevelFailure, attemptCount, error }); } } - return { result: null, attemptCount, selectedModelId }; + return { result: null, attemptCount, selectedModelId, lastFailure }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d7dcbcb441..883a7886e2 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -100,6 +100,7 @@ import { resolveProjectIconPath, setProjectIconOverrideFromSelection, } from "../projects/projectIconResolver"; +import { assertCursorCloudRenameAllowed } from "../../../shared/cursorCloudNaming"; import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; import { createPromptStash, @@ -5510,9 +5511,10 @@ export function registerIpc({ return await ctx.agentChatService.openCursorCloudChat({ cloudAgentId: arg.cloudAgentId, laneId: arg.laneId, - ...(arg.agentName ? { agentName: arg.agentName } : {}), ...(arg.sessionId ? { sessionId: arg.sessionId } : {}), ...(arg.modelId ? { modelId: arg.modelId } : {}), + ...(arg.reasoningEffort !== undefined ? { reasoningEffort: arg.reasoningEffort } : {}), + ...(arg.fastMode !== undefined ? { fastMode: arg.fastMode } : {}), }); }, ); @@ -7529,6 +7531,12 @@ export function registerIpc({ ipcMain.handle(IPC.sessionsUpdateMeta, async (_event, arg: UpdateSessionMetaArgs): Promise => { const ctx = ensureSessionContext(); + await assertCursorCloudRenameAllowed( + ctx.agentChatService + ? (sessionId) => ctx.agentChatService!.getSessionSummary(sessionId) + : null, + arg, + ); return ctx.sessionService.updateMeta(arg); }); diff --git a/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx index 7df3b351bc..969200a30b 100644 --- a/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx +++ b/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx @@ -213,7 +213,6 @@ export function CursorCloudFleetModal({ const opened = await window.ade.ai.cursorCloudOpenChat({ cloudAgentId: agentId, laneId, - agentName: entry.agent.name, }); if (opened.session) { announceWorkChatSessionCreated(projectRoot ?? "", opened.session); diff --git a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx index d499634f19..59e12a8236 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx @@ -39,7 +39,7 @@ import { type SessionStatusPresentation, } from "../../../shared/sessionStatusPresentation"; import { relativeTimeCompact } from "../../lib/format"; -import { isChatToolType, shortToolTypeLabel } from "../../lib/sessions"; +import { cursorOwnsSessionName, isChatToolType, shortToolTypeLabel } from "../../lib/sessions"; import { providerChatAccent } from "../chat/chatSurfaceTheme"; import { workToolFamily } from "../terminals/workSessionFilters"; import { @@ -670,17 +670,19 @@ export const ThreadResultRow = React.memo(function ThreadResultRow({ > New chat - + {!cursorOwnsSessionName(session) ? ( + + ) : null} {canSettle ? ( - {/* CURSOR-CLOUD-PANEL: the composer's Cursor Cloud glyph and its menu (whose second - entry, "Open existing cloud chat", mounted the right-side cloud panel) are - temporarily disabled; they return in the dedicated cloud-panel PR. Cloud mode is - now chosen in the launch shelf's machine picker, and the Send button carries the - cloud glyph while it is on. */} - {/* Secondary toggles, folded behind one glyph. Each entry is still gated by exactly the condition that used to gate its button, so a control that would not have rendered does not become a row. */} @@ -5896,6 +5979,15 @@ export function AgentChatComposer({ setIssueContextMenuOpen((open) => !open); }, }, + ...(cursorCloudPanelAvailable && onToggleCursorCloudPanel + ? [{ + id: "cursor-cloud-panel", + label: cursorCloudPaneOpen ? "Close Cursor Cloud agents" : "Open Cursor Cloud agents", + icon: , + active: cursorCloudPaneOpen, + onSelect: onToggleCursorCloudPanel, + }] + : []), ...(showOrchestratorModeButton ? [{ id: "orchestrator", @@ -6034,12 +6126,12 @@ export function AgentChatComposer({ ) : ( (() => { - // Switch the Send button to its cloud variant only when the chat is fresh enough - // to actually launch a new cloud run. Once turns exist, the launch path is closed - // and we keep the standard local Send affordance. - const cloudMode = cursorCloudCanLaunch - && cursorCloudModeActive - && !parallelChatMode; + // The Send button wears its cloud variant whenever cloud mode is live for this + // send — a fresh chat with cloud mode on. It stays the cloud variant even when the + // draft's model is not cloud-capable: the button goes disabled and its tooltip says + // why, because a local Send affordance here would mean sending to the wrong runtime. + // Once turns exist the launch path is closed and the standard local Send returns. + const cloudMode = cloudModeActiveForSend; const label = parallelChatMode ? "Send to lanes" : cloudMode diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 4e65b90189..390ce9da79 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -11711,20 +11711,27 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { await screen.findByRole("button", { name: "Send to Cursor Cloud" }); } - function renderCursorCloudDraft(args?: Parameters[0]) { + function renderCursorCloudDraft( + args?: Parameters[0] & { + pinnedModelId?: string; + pinnedReasoningEffort?: string | null; + }, + ) { // Pin the draft to the Cursor chat model the way a returning user's saved launch config does; // cloud mode is only offered for a Cursor model. + const { pinnedModelId, pinnedReasoningEffort, ...paneArgs } = args ?? {}; + const workDraftKind = paneArgs.workDraftKind ?? "chat"; const launchConfigKey = [ "ade.chat.lastLaunchConfig.v1", "/tmp/project-under-test", "lane-1", "standard", - "chat", + workDraftKind, ].map(encodeURIComponent).join(":"); window.localStorage.setItem(launchConfigKey, JSON.stringify({ version: 1, - modelId: CURSOR_MODEL_ID, - reasoningEffort: null, + modelId: pinnedModelId ?? CURSOR_MODEL_ID, + reasoningEffort: pinnedReasoningEffort ?? null, fastMode: false, executionMode: "focused", updatedAt: "2026-05-26T12:00:00.000Z", @@ -11740,7 +11747,7 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { cursorConfigValues: {}, }, })); - return renderAutoCreateDraftPane(args); + return renderAutoCreateDraftPane(paneArgs); } beforeEach(() => { @@ -11762,6 +11769,20 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { expect(await screen.findByRole("button", { name: "Send to Cursor Cloud" })).toBeTruthy(); }); + it("opens the all-agents Cursor Cloud panel from the composer overflow", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { listRepositories } = installCursorCloudMocks(); + renderCursorCloudDraft(); + + await selectCursorCloudMachine(); + fireEvent.click(await screen.findByRole("button", { name: "More composer controls" })); + fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Open Cursor Cloud agents" })); + + expect(await screen.findByText("Cursor Cloud agents")).toBeTruthy(); + expect(listRepositories).toHaveBeenCalled(); + expect(window.ade.ai.cursorCloudListAgents).toHaveBeenCalled(); + }); + it("keeps an existing lane's branch as the cloud agent's starting ref", async () => { installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); const { createRun, openChat } = installCursorCloudMocks(); @@ -11779,11 +11800,30 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { workOnCurrentBranch: true, autoCreatePR: false, })); - // Cursor's own name for the agent becomes the ADE session title. + // The created chat keeps the exact launch settings; Cursor remains the title authority. await waitFor(() => expect(openChat).toHaveBeenCalledWith(expect.objectContaining({ cloudAgentId: "cloud-agent-1", laneId: "lane-1", - agentName: "Tidy the cloud composer", + modelId: "composer-cloud", + reasoningEffort: null, + fastMode: false, + }))); + }); + + it("sends a CLI-kind draft to Cursor Cloud when the machine is switched to cloud", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { createRun } = installCursorCloudMocks(); + renderCursorCloudDraft({ workDraftKind: "cli" }); + + await selectCursorCloudMachine(); + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Run this in Cursor Cloud." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + await waitFor(() => expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ + promptText: "Run this in Cursor Cloud.", + modelId: "composer-cloud", + reasoningEffort: null, + fastMode: false, }))); }); @@ -11822,7 +11862,108 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Start something new." } }); fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); - expect((await screen.findAllByText(/remote: permission denied/)).length).toBeGreaterThan(0); + // git's stderr is rewritten into one plain sentence before it reaches the banner. + expect((await screen.findAllByText(/GitHub refused the push/)).length).toBeGreaterThan(0); + expect(screen.queryByText(/remote: permission denied/)).toBeNull(); + expect(createRun).not.toHaveBeenCalled(); + }); + + it("reads the primary lane's remote for the auto-create row instead of a lane that does not exist yet", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { getOriginRemote } = installCursorCloudMocks(); + renderCursorCloudDraft(); + await selectCursorCloudMachine(); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("option", { name: /Auto-create lane/i })); + + // The readiness probe targets the primary lane; the synthetic auto-create id never + // reaches the brain. Cloud mode survives the lane switch. + await waitFor(() => expect(getOriginRemote).toHaveBeenCalledWith({ laneId: "lane-primary" })); + expect(getOriginRemote.mock.calls.some(([args]) => String((args as { laneId: string }).laneId).includes("__ade_auto_create_lane__"))).toBe(false); + expect(await screen.findByRole("button", { name: "Send to Cursor Cloud" })).toBeTruthy(); + }); + + it("skips the pre-launch push when the lane's branch is only behind origin", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { createRun, push } = installCursorCloudMocks(); + const getSyncStatus = vi.fn().mockResolvedValue({ + hasUpstream: true, + upstreamState: "tracking", + upstreamRef: "origin/current-lane", + ahead: 0, + behind: 3, + diverged: false, + recommendedAction: "pull", + }); + Object.assign(window.ade.git, { getSyncStatus }); + renderCursorCloudDraft(); + await selectCursorCloudMachine(); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Use what origin has." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + // Origin is newer than the lane, and the cloud clones origin: nothing to push, no scary + // non-fast-forward banner, the launch goes ahead. + await waitFor(() => expect(createRun).toHaveBeenCalled()); + expect(getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(push).not.toHaveBeenCalled(); + }); + + it("blocks the cloud send when the lane's branch has diverged from origin", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { createRun, push } = installCursorCloudMocks(); + Object.assign(window.ade.git, { + getSyncStatus: vi.fn().mockResolvedValue({ + hasUpstream: true, + upstreamState: "tracking", + upstreamRef: "origin/current-lane", + ahead: 2, + behind: 3, + diverged: true, + recommendedAction: "rebase", + }), + }); + renderCursorCloudDraft(); + await selectCursorCloudMachine(); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Try to launch anyway." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + expect((await screen.findAllByText(/behind origin and also has local commits/)).length).toBeGreaterThan(0); + expect(push).not.toHaveBeenCalled(); + expect(createRun).not.toHaveBeenCalled(); + }); + + it("aborts an existing-lane cloud send when the push fails even if origin already lists the branch", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const push = vi.fn().mockRejectedValue(new Error("remote: permission denied")); + const { createRun } = installCursorCloudMocks({ push }); + Object.assign(window.ade.git, { + getSyncStatus: vi.fn().mockResolvedValue({ + hasUpstream: true, + upstreamState: "tracking", + upstreamRef: "origin/current-lane", + ahead: 2, + behind: 0, + diverged: false, + recommendedAction: "push", + }), + listBranches: vi.fn().mockResolvedValue([ + { name: "current-lane", isRemote: false, isCurrent: true, upstream: "origin/current-lane" }, + { name: "origin/current-lane", isRemote: true, isCurrent: false, upstream: null }, + ]), + }); + renderCursorCloudDraft(); + await selectCursorCloudMachine(); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Ship the unpushed commits." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + // Origin listing the branch is not proof it has these commits; a failed push + // used to be swallowed and the cloud agent cloned the stale remote. + expect((await screen.findAllByText(/GitHub refused the push/)).length).toBeGreaterThan(0); + expect(push).toHaveBeenCalledWith({ laneId: "lane-1" }); expect(createRun).not.toHaveBeenCalled(); }); @@ -11851,6 +11992,298 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { await act(async () => { releaseOpen?.(); await Promise.resolve(); }); }); + /** + * Seed the work pane catalog with both an SDK-capable Cursor model and one Cursor reports as + * CLI-only, so a test can prove the cloud surface drops the CLI-only one. + */ + function seedCursorCloudCatalogWithCliOnlyModel(): { cloudId: string; cliOnlyId: string } { + const cloud = createDynamicCursorCliModelDescriptor("composer-cloud", "Composer Cloud", { + cursorAvailability: { cli: true, sdk: true }, + }); + const cliOnly = createDynamicCursorCliModelDescriptor("composer-cli-only", "Composer CLI Only", { + cursorAvailability: { cli: true, sdk: false }, + }); + const models = [cloud, cliOnly]; + rememberWorkPaneCatalog({ + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [{ + key: "cursor", + displayName: "Cursor", + providers: [{ + key: "cursor", + displayName: "Cursor", + badgeColor: "#8B5CF6", + modelCount: models.length, + subsections: [{ + key: "cursor", + label: "Cursor", + models: models.map((model, index) => ({ + id: model.id, + runtimeModelId: model.providerModelId, + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: model.displayName, + isDefault: index === 0, + isAvailable: true, + cursorAvailability: model.cursorAvailability, + })), + }], + }], + }], + } as AgentChatModelCatalog); + return { cloudId: cloud.id, cliOnlyId: cliOnly.id }; + } + + it("keeps a Cursor model of unknown availability sendable to the cloud", async () => { + // Cursor's verified SDK catalog arrives asynchronously. Until it does, a Cursor model carries + // no availability flags at all. Treating that as "not cloud capable" empties the cloud picker + // and blocks every send on a cold start, so the unknown model stays eligible here and the main + // process rejects it later if Cursor turns out not to run it. + const model = createDynamicCursorCliModelDescriptor("composer-cloud", "Composer Cloud"); + expect(model.cursorAvailability).toBeUndefined(); + rememberWorkPaneCatalog({ + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [{ + key: "cursor", + displayName: "Cursor", + providers: [{ + key: "cursor", + displayName: "Cursor", + badgeColor: "#8B5CF6", + modelCount: 1, + subsections: [{ + key: "cursor", + label: "Cursor", + models: [{ + id: model.id, + runtimeModelId: model.providerModelId, + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: model.displayName, + isDefault: true, + isAvailable: true, + }], + }], + }], + }], + } as AgentChatModelCatalog); + installAdeMocks({ + sessions: [], + cursorModels: [{ id: "composer-cloud" }], + aiStatus: cursorAvailableAiStatus(), + }); + const { createRun } = installCursorCloudMocks(); + renderCursorCloudDraft(); + + await selectCursorCloudMachine(); + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Send before the catalog lands." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + await waitFor(() => expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "composer-cloud", + }))); + }); + + it("drops a CLI-only Cursor model from cloud mode and sends the SDK-capable one", async () => { + const { cloudId, cliOnlyId } = seedCursorCloudCatalogWithCliOnlyModel(); + const status = cursorAvailableAiStatus(); + (status as unknown as { availableModelIds: string[] }).availableModelIds = [cloudId, cliOnlyId]; + installAdeMocks({ + sessions: [], + cursorModels: [{ id: "composer-cloud" }, { id: "composer-cli-only" }], + aiStatus: status, + }); + const { createRun } = installCursorCloudMocks(); + // The saved launch config pins the CLI-only model, the way a user who last worked in the CLI + // would return. Cloud mode must move off it rather than offer it. + renderCursorCloudDraft({ pinnedModelId: cliOnlyId }); + + await selectCursorCloudMachine(); + + fireEvent.click(await findModelTrigger()); + await waitFor(() => expect(screen.getAllByText("Composer Cloud").length).toBeGreaterThan(0)); + expect(screen.queryByText("Composer CLI Only")).toBeNull(); + fireEvent.keyDown(document, { key: "Escape" }); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Cloud only, please." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + await waitFor(() => expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "composer-cloud", + }))); + }); + + /** + * Seed two SDK-capable Cursor models: one that advertises thinking levels and one that reports + * `reasoningEfforts: []`, i.e. a model with no thinking control at all. + */ + function seedCursorCloudCatalogWithAndWithoutReasoning(): { thinkingId: string; plainId: string } { + const thinking = createDynamicCursorCliModelDescriptor("grok-4.6", "Grok 4.6", { + reasoningTiers: ["low", "medium", "high", "xhigh"], + cursorAvailability: { cli: true, sdk: true }, + }); + const plain = createDynamicCursorCliModelDescriptor("composer-2.5", "Composer 2.5", { + cursorAvailability: { cli: true, sdk: true }, + }); + const models = [thinking, plain]; + rememberWorkPaneCatalog({ + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [{ + key: "cursor", + displayName: "Cursor", + providers: [{ + key: "cursor", + displayName: "Cursor", + badgeColor: "#8B5CF6", + modelCount: models.length, + subsections: [{ + key: "cursor", + label: "Cursor", + models: models.map((model, index) => ({ + id: model.id, + runtimeModelId: model.providerModelId, + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: model.displayName, + isDefault: index === 0, + isAvailable: true, + reasoningEfforts: (model.reasoningTiers ?? []).map((effort) => ({ + effort, + description: `${effort} reasoning`, + })), + cursorAvailability: model.cursorAvailability, + })), + }], + }], + }], + } as AgentChatModelCatalog); + return { thinkingId: thinking.id, plainId: plain.id }; + } + + it("drops a thinking level the newly picked draft model does not offer", async () => { + // Reproduces the production bug: a draft pinned to a Cursor model with thinking levels kept + // `xhigh` after the user picked a model that reports no thinking levels. The control is gone + // from the composer at that point, so the user cannot clear it, and the launch snapshot + // carried the dead value into Cursor Cloud, where the main process refused the run. + const { thinkingId, plainId } = seedCursorCloudCatalogWithAndWithoutReasoning(); + const status = cursorAvailableAiStatus(); + (status as unknown as { availableModelIds: string[] }).availableModelIds = [thinkingId, plainId]; + installAdeMocks({ + sessions: [], + cursorModels: [{ id: "grok-4.6" }, { id: "composer-2.5" }], + aiStatus: status, + }); + const { createRun } = installCursorCloudMocks(); + renderCursorCloudDraft({ pinnedModelId: thinkingId, pinnedReasoningEffort: "xhigh" }); + + await selectCursorCloudMachine(); + // The pinned model still offers `xhigh`, so hydration keeps it. + await waitFor(() => { + expect(screen.getByLabelText("Reasoning effort").textContent).toContain("XH"); + }); + + fireEvent.click(await findModelTrigger()); + // Search rather than trust the rail the picker opens on. With recents + // present it opens on "Recents", which lists neither seeded model; the + // search box always looks at every model in the catalog. + fireEvent.change(await screen.findByLabelText(/Search models/i), { + target: { value: "Composer 2.5" }, + }); + await clickEnabledModelOption(/Composer 2\.5/i); + fireEvent.keyDown(document, { key: "Escape" }); + + // The thinking control is gone with the model that offered it. + await waitFor(() => { + expect(screen.queryByLabelText("Reasoning effort")).toBeNull(); + }); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "No thinking level, please." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send to Cursor Cloud" })); + + await waitFor(() => expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "composer-2.5", + reasoningEffort: null, + fastMode: false, + }))); + }); + + /** + * Seed the work pane catalog with a CLI-only Cursor model and nothing else, so the cloud + * candidate list comes out empty and the auto-switch effect has nothing to switch to. + */ + function seedCursorCloudCatalogWithNoCloudModel(): string { + const cliOnly = createDynamicCursorCliModelDescriptor("composer-cli-only", "Composer CLI Only", { + cursorAvailability: { cli: true, sdk: false }, + }); + rememberWorkPaneCatalog({ + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [{ + key: "cursor", + displayName: "Cursor", + providers: [{ + key: "cursor", + displayName: "Cursor", + badgeColor: "#8B5CF6", + modelCount: 1, + subsections: [{ + key: "cursor", + label: "Cursor", + models: [{ + id: cliOnly.id, + runtimeModelId: cliOnly.providerModelId, + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: cliOnly.displayName, + isDefault: true, + isAvailable: true, + cursorAvailability: cliOnly.cursorAvailability, + }], + }], + }], + }], + } as AgentChatModelCatalog); + return cliOnly.id; + } + + it("blocks the send instead of running the prompt locally when the cloud model list is empty", async () => { + // Cloud mode is on, the machine picker reads "Cursor Cloud", and the only Cursor model this + // machine reports is one Cursor runs on the CLI only. There is nothing to auto-switch to, so + // the send must be BLOCKED with a reason. Falling through to the local runtime would run the + // prompt on a machine the user did not choose, with no indication it happened. The reason + // names the empty catalog, because telling the user to choose another model would point at + // an empty picker. + const cliOnlyId = seedCursorCloudCatalogWithNoCloudModel(); + const status = cursorAvailableAiStatus(); + (status as unknown as { availableModelIds: string[] }).availableModelIds = [cliOnlyId]; + installAdeMocks({ + sessions: [], + cursorModels: [{ id: "composer-cli-only" }], + aiStatus: status, + }); + const { createRun } = installCursorCloudMocks(); + const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-1", ptyId: "pty-1" }); + renderCursorCloudDraft({ workDraftKind: "cli", pinnedModelId: cliOnlyId, onLaunchCliSession }); + + await selectCursorCloudMachine(); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "This must not run locally." } }); + + expect((await screen.findByRole("button", { name: "Send to Cursor Cloud" }) as HTMLButtonElement).disabled).toBe(true); + + // Enter bypasses the disabled button, so it is the path that could silently send locally. + fireEvent.keyDown(textbox, { key: "Enter" }); + + expect((await screen.findAllByText(/Cursor's model list has not loaded yet/)).length).toBeGreaterThan(0); + expect(screen.queryByText(/Choose a Cursor Cloud model first/)).toBeNull(); + expect(onLaunchCliSession).not.toHaveBeenCalled(); + expect(createRun).not.toHaveBeenCalled(); + }); + it("narrows the model picker to Cursor models in cloud mode", async () => { const status = cursorAvailableAiStatus(); (status as unknown as { availableModelIds: string[] }).availableModelIds = [ @@ -12003,6 +12436,81 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { expect(listRepositories).toHaveBeenCalled(); }); + it("says the lane remote is still being read instead of claiming the lane has none", async () => { + // The production bug: the origin read had not answered yet, and the row said + // "This lane has no GitHub remote" for a lane that has one. + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { getOriginRemote } = installCursorCloudMocks(); + getOriginRemote.mockReset(); + getOriginRemote.mockImplementation(() => new Promise(() => {})); + renderCursorCloudDraft(); + + fireEvent.click(await screen.findByRole("button", { name: /Choose machine/ })); + const cloudRow = await screen.findByRole("menuitemradio", { name: /Cursor Cloud/ }); + expect((cloudRow as HTMLButtonElement).disabled).toBe(true); + await revealRowTooltip(cloudRow); + expect(await screen.findByText("Checking this lane's git remote…")).toBeTruthy(); + expect(screen.queryByText(/no GitHub remote/)).toBeNull(); + }); + + it("names a failed lane remote read and enables the row once a retry succeeds", async () => { + // The production bug: a transient failure of this read disabled Cursor Cloud + // with "This lane has no GitHub remote" until the user switched lanes. + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { getOriginRemote } = installCursorCloudMocks(); + getOriginRemote.mockReset(); + getOriginRemote.mockRejectedValue( + new Error("Error invoking remote method 'git:getOriginRemote': origin is unreachable"), + ); + renderCursorCloudDraft(); + + const machineTrigger = await screen.findByRole("button", { name: /Choose machine/ }); + fireEvent.click(machineTrigger); + const failedRow = await screen.findByRole("menuitemradio", { name: /Cursor Cloud/ }); + await waitFor(() => expect((failedRow as HTMLButtonElement).disabled).toBe(true)); + await revealRowTooltip(failedRow); + // The failure is named. It is not reported as a lane without a remote. + expect( + await screen.findByText("Could not read this lane's git remote: origin is unreachable"), + ).toBeTruthy(); + expect(screen.queryByText(/no GitHub remote/)).toBeNull(); + + // Reopening the picker is the retry affordance, and it retries the remote + // read as well as the Cursor repo list. + getOriginRemote.mockResolvedValue({ + remoteUrl: "git@github.com:acme/project.git", + branch: "current-lane", + }); + const callsBeforeRetry = getOriginRemote.mock.calls.length; + fireEvent.click(machineTrigger); + fireEvent.click(machineTrigger); + + await waitFor(() => expect(getOriginRemote.mock.calls.length).toBeGreaterThan(callsBeforeRetry)); + await waitFor(() => { + expect( + (screen.getByRole("menuitemradio", { name: /Cursor Cloud/ }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + }); + + it("keeps the no-remote sentence for a lane whose read really came back empty", async () => { + installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); + const { getOriginRemote } = installCursorCloudMocks(); + getOriginRemote.mockReset(); + getOriginRemote.mockResolvedValue({ remoteUrl: null, branch: "main" }); + renderCursorCloudDraft(); + + fireEvent.click(await screen.findByRole("button", { name: /Choose machine/ })); + const cloudRow = await screen.findByRole("menuitemradio", { name: /Cursor Cloud/ }); + await waitFor(() => expect((cloudRow as HTMLButtonElement).disabled).toBe(true)); + await revealRowTooltip(cloudRow); + expect( + await screen.findByText( + "This lane has no GitHub remote, so there is nothing for Cursor Cloud to clone.", + ), + ).toBeTruthy(); + }); + it("disables Cursor Cloud when a remote draft machine is selected", async () => { installAdeMocks({ sessions: [], cursorModels: [{ id: "composer-cloud" }], aiStatus: cursorAvailableAiStatus() }); installCursorCloudMocks(); @@ -12155,7 +12663,7 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { fireEvent.click(await screen.findByRole("button", { name: "Advanced" })); expect(await screen.findByRole("menuitemcheckbox", { name: "NPM_TOKEN" })).toBeTruthy(); expect(screen.queryByRole("menuitemcheckbox", { name: "CURSOR_API_KEY" })).toBeNull(); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "NPM_TOKEN" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Select all attachable secrets" })); fireEvent.click(screen.getByRole("checkbox", { name: "Remember for this lane" })); fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Use the token." } }); @@ -12163,7 +12671,7 @@ describe("AgentChatPane Cursor Cloud composer mode", () => { await waitFor(() => expect(createRun).toHaveBeenCalled()); expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ - secretNames: ["NPM_TOKEN"], + secretNames: ["NPM_TOKEN", "GH_TOKEN"], rememberSecretNames: true, })); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 67eb574243..3d210ae8ed 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -114,6 +114,8 @@ import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer, + CURSOR_CLOUD_MODEL_BLOCKED_MESSAGE, + CURSOR_CLOUD_MODELS_NOT_LOADED_MESSAGE, type ParallelComposerControlSlot, } from "./AgentChatComposer"; import { ChatAttachmentDropOverlay } from "./ChatAttachmentDropOverlay"; @@ -122,13 +124,15 @@ import { collectAgentChatPromptHistory, type AgentChatPromptHistoryEntry } from import { ChatLifecycleBanner, shouldRenderChatLifecycleBanner } from "./ChatLifecycleBanner"; import { ChatAwayDigestCard } from "./ChatAwayDigestCard"; import { ChatSubagentTakeoverBanner } from "./ChatSubagentTakeoverBanner"; -import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; +import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; import { resolveContextCompactControl } from "../../../shared/contextCompaction"; import { DEFAULT_RUNTIME_CATALOG_SCOPE, - getSharedRuntimeCatalog, } from "../shared/ModelPicker/runtimeCatalogCache"; +import { runtimeCatalogModelIds, useCursorCloudModelEligibility } from "./useCursorCloudModelEligibility"; +import { reconcileDraftModelControls } from "./draftModelControls"; +import { useLaneGitRemote } from "./useLaneGitRemote"; import { familiesFromStatus } from "../shared/ModelPicker/useProviderAuthStatus"; import { AgentChatMessageList, @@ -198,11 +202,7 @@ import { ChatAppControlPanel } from "./ChatAppControlPanel"; import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; import { RewindFilesConfirmDialog, type RewindFilesConfirmDialogState } from "./RewindFilesConfirmDialog"; import { buildRewindPreviewFiles, deriveRewindDiffSummaries } from "./rewindFilesPreview"; -// CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. -// import { ChatCursorCloudPanel, type ChatCursorCloudPanelHandle } from "./ChatCursorCloudPanel"; -// The inline "Send to Cursor Cloud" strip is superseded by composer-native cloud mode: the repo -// comes from the lane, the branch from the lane picker, the model from the model picker. -// CursorCloudInlineLaunch.tsx stays in the tree with its export intact. +import { ChatCursorCloudPanel } from "./ChatCursorCloudPanel"; import { getLaneAccent } from "../lanes/laneColorPalette"; import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; import { ChatTerminalDrawer } from "./ChatTerminalDrawer"; @@ -279,7 +279,7 @@ import { import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { WorkActivityModule } from "../usage/ActivityModule"; import { branchNameFromRef } from "../prs/shared/laneBranchTargets"; -import { cursorCloudAgentWebUrl, cursorCloudErrorMessage, resolveCursorCloudPrCreateFields } from "../../lib/cursorCloudUtils"; +import { cursorCloudAgentWebUrl, cursorCloudErrorMessage, resolveCursorCloudPrCreateFields, pushAutoCreatedLaneOriginForCursorCloud, ensureExistingLaneOriginReadyForCursorCloud } from "../../lib/cursorCloudUtils"; import { openExternalUrl } from "../../lib/openExternal"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { @@ -1001,18 +1001,6 @@ function draftLaunchJobMessage(job: DraftLaunchJob): string { : `Ready to open ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}.`; } -function originHasLaneBranch(laneId: string): Promise { - return window.ade.git.getOriginRemote({ laneId }).then(async (info) => { - const branch = info?.branch?.trim() || ""; - if (!branch) return false; - const branches = await window.ade.git.listBranches({ laneId }).catch(() => []); - const originRef = `origin/${branch}`; - return branches.some((candidate) => ( - candidate.isRemote && (candidate.name === originRef || candidate.name === branch) - )); - }).catch(() => false); -} - function staleDraftLaunchJobMessage(job: DraftLaunchJob): string { return `${draftLaunchJobMessage(job)} Still working. You can hide this status while ADE continues in the background.`; } @@ -3696,11 +3684,7 @@ export function AgentChatPane({ // whatever the user had on screen. It now only offers: a chip appears while a // simulator session is live and the drawer is closed. const [iosSimulatorSessionChip, setIosSimulatorSessionChip] = useState<{ deviceName: string | null } | null>(null); - // CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. The state - // and its `setCursorCloudPaneOpen(false)` call sites stay wired so the panel can be restored by - // uncommenting the mount below — nothing sets it true while the panel is off. const [cursorCloudPaneOpen, setCursorCloudPaneOpen] = useState(false); - void cursorCloudPaneOpen; // Subagent drill-in: when set, the chat surface renders the named subagent's // transcript instead of the parent stream and the composer is disabled. const [subagentView, setSubagentView] = useState<{ @@ -3719,11 +3703,7 @@ export function AgentChatPane({ const [cloudOverlayArmed, setCloudOverlayArmed] = useState(false); const [cloudHydrateFailed, setCloudHydrateFailed] = useState(false); const [cloudBackfillNonce, setCloudBackfillNonce] = useState(0); - // CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. - // const cursorCloudPanelRef = useRef(null); const rewindConfirmResolveRef = useRef<((confirmed: boolean) => void) | null>(null); - const [laneGitRemote, setLaneGitRemote] = useState(null); - const [laneGitBranch, setLaneGitBranch] = useState(null); const [iosElementContextItems, setIosElementContextItems] = useState([]); const [appControlOpen, setAppControlOpen] = useState( () => readChatCompanionUiState(initialCompanionStateKey).appControlOpen, @@ -5472,8 +5452,16 @@ export function AgentChatPane({ const applyLaunchConfigToComposer = useCallback((config: LastLaunchConfig) => { setModelId(config.modelId); - setReasoningEffort(config.reasoningEffort); - setFastModeState(config.fastMode); + // Every caller of this hydrates a DRAFT, so the same rule as a draft model + // change applies: a persisted thinking level or fast flag must not survive a + // model that no longer exposes that control. Without this, a stale stored + // value reaches the launch snapshot and the main process refuses the run. + const reconciledControls = reconcileDraftModelControls( + resolveScopedModelDescriptor(config.modelId, modelCatalogScopeKey), + { reasoningEffort: config.reasoningEffort, fastMode: config.fastMode }, + ); + setReasoningEffort(reconciledControls.reasoningEffort); + setFastModeState(reconciledControls.fastMode); setExecutionMode(config.executionMode); setInteractionMode(config.controls.interactionMode); setClaudePermissionMode(config.controls.claudePermissionMode); @@ -5484,7 +5472,7 @@ export function AgentChatPane({ setDroidPermissionMode(config.controls.droidPermissionMode); setCursorModeId(config.controls.cursorModeId); setCursorConfigValues({ ...config.controls.cursorConfigValues }); - }, [setFastModeState]); + }, [modelCatalogScopeKey, setFastModeState]); const syncComposerToSession = useCallback((session: AgentChatSessionSummary | null) => { if (!session) { @@ -5642,16 +5630,8 @@ export function AgentChatPane({ includeActiveSessionModel: !modelSelectionConstrained, }); if (modelSelectionConstrained) return filterCursorModelIdsForDraftKind(base, workDraftKind, modelCatalogScopeKey); - // Union in the runtime catalog's dynamic ids (ollama, LM Studio, opencode, - // cursor) for the composer's OWN machine — reading the bound machine's - // catalog here would offer models the target machine cannot run. - const catalog = getSharedRuntimeCatalog(modelCatalogScopeKey); - if (!catalog) return filterCursorModelIdsForDraftKind(base, workDraftKind, modelCatalogScopeKey); - const runtimeIds = descriptorsFromAgentChatModelCatalog( - catalog, - undefined, - modelCatalogScopeKey, - ).availableModelIds; + // Union in the runtime catalog's dynamic ids (ollama, LM Studio, opencode, cursor). + const runtimeIds = runtimeCatalogModelIds(modelCatalogScopeKey); if (!runtimeIds.length) return filterCursorModelIdsForDraftKind(base, workDraftKind, modelCatalogScopeKey); const merged = new Set(base); for (const id of runtimeIds) merged.add(id); @@ -5663,10 +5643,6 @@ export function AgentChatPane({ : undefined), [aiStatus, workDraftKind], ); - const cursorCloudModelIds = useMemo( - () => effectiveAvailableModelIds.filter((id) => id.startsWith("cursor/")), - [effectiveAvailableModelIds], - ); const draftCursorModelSelectionError = useMemo(() => { if (!forceDraft || selectedSessionId || lockSessionId || initialSessionId || !modelId) return null; const descriptor = resolveScopedModelDescriptor(modelId, modelCatalogScopeKey); @@ -5688,18 +5664,18 @@ export function AgentChatPane({ }, [draftCursorModelSelectionError, effectiveAvailableModelIds, modelId, modelSelectionConstrained]); const cursorCloudApiAvailable = providerConnections?.cursor?.runtimeAvailable === true || aiStatus?.availableProviders?.cursor === true; - const cursorCloudAvailable = Boolean(laneId) - && cursorCloudApiAvailable + const cursorCloudPanelAvailable = Boolean(laneId) + && cursorCloudApiAvailable; + const cursorCloudAvailable = cursorCloudPanelAvailable && (selectedSession?.provider === "cursor" || (typeof modelId === "string" && modelId.startsWith("cursor/"))); // Launch-to-cloud is only allowed for a fresh chat: no events yet AND not already promoted to a // cloud agent. const cursorCloudCanLaunch = cursorCloudAvailable && selectedEvents.length === 0 && !selectedSession?.cursorCloudAgentId; - /* CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. useEffect(() => { - if (!cursorCloudAvailable && cursorCloudPaneOpen) setCursorCloudPaneOpen(false); - }, [cursorCloudAvailable, cursorCloudPaneOpen]); + if (!cursorCloudPanelAvailable && cursorCloudPaneOpen) setCursorCloudPaneOpen(false); + }, [cursorCloudPanelAvailable, cursorCloudPaneOpen]); useEffect(() => { if (!cursorCloudPaneOpen) return; const onKey = (event: KeyboardEvent) => { @@ -5708,29 +5684,25 @@ export function AgentChatPane({ window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [cursorCloudPaneOpen]); - */ // The lane's remote and branch feed ChatPrPane's branchName fallback as well as cloud launches. - // One read per lane, no polling. - useEffect(() => { - if (!laneId) return; - const getOriginRemote = window.ade.git?.getOriginRemote; - if (!getOriginRemote) return; - let cancelled = false; - void getOriginRemote({ laneId }) - .then((info) => { - if (cancelled) return; - setLaneGitRemote(info?.remoteUrl ?? null); - setLaneGitBranch(info?.branch ?? null); - }) - .catch(() => { - if (cancelled) return; - setLaneGitRemote(null); - setLaneGitBranch(null); - }); - return () => { - cancelled = true; - }; - }, [laneId]); + // One read per lane, no polling — but a tri-state one, so a pending or failed + // read cannot be mistaken for "this lane has no remote". + // The auto-create row is not a lane yet: asking the brain for its remote fails with + // "Lane not found". Its branch is cut from the primary lane of the same repo, so cloud + // readiness reads the primary lane's remote instead. The launch itself creates the + // lane and pushes that branch before the agent starts. + const cloudReadinessLaneId = useMemo(() => { + if (!isAutoCreateLaneOptionId(draftLaunchTargetId)) return laneId; + const primary = lanes.find((lane) => lane.laneType === "primary") ?? null; + return primary?.id ?? laneId; + }, [draftLaunchTargetId, laneId, lanes]); + const { + remoteUrl: laneGitRemote, + branch: laneGitBranch, + status: laneGitRemoteStatus, + error: laneGitRemoteError, + refetch: refetchLaneGitRemote, + } = useLaneGitRemote(cloudReadinessLaneId, chatRuntimePin); const { cursorCloudMode, setCursorCloudMode, @@ -5747,47 +5719,45 @@ export function AgentChatPane({ refetchCursorCloudRepos, } = useCursorCloudDraftState({ cursorCloudAvailable, - laneId, + laneId: cloudReadinessLaneId, laneGitRemote, laneGitBranch, + laneGitRemoteStatus, + laneGitRemoteError, }); + // Opening the machine picker is the retry affordance for both probes the + // cloud row depends on: Cursor's repo list and this lane's git remote. Each + // re-runs only when it actually failed, so opening a healthy picker costs + // nothing. + const handleDraftMachinePickerOpen = useCallback(() => { + refetchCursorCloudRepos(); + if (laneGitRemoteStatus === "error") refetchLaneGitRemote(); + }, [laneGitRemoteStatus, refetchCursorCloudRepos, refetchLaneGitRemote]); // Cloud mode drops the moment the chat stops being launchable — a non-cursor model, a chat that // has started, or a lost Cursor connection all land here. That is also how "pick a non-cursor // model" turns the toggle off: `cursorCloudAvailable` requires a cursor model. useEffect(() => { if (!cursorCloudCanLaunch && cursorCloudMode) setCursorCloudMode(false); }, [cursorCloudCanLaunch, cursorCloudMode, setCursorCloudMode]); - /* CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. This 20s - poll only fed the badge on the panel's entry point, so it is off while the panel is — - nothing in the composer counts running cloud agents today. - const [cursorCloudActiveCount, setCursorCloudActiveCount] = useState(0); - useEffect(() => { - if (!cursorCloudAvailable) { - setCursorCloudActiveCount(0); - return; - } - let cancelled = false; - async function poll() { - try { - const result = await window.ade.ai.cursorCloudListAgents({ limit: 16 }); - if (cancelled) return; - const active = result.items.filter((agent) => { - const s = (agent.status ?? "").toLowerCase(); - return s === "running" || s === "creating"; - }).length; - setCursorCloudActiveCount(active); - } catch { - // best-effort - } - } - void poll(); - const interval = window.setInterval(poll, 20_000); - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [cursorCloudAvailable]); - */ + const applyCursorCloudModelSwitch = useCallback((nextModelId: string) => { + setModelId(nextModelId); + setReasoningEffort(null); + setFastModeState(false); + // Mark the draft as touched, exactly like every other draft-state writer. + // Without it, a re-hydration of the saved launch config undoes the switch + // and the auto-switch fires again. + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + }, [draftLaunchConfigScopeKey, setFastModeState]); + // Declared after `useCursorCloudDraftState` because it reads `cursorCloudMode` from it. + const { cursorCloudModelIds, cursorCloudModelReady } = useCursorCloudModelEligibility({ + availableModelIds, + availableModelIdsOverride, + modelCatalogScopeKey, + modelId, + runtimeCatalogVersion, + cursorCloudMode, + onSwitchModel: applyCursorCloudModelSwitch, + }); // Runtime tracks whether sends go to the local agent or to a promoted Cursor Cloud agent. The // value is derived purely from session state — the previous renderer-side override (split-send // chevron) was removed when launches were funneled through the dedicated cloud composer surface. @@ -5795,16 +5765,7 @@ export function AgentChatPane({ ?? (selectedSession?.cursorCloudAgentId ? "cloud" : "local"); const handoffAvailableModelIds = useMemo(() => { const merged = new Set(availableModelIds); - const catalog = getSharedRuntimeCatalog(modelCatalogScopeKey); - if (catalog) { - for (const id of descriptorsFromAgentChatModelCatalog( - catalog, - undefined, - modelCatalogScopeKey, - ).availableModelIds) { - merged.add(id); - } - } + for (const id of runtimeCatalogModelIds(modelCatalogScopeKey)) merged.add(id); if (selectedSessionModelId) { merged.add(selectedSessionModelId); } @@ -9920,7 +9881,6 @@ export function AgentChatPane({ cloudAgentId: session.cursorCloudAgentId, laneId: session.laneId, sessionId: session.sessionId, - ...(session.title?.trim() ? { agentName: session.title.trim() } : {}), }).then((result) => { if (result.session) notifySessionCreated(result.session); loadedHistoryRef.current.delete(session.sessionId); @@ -10007,6 +9967,13 @@ export function AgentChatPane({ ?? "Connect this repo to Cursor before sending work to Cursor Cloud."); return false; } + const cloudModelId = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : ""; + if (!cloudModelId || !cursorCloudModelIds.includes(modelId)) { + setError(cursorCloudModelIds.length > 0 + ? CURSOR_CLOUD_MODEL_BLOCKED_MESSAGE + : CURSOR_CLOUD_MODELS_NOT_LOADED_MESSAGE); + return false; + } if (draftMachineUnavailableRef.current) { setError("The selected machine is not currently available."); return false; @@ -10022,7 +9989,6 @@ export function AgentChatPane({ } if (cursorCloudLaunchInFlightRef.current) return false; cursorCloudLaunchInFlightRef.current = true; - const cloudModelId = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : ""; setError(null); // A cloud launch has the same shape as a local one — make the lane, start the agent, hand @@ -10084,19 +10050,16 @@ export function AgentChatPane({ createdLaneId = createdLane.autoCreated ? createdLane.laneId : null; targetLaneId = createdLane.laneId; patchDraftLaunchJob(jobId, { laneId: createdLane.laneId, laneName: createdLane.laneName }); - // The cloud machine clones from origin, so the lane's branch has to exist there before - // the agent starts — a fresh worktree branch is local-only. A failed push aborts the - // send: an agent pointed at a branch origin has never heard of does nothing useful. - await window.ade.git.push({ laneId: createdLane.laneId }); + await pushAutoCreatedLaneOriginForCursorCloud({ + laneId: createdLane.laneId, + branchHint: createdLane.laneName, + git: window.ade.git, + }); } else if (targetLaneId) { - // An existing lane already has its branch; it only needs to be on the remote. The push - // is a no-op when it is, so ask for it rather than probing first. - try { - await window.ade.git.push({ laneId: targetLaneId }); - } catch (pushError) { - const presentOnOrigin = await originHasLaneBranch(targetLaneId); - if (!presentOnOrigin) throw pushError; - } + await ensureExistingLaneOriginReadyForCursorCloud({ + laneId: targetLaneId, + git: window.ade.git, + }); } if (!targetLaneId) throw new Error("Select a lane before sending."); const info = await window.ade.git.getOriginRemote({ laneId: targetLaneId }).catch(() => null); @@ -10118,6 +10081,8 @@ export function AgentChatPane({ repoUrl: cursorCloudRepoUrl, startingRef, modelId: cloudModelId || null, + reasoningEffort: snapshot.reasoningEffort, + fastMode: snapshot.fastMode, autoCreatePR: prFields.autoCreatePR, // Lane selection already decided the branch, so the agent always commits to it rather // than branching again underneath us. `prUrl` also implies the PR head branch. @@ -10141,7 +10106,8 @@ export function AgentChatPane({ laneId: targetLaneId, sessionId, ...(cloudModelId ? { modelId: cloudModelId } : {}), - ...(created.agent.name?.trim() ? { agentName: created.agent.name.trim() } : {}), + reasoningEffort: snapshot.reasoningEffort, + fastMode: snapshot.fastMode, }); } catch { opened = { sessionId }; @@ -10197,6 +10163,7 @@ export function AgentChatPane({ adoptCursorCloudChatSession, buildDraftLaunchSnapshotForCurrentState, cursorCloudAutoPr, + cursorCloudModelIds, cursorCloudRepoUrl, cursorCloudUnavailableReason, draftLaunchTargetIsAutoCreate, @@ -12620,10 +12587,8 @@ export function AgentChatPane({ ) : undefined} /> ); - /* CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. const cursorCloudPanelContent = ( setError(message)} /> ); - */ const terminalPanelContent = chatTerminalVisible ? ( { setRuntimeCatalogVersion((version) => version + 1); }} - allowCliOnlyModels={workDraftKind === "cli"} + allowCliOnlyModels={workDraftKind === "cli" && !cursorCloudMode} reasoningEffort={reasoningEffort} fastMode={fastMode} usageViewModel={selectedUsageViewModel} @@ -13332,8 +13295,9 @@ export function AgentChatPane({ orchestratorModeActive={isOrchestratorDraft || isOrchestratorLead} orchestrationRole={isOrchestratorDraft ? "lead" : activeOrchestrationRole} onModelChange={(nextModelId, options) => { - const modelAllowed = - modelSelectionConstrained + const modelAllowed = cursorCloudMode + ? cursorCloudModelIds.includes(nextModelId) + : modelSelectionConstrained ? effectiveAvailableModelIds.includes(nextModelId) : ( !effectiveAvailableModelIds.length @@ -13347,13 +13311,27 @@ export function AgentChatPane({ if (isPersistentIdentitySurface && sessionMutationKind) { return; } - if (!selectedSessionId) { - draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; - } + const previousFastMode = fastModeRef.current; if (options) { setFastModeState(options.fastMode); } const snapshot = buildModelSelectionSnapshot(nextModelId); + if (!selectedSessionId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + // The draft owns its thinking level and fast flag, so a model + // change leaves behind a value the new model may not expose. + // Reconcile here, before launch: the launch snapshot reads this + // state raw, and the main process rejects an effort the model + // does not advertise. This is the rule the Cursor Cloud + // auto-switch already applies, generalized to every draft model + // change. + const reconciledControls = reconcileDraftModelControls(snapshot.nextDesc, { + reasoningEffort, + fastMode: options ? options.fastMode : previousFastMode, + }); + setReasoningEffort(reconciledControls.reasoningEffort); + setFastModeState(reconciledControls.fastMode); + } if (!selectedSessionId || turnActive) { applyModelSelectionSnapshot(snapshot); return; @@ -13518,11 +13496,23 @@ export function AgentChatPane({ }); }} cursorCloudCanLaunch={cursorCloudCanLaunch} + cursorCloudModelReady={cursorCloudModelReady} + cursorCloudHasEligibleModels={cursorCloudModelIds.length > 0} cursorCloudModeActive={cursorCloudMode} - // CURSOR-CLOUD-PANEL: the composer's cloud glyph, its menu, and "Open existing cloud - // chat" are temporarily disabled; they return in the dedicated cloud-panel PR. Cloud - // mode is entered only by picking "Cursor Cloud" in the launch shelf's machine - // picker, and the Send button carries the cloud glyph while it is on. + cursorCloudPanelAvailable={cursorCloudPanelAvailable} + cursorCloudPaneOpen={cursorCloudPaneOpen} + onToggleCursorCloudPanel={() => { + setCursorCloudPaneOpen((current) => { + const next = !current; + if (next) { + setChatActionsOpen(false); + setIosSimulatorOpen(false); + setAppControlOpen(false); + setTerminalDrawerOpen(false); + } + return next; + }); + }} onSubmitToCloud={async (promptText) => { void copyPromptForLaunch(promptText); return launchCursorCloudRun(promptText); @@ -13742,9 +13732,7 @@ export function AgentChatPane({ // App Control) host their own input affordances, so the empty-state layout // shrinks the hero and moves the composer below. const appPanelOpen = effectiveIosSimulatorOpen || effectiveAppControlOpen; - /* CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. - const effectiveCursorCloudPaneOpen = cursorCloudPaneOpen && cursorCloudAvailable; - */ + const effectiveCursorCloudPaneOpen = cursorCloudPaneOpen && cursorCloudPanelAvailable; const terminalRightPaneOpen = chatTerminalVisible && !hasExternalTerminalPane && terminalDrawerOpen && Boolean(selectedSessionId); // Orchestration: derive runId / role from the active session. When set, mount // the right plan panel and (for "orchestrator-lead") wrap the chat surface in @@ -13752,7 +13740,7 @@ export function AgentChatPane({ const orchestrationRunId = selectedSession?.orchestrationRunId ?? null; const orchestrationRole = activeOrchestrationRole; const orchestrationPanelOpen = Boolean(orchestrationRunId); - const heavyRightPaneOpen = appPanelOpen || orchestrationPanelOpen || terminalRightPaneOpen; + const heavyRightPaneOpen = appPanelOpen || orchestrationPanelOpen || terminalRightPaneOpen || effectiveCursorCloudPaneOpen; const supportsSplit = layoutVariant !== "grid-tile"; const chatActionsFloating = chatActionsOpen && supportsSplit && !heavyRightPaneOpen; const chatActionsRightPaneOpen = chatActionsOpen && !chatActionsFloating; @@ -14307,8 +14295,7 @@ export function AgentChatPane({ {effectiveIosSimulatorOpen ? renderRightPane(iosSimulatorPanelContent) : null} {effectiveAppControlOpen ? renderRightPane(appControlPanelContent) : null} - {/* CURSOR-CLOUD-PANEL: temporarily disabled; returns in the dedicated cloud-panel PR. */} - {/* {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} */} + {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} {terminalRightPaneOpen && terminalPanelContent ? renderRightPane(terminalPanelContent) : null} {orchestrationPanelOpen && orchestrationPanelContent ? renderRightPane(orchestrationPanelContent) : null} @@ -14408,7 +14395,7 @@ export function AgentChatPane({ selectedMachineId={draftShelfMachineValue} onChange={handleDraftShelfMachineChange} disabled={shellLaunchBusy} - onOpen={refetchCursorCloudRepos} + onOpen={handleDraftMachinePickerOpen} /> )} diff --git a/apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.test.tsx b/apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.test.tsx deleted file mode 100644 index 983b073e74..0000000000 --- a/apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* @vitest-environment jsdom */ - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CursorCloudInlineLaunch } from "./CursorCloudInlineLaunch"; - -const originalAde = globalThis.window.ade; - -function installAdeMocks() { - globalThis.window.ade = { - ai: { - cursorCloudListRepositories: vi.fn().mockResolvedValue([ - { url: "https://github.com/acme/project.git", name: "project" }, - ]), - cursorCloudCreateRun: vi.fn().mockResolvedValue({ - agent: { agentId: "agent-1" }, - }), - }, - git: { - listBranches: vi.fn().mockResolvedValue([ - { name: "main", isRemote: false }, - { name: "feature/work", isRemote: false }, - ]), - getOpenPrForBranch: vi.fn().mockResolvedValue(null), - }, - } as any; -} - -describe("CursorCloudInlineLaunch", () => { - beforeEach(() => { - installAdeMocks(); - }); - - afterEach(() => { - cleanup(); - if (originalAde === undefined) { - delete (globalThis.window as any).ade; - } else { - globalThis.window.ade = originalAde; - } - }); - - it("cancels Cursor Cloud launch setup without creating a run", async () => { - const onClose = vi.fn(); - - render( - , - ); - - expect(await screen.findByText("Send to Cursor Cloud")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Cancel cloud send" })); - - expect(onClose).toHaveBeenCalledTimes(1); - expect(window.ade.ai.cursorCloudCreateRun).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx b/apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx deleted file mode 100644 index e91357afee..0000000000 --- a/apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx +++ /dev/null @@ -1,447 +0,0 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; -import { ArrowSquareOut, CloudArrowUp, X } from "@phosphor-icons/react"; -import type { CursorCloudRepository } from "../../../shared/types"; -import { getModelById } from "../../../shared/modelRegistry"; -import { openExternalUrl } from "../../lib/openExternal"; -import { cursorCloudErrorMessage, repoMatchKey } from "../../lib/cursorCloudUtils"; -import { cn } from "../ui/cn"; -import { SmartTooltip } from "../ui/SmartTooltip"; - -const CURSOR_VIOLET = "#A78BFA"; - -export type CursorCloudInlineLaunchHandle = { - launchWithPrompt: (promptText: string) => Promise<{ agentId: string } | null>; -}; - -type Props = { - cursorModelIds: string[]; - defaultRepoUrl?: string | null; - defaultBranch?: string | null; - defaultModelSdkId?: string | null; - laneGitRemote?: string | null; - laneId?: string | null; - onLaunched?: (agentId: string) => void; - onClose: () => void; - onMissingFields?: (message: string) => void; -}; - -type DetectedPr = { - prUrl: string; - prNumber: number | null; - title: string | null; - headRefName: string | null; -}; - -function repoLabel(url: string): string { - if (!url) return ""; - const trimmed = url.replace(/\.git$/i, "").replace(/\/+$/, ""); - const parts = trimmed.split("/"); - if (parts.length >= 2) return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; - return trimmed; -} - -function prFallbackLabel(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.host}${parsed.pathname}`.replace(/\/$/, ""); - } catch { - return url; - } -} - -export const CursorCloudInlineLaunch = forwardRef(function CursorCloudInlineLaunch({ - cursorModelIds, - defaultRepoUrl, - defaultBranch, - defaultModelSdkId, - laneGitRemote, - laneId, - onLaunched, - onClose, - onMissingFields, -}, ref) { - const [repos, setRepos] = useState([]); - const [reposLoaded, setReposLoaded] = useState(false); - const [repoUrl, setRepoUrl] = useState(defaultRepoUrl ?? ""); - const [branch, setBranch] = useState(defaultBranch ?? ""); - const [branches, setBranches] = useState([]); - const [branchesLoaded, setBranchesLoaded] = useState(false); - const [modelId, setModelId] = useState(defaultModelSdkId ?? ""); - const [autoCreatePR, setAutoCreatePR] = useState(false); - const [workOnCurrentBranch, setWorkOnCurrentBranch] = useState(false); - const [detectedPr, setDetectedPr] = useState(null); - const [prDetectPending, setPrDetectPending] = useState(false); - const [error, setError] = useState(null); - const pendingDetectionRef = useRef | null>(null); - const detectedPrRef = useRef(null); - - const modelOptions = useMemo(() => { - const seen = new Set(); - return cursorModelIds - .filter((id) => id.startsWith("cursor/")) - .map((id) => { - const value = id.replace(/^cursor\//, ""); - if (!value || seen.has(value)) return null; - seen.add(value); - return { id, value, label: getModelById(id)?.displayName ?? value }; - }) - .filter((entry): entry is { id: string; value: string; label: string } => Boolean(entry)); - }, [cursorModelIds]); - - useEffect(() => { - let cancelled = false; - void window.ade.ai - .cursorCloudListRepositories() - .then((next) => { - if (cancelled) return; - setRepos(next); - setReposLoaded(true); - setRepoUrl((current) => { - if (current) return current; - if (laneGitRemote) { - const target = repoMatchKey(laneGitRemote); - const match = next.find((repo) => repoMatchKey(repo.url) === target); - if (match) return match.url; - } - return next[0]?.url || ""; - }); - }) - .catch((err) => { - if (cancelled) return; - setReposLoaded(true); - setError(cursorCloudErrorMessage(err)); - }); - return () => { cancelled = true; }; - }, [laneGitRemote]); - - useEffect(() => { - if (!defaultBranch) return; - setBranch((current) => current || defaultBranch); - }, [defaultBranch]); - - useEffect(() => { - if (!laneId) { - setBranches([]); - setBranchesLoaded(false); - return; - } - let cancelled = false; - void window.ade.git - .listBranches({ laneId }) - .then((items) => { - if (cancelled) return; - const seen = new Set(); - const local: string[] = []; - for (const item of items) { - if (item.isRemote) continue; - const name = (item.name ?? "").trim(); - if (!name || seen.has(name)) continue; - seen.add(name); - local.push(name); - } - setBranches(local); - setBranchesLoaded(true); - }) - .catch(() => { - if (cancelled) return; - setBranchesLoaded(true); - }); - return () => { cancelled = true; }; - }, [laneId]); - - // When laneGitRemote arrives after the initial repo list, upgrade the - // auto-selection from "first repo" to the lane's repo. - useEffect(() => { - if (!laneGitRemote || repos.length === 0) return; - const target = repoMatchKey(laneGitRemote); - if (!target) return; - const match = repos.find((repo) => repoMatchKey(repo.url) === target); - if (!match) return; - setRepoUrl((current) => { - if (!current) return match.url; - if (current === repos[0]?.url && current !== match.url) return match.url; - return current; - }); - }, [laneGitRemote, repos]); - - useEffect(() => { - if (!defaultModelSdkId) return; - setModelId((current) => current || defaultModelSdkId); - }, [defaultModelSdkId]); - - // Detect open PR for the selected branch. Re-runs whenever lane or branch changes. - useEffect(() => { - if (!laneId) { - setDetectedPr(null); - detectedPrRef.current = null; - pendingDetectionRef.current = null; - setPrDetectPending(false); - return; - } - const trimmedBranch = branch.trim(); - let cancelled = false; - setPrDetectPending(true); - setDetectedPr(null); - detectedPrRef.current = null; - const detection = window.ade.git - .getOpenPrForBranch({ laneId, branch: trimmedBranch || undefined }) - .then((result): DetectedPr | null => { - const next: DetectedPr | null = result && result.prUrl - ? { - prUrl: result.prUrl, - prNumber: result.prNumber, - title: result.title, - headRefName: result.headRefName, - } - : null; - if (!cancelled) { - setDetectedPr(next); - detectedPrRef.current = next; - setPrDetectPending(false); - } - return next; - }) - .catch(() => { - if (!cancelled) { - setDetectedPr(null); - detectedPrRef.current = null; - setPrDetectPending(false); - } - return null; - }); - pendingDetectionRef.current = detection; - return () => { cancelled = true; }; - }, [laneId, branch]); - - const launchWithPrompt = useCallback(async (rawPrompt: string): Promise<{ agentId: string } | null> => { - const trimmedPrompt = rawPrompt.trim(); - const trimmedRepo = repoUrl.trim(); - if (!trimmedPrompt) { - onMissingFields?.("Type a prompt above."); - return null; - } - if (!trimmedRepo) { - onMissingFields?.("Pick a repository."); - return null; - } - setError(null); - try { - const pending = pendingDetectionRef.current; - if (pending) { - await Promise.race([ - pending, - new Promise((resolve) => setTimeout(() => resolve(null), 6000)), - ]); - } - // Only reuse a detected PR when the selected repo still matches the lane - // it was detected against. Otherwise the user could ship a mismatched - // repoUrl/prUrl pair to the cloud API. - const detectedRepoKey = repoMatchKey(laneGitRemote); - const selectedRepoKey = repoMatchKey(trimmedRepo); - const resolvedPr = - detectedRepoKey !== "" && detectedRepoKey === selectedRepoKey - ? detectedPrRef.current - : null; - const attachToPr = resolvedPr?.prUrl ?? null; - const created = await window.ade.ai.cursorCloudCreateRun({ - promptText: trimmedPrompt, - repoUrl: trimmedRepo, - startingRef: branch.trim() || null, - modelId: modelId.trim() || null, - autoCreatePR: attachToPr ? false : autoCreatePR, - workOnCurrentBranch: attachToPr ? true : workOnCurrentBranch, - prUrl: attachToPr, - skipReviewerRequest: true, - }); - onLaunched?.(created.agent.agentId); - return { agentId: created.agent.agentId }; - } catch (err) { - setError(cursorCloudErrorMessage(err)); - return null; - } - }, [autoCreatePR, branch, laneGitRemote, modelId, onLaunched, onMissingFields, repoUrl, workOnCurrentBranch]); - - useImperativeHandle(ref, () => ({ launchWithPrompt }), [launchWithPrompt]); - - const selectedRepoMatchesLaneRepo = - repoMatchKey(repoUrl) !== "" && repoMatchKey(repoUrl) === repoMatchKey(laneGitRemote); - const effectiveDetectedPr = selectedRepoMatchesLaneRepo ? detectedPr : null; - const prPillLabel = effectiveDetectedPr - ? effectiveDetectedPr.prNumber != null - ? `PR #${effectiveDetectedPr.prNumber}` - : prFallbackLabel(effectiveDetectedPr.prUrl) - : null; - const prPillTitle = effectiveDetectedPr?.title?.trim() || null; - - return ( -
-
- - Send to Cursor Cloud - — pick a repo and any options, then Send. - -
- -
- - - - - - - - - - - -
- - {prDetectPending ? ( -
- ) : effectiveDetectedPr ? ( - - - - ) : ( -
- - - - - - - -
- )} - - {error ? ( -
{error}
- ) : null} -
- ); -}); diff --git a/apps/desktop/src/renderer/components/chat/CursorCloudSecretsPicker.tsx b/apps/desktop/src/renderer/components/chat/CursorCloudSecretsPicker.tsx index 9420ed4267..a636a1e0c1 100644 --- a/apps/desktop/src/renderer/components/chat/CursorCloudSecretsPicker.tsx +++ b/apps/desktop/src/renderer/components/chat/CursorCloudSecretsPicker.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from "react"; + export function isInjectableCloudSecretName(name: string): boolean { const trimmed = name.trim(); return trimmed.length > 0 && !trimmed.toUpperCase().startsWith("CURSOR_"); @@ -21,7 +23,14 @@ export function CursorCloudSecretsList({ onRememberChange: (remember: boolean) => void; }) { const selected = new Set(selectedNames); - const injectableNames = availableNames.filter(isInjectableCloudSecretName); + const injectableNames = [...new Set(availableNames.filter(isInjectableCloudSecretName))]; + const allSelected = injectableNames.length > 0 && injectableNames.every((name) => selected.has(name)); + const partiallySelected = injectableNames.some((name) => selected.has(name)) && !allSelected; + const selectAllRef = useRef(null); + + useEffect(() => { + if (selectAllRef.current) selectAllRef.current.indeterminate = partiallySelected; + }, [partiallySelected]); const toggleName = (name: string) => { if (selected.has(name)) { @@ -31,11 +40,35 @@ export function CursorCloudSecretsList({ onSelectedNamesChange([...selectedNames, name]); }; + const toggleAll = () => { + if (allSelected) { + const injectableSet = new Set(injectableNames); + onSelectedNamesChange(selectedNames.filter((name) => !injectableSet.has(name))); + return; + } + onSelectedNamesChange([...new Set([...selectedNames, ...injectableNames])]); + }; + return (

Attach ADE secrets

+ {injectableNames.length > 0 ? ( + + ) : null}
{injectableNames.length === 0 ? (

diff --git a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx index c63f706a42..62d58bf81a 100644 --- a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx +++ b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx @@ -203,11 +203,15 @@ export function DraftMachinePicker({ aria-expanded={open} aria-label={triggerAriaLabel} disabled={disabled} - onClick={() => setOpen((current) => { - const next = !current; + // `onOpen` runs outside the state updater on purpose. React may call + // an updater during another component's render and may call it twice, + // so a probe fired from inside it warns about updating the parent + // mid-render and can run twice per click. + onClick={() => { + const next = !open; + setOpen(next); if (next) onOpen?.(); - return next; - })} + }} className={cn( "inline-flex h-7 min-w-0 shrink items-center gap-1.5 rounded-md border px-2", "font-sans text-[11px] font-medium transition-colors", diff --git a/apps/desktop/src/renderer/components/chat/draftModelControls.test.ts b/apps/desktop/src/renderer/components/chat/draftModelControls.test.ts new file mode 100644 index 0000000000..af045f6320 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/draftModelControls.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { ModelDescriptor } from "../../../shared/modelRegistry"; +import { reconcileDraftModelControls } from "./draftModelControls"; + +function descriptor(overrides: Partial): ModelDescriptor { + return { + id: "cursor/test-model", + shortId: "test-model", + displayName: "Test Model", + family: "cursor", + authTypes: ["api-key"], + contextWindow: 100000, + maxOutputTokens: 8000, + capabilities: { tools: true, vision: false, reasoning: true, streaming: true }, + color: "#A78BFA", + providerRoute: "cursor-sdk", + providerModelId: "test-model", + isCliWrapped: false, + ...overrides, + }; +} + +describe("reconcileDraftModelControls", () => { + it("clears the thinking level when the model exposes no tiers", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: [] }), + { reasoningEffort: "xhigh", fastMode: false }, + ); + expect(result.reasoningEffort).toBeNull(); + }); + + it("clears the thinking level when the model omits tiers entirely", () => { + const result = reconcileDraftModelControls( + descriptor({}), + { reasoningEffort: "xhigh", fastMode: false }, + ); + expect(result.reasoningEffort).toBeNull(); + }); + + it("keeps a thinking level the model still lists", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low", "medium", "high", "xhigh"] }), + { reasoningEffort: "xhigh", fastMode: false }, + ); + expect(result.reasoningEffort).toBe("xhigh"); + }); + + it("matches a thinking level case-insensitively and returns the model's spelling", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low", "medium", "high"] }), + { reasoningEffort: " HIGH ", fastMode: false }, + ); + expect(result.reasoningEffort).toBe("high"); + }); + + it("falls back to the advertised default when the level is not listed", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low", "medium", "high"], defaultReasoningEffort: "medium" }), + { reasoningEffort: "xhigh", fastMode: false }, + ); + expect(result.reasoningEffort).toBe("medium"); + }); + + it("clears the level when the advertised default is not a real tier", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low", "medium"], defaultReasoningEffort: "ultra" }), + { reasoningEffort: "xhigh", fastMode: false }, + ); + expect(result.reasoningEffort).toBeNull(); + }); + + it("clears the level when the model advertises no default", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low", "medium"] }), + { reasoningEffort: "xhigh", fastMode: false }, + ); + expect(result.reasoningEffort).toBeNull(); + }); + + it("keeps an auto (null) level as auto", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low", "medium"], defaultReasoningEffort: "medium" }), + { reasoningEffort: null, fastMode: false }, + ); + expect(result.reasoningEffort).toBeNull(); + }); + + it("turns fast mode off when the model exposes no service tiers", () => { + const result = reconcileDraftModelControls( + descriptor({ reasoningTiers: ["low"] }), + { reasoningEffort: "low", fastMode: true }, + ); + expect(result.fastMode).toBe(false); + }); + + it("turns fast mode off when the model lists service tiers without fast", () => { + const result = reconcileDraftModelControls( + descriptor({ serviceTiers: ["priority"] }), + { reasoningEffort: null, fastMode: true }, + ); + expect(result.fastMode).toBe(false); + }); + + it("keeps fast mode when the model advertises the fast service tier", () => { + const result = reconcileDraftModelControls( + descriptor({ serviceTiers: ["fast"] }), + { reasoningEffort: null, fastMode: true }, + ); + expect(result.fastMode).toBe(true); + }); + + it("leaves both controls untouched when the descriptor does not resolve", () => { + const current = { reasoningEffort: "xhigh", fastMode: true }; + expect(reconcileDraftModelControls(undefined, current)).toEqual(current); + expect(reconcileDraftModelControls(null, current)).toEqual(current); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/draftModelControls.ts b/apps/desktop/src/renderer/components/chat/draftModelControls.ts new file mode 100644 index 0000000000..987e4537b5 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/draftModelControls.ts @@ -0,0 +1,59 @@ +import { modelSupportsFastMode, type ModelDescriptor } from "../../../shared/modelRegistry"; + +export type DraftModelControls = { + reasoningEffort: string | null; + fastMode: boolean; +}; + +/** + * Reconciles a draft's thinking level and fast-mode flag to the model the draft + * now points at. + * + * A composer control is only meaningful while the selected model advertises it. + * The draft keeps `reasoningEffort` and `fastMode` in its own state, so a model + * change leaves a value behind that the new model never exposes: switching from + * a Cursor model with thinking levels to `cursor/composer-2.5` (which reports + * `reasoningEfforts: []`) used to carry `xhigh` into the launch snapshot, and + * the main process rejected the cloud run. The control is invisible at that + * point, so the user cannot clear it themselves. + * + * The rules mirror what the composer renders: + * - `ReasoningEffortPicker` reads `descriptor.reasoningTiers`. No tiers means no + * picker, so the effort must be null. + * - A tier the model does not list falls back to the model's advertised default, + * and to null when that default is not a real tier either. + * - A null effort stays null. Null is the composer's "Auto", which every model + * accepts, so a model change must not turn it into an explicit tier. + * - `modelSupportsFastMode` gates the fast toggle on the "fast" service tier. + * + * A descriptor that does not resolve leaves both values untouched. A catalog + * that has not loaded yet is not evidence that a control disappeared, and the + * main process still fails closed on a value the runtime cannot accept. + */ +export function reconcileDraftModelControls( + descriptor: ModelDescriptor | null | undefined, + current: DraftModelControls, +): DraftModelControls { + if (!descriptor) return current; + return { + reasoningEffort: reconcileDraftReasoningEffort(descriptor, current.reasoningEffort), + fastMode: current.fastMode && modelSupportsFastMode(descriptor), + }; +} + +function reconcileDraftReasoningEffort( + descriptor: ModelDescriptor, + current: string | null, +): string | null { + const tiers = descriptor.reasoningTiers ?? []; + if (!tiers.length) return null; + const normalizedCurrent = current?.trim().toLowerCase() ?? ""; + if (!normalizedCurrent) return null; + const matchedTier = tiers.find((tier) => tier.trim().toLowerCase() === normalizedCurrent); + if (matchedTier) return matchedTier; + const advertisedDefault = descriptor.defaultReasoningEffort?.trim().toLowerCase() ?? ""; + const matchedDefault = advertisedDefault + ? tiers.find((tier) => tier.trim().toLowerCase() === advertisedDefault) + : undefined; + return matchedDefault ?? null; +} diff --git a/apps/desktop/src/renderer/components/chat/useCursorCloudDraftState.ts b/apps/desktop/src/renderer/components/chat/useCursorCloudDraftState.ts index 134aa85537..fd47e35cc4 100644 --- a/apps/desktop/src/renderer/components/chat/useCursorCloudDraftState.ts +++ b/apps/desktop/src/renderer/components/chat/useCursorCloudDraftState.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cursorCloudErrorMessage, repoMatchKey, type CursorCloudExistingPr } from "../../lib/cursorCloudUtils"; import { isInjectableCloudSecretName } from "./CursorCloudSecretsPicker"; +import type { LaneGitRemoteStatus } from "./useLaneGitRemote"; export type CursorCloudRepoListState = | { status: "loading" } @@ -12,6 +13,9 @@ type UseCursorCloudDraftStateInput = { laneId: string | null; laneGitRemote: string | null; laneGitBranch: string | null; + /** Tri-state read of the lane remote. See `useLaneGitRemote`. */ + laneGitRemoteStatus: LaneGitRemoteStatus; + laneGitRemoteError: string | null; }; /** @@ -26,6 +30,8 @@ export function useCursorCloudDraftState({ laneId, laneGitRemote, laneGitBranch, + laneGitRemoteStatus, + laneGitRemoteError, }: UseCursorCloudDraftStateInput) { const [cursorCloudMode, setCursorCloudMode] = useState(false); const [cursorCloudAutoPr, setCursorCloudAutoPr] = useState(false); @@ -158,10 +164,26 @@ export function useCursorCloudDraftState({ return repoState.urls.find((url) => repoMatchKey(url) === target) ?? null; }, [laneGitRemote, repoState]); + /** + * Every reason names the thing that is actually true right now. The lane + * remote is read asynchronously and can fail, so "no GitHub remote" is only + * said once the read finished and came back empty — a pending or failed read + * gets its own sentence, and the failed one can be retried. + */ const cursorCloudUnavailableReason = useMemo(() => { if (!cursorCloudAvailable) return null; if (repoState.status === "loading") return "Checking Cursor Cloud…"; if (repoState.status === "error") return repoState.message; + // Unreachable while the pane gates `cursorCloudAvailable` on a lane, but the + // hook must not blame a missing remote for a missing lane if that changes. + if (!laneId) return "Choose a lane before sending to Cursor Cloud."; + if (laneGitRemoteStatus === "idle" || laneGitRemoteStatus === "loading") { + return "Checking this lane's git remote…"; + } + if (laneGitRemoteStatus === "error") { + const detail = laneGitRemoteError?.trim() || "The git remote read failed."; + return `Could not read this lane's git remote: ${detail}`; + } if (!laneGitRemote) { return "This lane has no GitHub remote, so there is nothing for Cursor Cloud to clone."; } @@ -169,11 +191,25 @@ export function useCursorCloudDraftState({ return "This repo is not connected to Cursor. Connect it in Cursor, then try again."; } return null; - }, [cursorCloudAvailable, cursorCloudRepoUrl, laneGitRemote, repoState]); + }, [ + cursorCloudAvailable, + cursorCloudRepoUrl, + laneGitRemote, + laneGitRemoteError, + laneGitRemoteStatus, + laneId, + repoState, + ]); + // Cloud mode drops only on a definitive reason. A probe that is merely in + // flight (the repo list, or the remote of a lane the user just switched to) + // keeps the mode: the send control is disabled by the reason text meanwhile, + // and turning the mode off would make the user re-pick Cursor Cloud after + // every lane change. + const cursorCloudProbesPending = repoState.status === "loading" || laneGitRemoteStatus === "loading"; useEffect(() => { - if (cursorCloudMode && cursorCloudUnavailableReason) setCursorCloudMode(false); - }, [cursorCloudMode, cursorCloudUnavailableReason]); + if (cursorCloudMode && cursorCloudUnavailableReason && !cursorCloudProbesPending) setCursorCloudMode(false); + }, [cursorCloudMode, cursorCloudProbesPending, cursorCloudUnavailableReason]); return { cursorCloudMode, diff --git a/apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.test.ts b/apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.test.ts new file mode 100644 index 0000000000..94424ade1f --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { AgentChatModelCatalog } from "../../../shared/types"; +import { createDynamicCursorCliModelDescriptor } from "../../../shared/modelRegistry"; +import { descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; +import { + rememberRuntimeCatalog, + resetModelPickerRuntimeCatalogForTests, +} from "../shared/ModelPicker/runtimeCatalogCache"; +import { cursorCloudEligibleModelIds, runtimeCatalogModelIds } from "./useCursorCloudModelEligibility"; + +/** + * The cloud eligibility rule, tested without rendering `AgentChatPane`. + * + * Cursor's verified SDK catalog decides what the cloud accepts and it arrives + * asynchronously, so the rule has to distinguish "Cursor told us this model is + * CLI-only" from "we do not know yet". + */ +const SCOPE = "local:/tmp/eligibility-under-test"; + +function seedCursorCatalog(): { cliOnlyId: string; sdkOnlyId: string; unknownId: string } { + const cliOnly = createDynamicCursorCliModelDescriptor("cli-only", "Cursor CLI Only", { + cursorAvailability: { cli: true, sdk: false }, + }); + const sdkOnly = createDynamicCursorCliModelDescriptor("sdk-only", "Cursor Chat Only", { + cursorAvailability: { cli: false, sdk: true }, + }); + // No `cursorAvailability` at all: the catalog has not told us either way yet. + const unknown = createDynamicCursorCliModelDescriptor("availability-unknown", "Cursor Unknown"); + const models = [cliOnly, sdkOnly, unknown]; + const catalog = { + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [{ + key: "cursor", + displayName: "Cursor", + providers: [{ + key: "cursor", + displayName: "Cursor", + badgeColor: "#8B5CF6", + modelCount: models.length, + subsections: [{ + key: "cursor", + label: "Cursor", + models: models.map((model, index) => ({ + id: model.id, + runtimeModelId: model.providerModelId, + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: model.displayName, + isDefault: index === 1, + isAvailable: true, + cursorAvailability: model.cursorAvailability, + })), + }], + }], + }], + } as AgentChatModelCatalog; + rememberRuntimeCatalog(catalog, { mode: "cached", scopeKey: SCOPE }); + descriptorsFromAgentChatModelCatalog(catalog, undefined, SCOPE); + return { cliOnlyId: cliOnly.id, sdkOnlyId: sdkOnly.id, unknownId: unknown.id }; +} + +describe("cursorCloudEligibleModelIds", () => { + beforeEach(() => { + resetModelPickerRuntimeCatalogForTests(); + }); + + it("keeps a Cursor model whose SDK availability is still unknown", () => { + const { unknownId } = seedCursorCatalog(); + expect(cursorCloudEligibleModelIds([unknownId], SCOPE)).toEqual([unknownId]); + }); + + it("excludes a Cursor model Cursor reports as CLI-only", () => { + const { cliOnlyId, sdkOnlyId } = seedCursorCatalog(); + expect(cursorCloudEligibleModelIds([cliOnlyId, sdkOnlyId], SCOPE)).toEqual([sdkOnlyId]); + }); + + it("excludes non-Cursor models", () => { + seedCursorCatalog(); + expect(cursorCloudEligibleModelIds(["anthropic/claude-sonnet-5", "openai/gpt-5.4"], SCOPE)).toEqual([]); + }); + + it("keeps a cursor/ id the catalog has never reported", () => { + // The registry resolves any `cursor/` to a Cursor descriptor carrying no + // availability flags. That is the cold-start case, and it stays eligible: the + // main process rejects it later if Cursor turns out not to run it. + seedCursorCatalog(); + expect(cursorCloudEligibleModelIds(["cursor/never-reported"], SCOPE)).toEqual(["cursor/never-reported"]); + }); + + it("de-duplicates candidates and keeps the order they were offered in", () => { + const { sdkOnlyId, unknownId } = seedCursorCatalog(); + expect(cursorCloudEligibleModelIds( + [unknownId, sdkOnlyId, unknownId], + SCOPE, + )).toEqual([unknownId, sdkOnlyId]); + }); + + it("returns nothing for a scope with no runtime catalog", () => { + seedCursorCatalog(); + expect(runtimeCatalogModelIds("local:/tmp/some-other-machine")).toEqual([]); + }); + + it("reports the runtime catalog's own ids for its scope", () => { + const { cliOnlyId, sdkOnlyId, unknownId } = seedCursorCatalog(); + const ids = runtimeCatalogModelIds(SCOPE); + expect(ids).toContain(cliOnlyId); + expect(ids).toContain(sdkOnlyId); + expect(ids).toContain(unknownId); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.ts b/apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.ts new file mode 100644 index 0000000000..6a27837e1a --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/useCursorCloudModelEligibility.ts @@ -0,0 +1,122 @@ +import { useEffect, useMemo } from "react"; +import { + descriptorsFromAgentChatModelCatalog, + resolveModelDescriptorWithRuntimeCatalog, +} from "../shared/ModelPicker/modelCatalog"; +import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache"; + +/** + * The dynamic model ids the runtime catalog reports for one machine scope + * (Ollama, LM Studio, OpenCode, Cursor). + * + * Always read the composer's OWN machine scope. Reading the bound machine's + * catalog offers models the target machine cannot run. + */ +export function runtimeCatalogModelIds(scopeKey: string): string[] { + const catalog = getSharedRuntimeCatalog(scopeKey); + if (!catalog) return []; + return descriptorsFromAgentChatModelCatalog(catalog, undefined, scopeKey).availableModelIds; +} + +/** + * The models Cursor Cloud is allowed to run, taken from `candidateIds` in order + * and de-duplicated. + * + * Cursor's own SDK catalog is the authority on which models the cloud accepts, + * and that catalog arrives asynchronously. Exclude only the models Cursor has + * told us are CLI-only. A Cursor model whose availability is still unknown stays + * eligible, so a cold start does not empty the cloud picker and block every + * cloud send. The main process still fails closed: `createCursorCloudRun` throws + * when it cannot resolve the model in the verified SDK catalog, so an unknown + * model that turns out to be CLI-only is rejected there with a real error + * instead of being silently swapped here. + */ +export function cursorCloudEligibleModelIds( + candidateIds: Iterable, + scopeKey: string, +): string[] { + const seen = new Set(); + const eligible: string[] = []; + for (const id of candidateIds) { + if (seen.has(id)) continue; + seen.add(id); + if (!id.startsWith("cursor/")) continue; + const descriptor = resolveModelDescriptorWithRuntimeCatalog(id, scopeKey); + if (descriptor?.family !== "cursor") continue; + if (descriptor.cursorAvailability?.sdk === false) continue; + eligible.push(id); + } + return eligible; +} + +type UseCursorCloudModelEligibilityInput = { + availableModelIds: string[]; + /** + * A caller-imposed model constraint. It REPLACES `availableModelIds` rather + * than adding to it, the same way `effectiveAvailableModelIds` reads it. + */ + availableModelIdsOverride?: string[] | null; + modelCatalogScopeKey: string; + modelId: string; + /** Bumped when the shared runtime catalog changes; the memo reads that cache imperatively. */ + runtimeCatalogVersion: number; + cursorCloudMode: boolean; + /** + * Applies the auto-switch to a cloud-capable model. The pane owns every + * draft-state write, including the launch-config ownership marker, so this + * hook never touches draft state itself. + */ + onSwitchModel: (modelId: string) => void; +}; + +/** + * Owns one rule: which Cursor models this draft may send to Cursor Cloud, and + * whether the draft's current model is one of them. + * + * `cursorCloudModelReady` is deliberately separate from "cloud mode is live". + * Folding the model check into the launchable check would make the pane's + * cloud-mode-drop effect turn cloud mode off before the auto-switch below could + * correct the model. + */ +export function useCursorCloudModelEligibility({ + availableModelIds, + availableModelIdsOverride, + modelCatalogScopeKey, + modelId, + runtimeCatalogVersion, + cursorCloudMode, + onSwitchModel, +}: UseCursorCloudModelEligibilityInput): { + cursorCloudModelIds: string[]; + cursorCloudModelReady: boolean; +} { + const cursorCloudModelIds = useMemo( + () => { + const candidateIds: string[] = [...(availableModelIdsOverride ?? availableModelIds)]; + // Keep the draft's own model in the running. Dropping it would make the auto-switch effect + // below reassign a model the user picked, purely because a catalog has not loaded yet. + if (modelId.startsWith("cursor/")) candidateIds.push(modelId); + candidateIds.push(...runtimeCatalogModelIds(modelCatalogScopeKey)); + return cursorCloudEligibleModelIds(candidateIds, modelCatalogScopeKey); + }, + [availableModelIds, availableModelIdsOverride, modelCatalogScopeKey, modelId, runtimeCatalogVersion], + ); + + useEffect(() => { + if (!cursorCloudMode || !cursorCloudModelIds.length || cursorCloudModelIds.includes(modelId)) return; + // A CLI-only Cursor draft can enter cloud mode with its current model still + // selected for one render. Move it to the first SDK-capable model before the + // send control becomes usable; a cloud request must never fall back to + // Cursor's implicit/default model. Only a model Cursor reports as CLI-only + // reaches this line: `cursorCloudModelIds` keeps a model of unknown + // availability, so an unloaded catalog never reassigns the user's choice. + onSwitchModel(cursorCloudModelIds[0]!); + }, [cursorCloudMode, cursorCloudModelIds, modelId, onSwitchModel]); + + // True when the draft's model does not block a cloud send. Read only while + // cloud mode is active, so an ineligible model outside cloud mode is not a + // blocked state. + const cursorCloudModelReady = !cursorCloudMode || cursorCloudModelIds.includes(modelId); + + return { cursorCloudModelIds, cursorCloudModelReady }; +} diff --git a/apps/desktop/src/renderer/components/chat/useLaneGitRemote.test.ts b/apps/desktop/src/renderer/components/chat/useLaneGitRemote.test.ts new file mode 100644 index 0000000000..8a924ac322 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/useLaneGitRemote.test.ts @@ -0,0 +1,253 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +import type { OpenProjectBinding } from "../../../shared/types/core"; +import { useLaneGitRemote } from "./useLaneGitRemote"; + +type OriginRemote = { remoteUrl: string | null; branch: string | null }; + +let getOriginRemote: ReturnType; + +function installGitBridge(implementation?: ReturnType) { + getOriginRemote = implementation ?? vi.fn(); + (window as unknown as { ade: unknown }).ade = { + git: { getOriginRemote }, + }; +} + +/** + * Advances fake timers and flushes the microtask queue React needs to apply the + * state the resolved promise sets. `vi.advanceTimersByTime` alone leaves the + * `.then` callback queued, so the assertion after it would read stale state. + */ +async function advance(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +/** Flushes pending promise callbacks without moving the clock. */ +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useLaneGitRemote", () => { + beforeEach(() => { + vi.useFakeTimers(); + installGitBridge(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete (window as unknown as { ade?: unknown }).ade; + }); + + it("stays idle and reads nothing without a lane", async () => { + const { result } = renderHook(() => useLaneGitRemote(null)); + + expect(result.current.status).toBe("idle"); + expect(result.current.remoteUrl).toBeNull(); + expect(result.current.branch).toBeNull(); + expect(result.current.error).toBeNull(); + expect(getOriginRemote).not.toHaveBeenCalled(); + }); + + it("reports loading, then the remote it read", async () => { + let resolveRead: ((value: OriginRemote) => void) | null = null; + getOriginRemote.mockImplementation(() => new Promise((resolve) => { + resolveRead = resolve; + })); + + const { result } = renderHook(() => useLaneGitRemote("lane-1")); + + expect(result.current.status).toBe("loading"); + expect(getOriginRemote).toHaveBeenCalledWith({ laneId: "lane-1" }); + + await act(async () => { + resolveRead?.({ remoteUrl: "git@github.com:acme/project.git", branch: "ade/feature" }); + await Promise.resolve(); + }); + + expect(result.current.status).toBe("ready"); + expect(result.current.remoteUrl).toBe("git@github.com:acme/project.git"); + expect(result.current.branch).toBe("ade/feature"); + expect(result.current.error).toBeNull(); + }); + + it("reports a lane with no remote as ready and empty, not as a failure", async () => { + getOriginRemote.mockResolvedValue({ remoteUrl: null, branch: "main" }); + + const { result } = renderHook(() => useLaneGitRemote("lane-1")); + await flush(); + + expect(result.current.status).toBe("ready"); + expect(result.current.remoteUrl).toBeNull(); + expect(result.current.branch).toBe("main"); + }); + + it("passes the runtime pin when the caller supplies one", async () => { + getOriginRemote.mockResolvedValue({ remoteUrl: null, branch: null }); + const pin = { kind: "remote", key: "remote:studio:project-a" } as unknown as OpenProjectBinding; + + renderHook(() => useLaneGitRemote("lane-1", pin)); + await flush(); + + expect(getOriginRemote).toHaveBeenCalledWith({ laneId: "lane-1" }, pin); + }); + + it("surfaces the failure message and clears it on the first automatic retry", async () => { + getOriginRemote + .mockRejectedValueOnce(new Error("Error invoking remote method 'git:getOriginRemote': git is not installed")) + .mockResolvedValue({ remoteUrl: "git@github.com:acme/project.git", branch: "main" }); + + const { result } = renderHook(() => useLaneGitRemote("lane-1")); + await flush(); + + expect(result.current.status).toBe("error"); + // The IPC wrapper is stripped so the reason reads as the real cause. + expect(result.current.error).toBe("git is not installed"); + expect(result.current.remoteUrl).toBeNull(); + + await advance(1_000); + + expect(getOriginRemote).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe("ready"); + expect(result.current.error).toBeNull(); + expect(result.current.remoteUrl).toBe("git@github.com:acme/project.git"); + }); + + it("backs off 1s, 3s and 8s and then stops retrying", async () => { + getOriginRemote.mockRejectedValue(new Error("origin unreachable")); + + const { result } = renderHook(() => useLaneGitRemote("lane-1")); + await flush(); + expect(getOriginRemote).toHaveBeenCalledTimes(1); + + await advance(999); + expect(getOriginRemote).toHaveBeenCalledTimes(1); + await advance(1); + expect(getOriginRemote).toHaveBeenCalledTimes(2); + + await advance(2_999); + expect(getOriginRemote).toHaveBeenCalledTimes(2); + await advance(1); + expect(getOriginRemote).toHaveBeenCalledTimes(3); + + await advance(7_999); + expect(getOriginRemote).toHaveBeenCalledTimes(3); + await advance(1); + expect(getOriginRemote).toHaveBeenCalledTimes(4); + + // Four attempts is the whole budget. The hook now waits for `refetch()`. + await advance(60_000); + expect(getOriginRemote).toHaveBeenCalledTimes(4); + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("origin unreachable"); + }); + + it("restarts the whole sequence from refetch", async () => { + getOriginRemote.mockRejectedValue(new Error("origin unreachable")); + + const { result } = renderHook(() => useLaneGitRemote("lane-1")); + await flush(); + await advance(1_000); + await advance(3_000); + await advance(8_000); + expect(getOriginRemote).toHaveBeenCalledTimes(4); + + getOriginRemote.mockResolvedValue({ remoteUrl: "git@github.com:acme/project.git", branch: "main" }); + await act(async () => { + result.current.refetch(); + await Promise.resolve(); + }); + + expect(getOriginRemote).toHaveBeenCalledTimes(5); + await flush(); + expect(result.current.status).toBe("ready"); + expect(result.current.remoteUrl).toBe("git@github.com:acme/project.git"); + }); + + it("drops a slow answer for the lane the user left", async () => { + const pending: Array<(value: OriginRemote) => void> = []; + getOriginRemote.mockImplementation(() => new Promise((resolve) => { + pending.push(resolve); + })); + + const { result, rerender } = renderHook( + ({ laneId }: { laneId: string }) => useLaneGitRemote(laneId), + { initialProps: { laneId: "lane-1" } }, + ); + + rerender({ laneId: "lane-2" }); + expect(result.current.status).toBe("loading"); + expect(result.current.remoteUrl).toBeNull(); + + // lane-1's read lands late. It must not become lane-2's answer. + await act(async () => { + pending[0]?.({ remoteUrl: "git@github.com:acme/lane-one.git", branch: "lane-one" }); + await Promise.resolve(); + }); + expect(result.current.status).toBe("loading"); + expect(result.current.remoteUrl).toBeNull(); + + await act(async () => { + pending[1]?.({ remoteUrl: "git@github.com:acme/lane-two.git", branch: "lane-two" }); + await Promise.resolve(); + }); + expect(result.current.status).toBe("ready"); + expect(result.current.remoteUrl).toBe("git@github.com:acme/lane-two.git"); + expect(result.current.branch).toBe("lane-two"); + }); + + it("cancels a scheduled retry when the lane changes", async () => { + getOriginRemote.mockRejectedValue(new Error("origin unreachable")); + + const { rerender } = renderHook( + ({ laneId }: { laneId: string }) => useLaneGitRemote(laneId), + { initialProps: { laneId: "lane-1" } }, + ); + await flush(); + expect(getOriginRemote).toHaveBeenCalledTimes(1); + + rerender({ laneId: "lane-2" }); + await flush(); + expect(getOriginRemote).toHaveBeenLastCalledWith({ laneId: "lane-2" }); + const callsAfterSwitch = getOriginRemote.mock.calls.length; + + // Only lane-2's own retry may fire. Two new calls would mean lane-1's + // pending timer survived the switch. + await advance(1_000); + expect(getOriginRemote.mock.calls.length).toBe(callsAfterSwitch + 1); + expect(getOriginRemote).toHaveBeenLastCalledWith({ laneId: "lane-2" }); + }); + + it("stops retrying after unmount", async () => { + getOriginRemote.mockRejectedValue(new Error("origin unreachable")); + + const { unmount } = renderHook(() => useLaneGitRemote("lane-1")); + await flush(); + expect(getOriginRemote).toHaveBeenCalledTimes(1); + + unmount(); + await advance(20_000); + expect(getOriginRemote).toHaveBeenCalledTimes(1); + }); + + it("says so when the window has no git bridge at all", async () => { + (window as unknown as { ade: unknown }).ade = {}; + + const { result } = renderHook(() => useLaneGitRemote("lane-1")); + await flush(); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("Git is unavailable in this window."); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/useLaneGitRemote.ts b/apps/desktop/src/renderer/components/chat/useLaneGitRemote.ts new file mode 100644 index 0000000000..23be2523a6 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/useLaneGitRemote.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { stripElectronErrorWrapper } from "../../../shared/codedError"; +import type { OpenProjectBinding } from "../../../shared/types/core"; + +export type LaneGitRemoteStatus = "idle" | "loading" | "ready" | "error"; + +export type LaneGitRemoteState = { + remoteUrl: string | null; + branch: string | null; + status: LaneGitRemoteStatus; + error: string | null; + /** Restarts the read from the first attempt and clears any pending retry. */ + refetch: () => void; +}; + +/** + * Backoff between automatic retries, in milliseconds. Three retries, then the + * hook stops and waits for `refetch()`. + */ +export const LANE_GIT_REMOTE_RETRY_DELAYS_MS = [1_000, 3_000, 8_000] as const; + +const NO_GIT_BRIDGE_MESSAGE = "Git is unavailable in this window."; + +/** + * Reads one lane's origin remote and current branch, and says which of the four + * things is true: nothing asked yet, a read in flight, a finished read, or a + * failed read with its message. + * + * The single boolean this replaces could not tell "this lane has no remote" + * apart from "the read failed or has not finished". A transient failure at + * window start therefore disabled Cursor Cloud with the sentence "This lane has + * no GitHub remote", for a lane that has one, until the user switched lanes. + * + * A failed read retries on its own (1 s, 3 s, 8 s) while the lane stays + * selected, then stops. `status` stays "error" across those retries so the + * message the user reads does not flicker between the failure and "checking". + * Every read is cancelled on a lane change and on unmount, so a slow answer for + * the previous lane can never overwrite the current lane's values. + */ +export function useLaneGitRemote( + laneId: string | null, + pin?: OpenProjectBinding | null, +): LaneGitRemoteState { + const [remoteUrl, setRemoteUrl] = useState(null); + const [branch, setBranch] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + + // The pin object is rebuilt on every render by some callers, so the effect + // keys off its stable `key` instead and reads the object itself from a ref. + // A chat handed to another machine keeps its lane id, so the binding has to + // be part of what restarts the read. + const pinRef = useRef(pin); + pinRef.current = pin; + const pinKey = pin?.key ?? null; + + const refetch = useCallback(() => { + setGeneration((current) => current + 1); + }, []); + + useEffect(() => { + if (!laneId) { + setRemoteUrl(null); + setBranch(null); + setStatus("idle"); + setError(null); + return; + } + let cancelled = false; + let timer: ReturnType | null = null; + + // A new lane means the previous lane's answer is wrong, not stale-but-close. + setRemoteUrl(null); + setBranch(null); + setError(null); + setStatus("loading"); + + const run = (attempt: number) => { + const getOriginRemote = window.ade.git?.getOriginRemote; + if (!getOriginRemote) { + setStatus("error"); + setError(NO_GIT_BRIDGE_MESSAGE); + return; + } + const activePin = pinRef.current; + const request = activePin + ? getOriginRemote({ laneId }, activePin) + : getOriginRemote({ laneId }); + void request + .then((info) => { + if (cancelled) return; + setRemoteUrl(info?.remoteUrl ?? null); + setBranch(info?.branch ?? null); + setError(null); + setStatus("ready"); + }) + .catch((caught: unknown) => { + if (cancelled) return; + const raw = caught instanceof Error ? caught.message : String(caught); + setError(stripElectronErrorWrapper(raw) || "The git remote read failed."); + setStatus("error"); + const delay = LANE_GIT_REMOTE_RETRY_DELAYS_MS[attempt]; + if (delay == null) return; + timer = setTimeout(() => { + timer = null; + if (cancelled) return; + run(attempt + 1); + }, delay); + }); + }; + + run(0); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [generation, laneId, pinKey]); + + return { remoteUrl, branch, status, error, refetch }; +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx index 9ccc94e2ef..3bf06c67ac 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -508,6 +508,17 @@ describe("SessionContextMenu grouped actions", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("hides ADE rename controls for Cursor Cloud agents but keeps status actions", () => { + const onRegenerateMetadata = vi.fn(); + renderMenu(makeSession({ cursorCloudAgentId: "cloud-agent-1" }), { onRegenerateMetadata }); + + expect(screen.queryByRole("button", { name: "Rename" })).toBeNull(); + openSubmenuByHover(screen.getByTestId("session-menu-name-status")); + + expect(screen.queryByRole("button", { name: "Rename…" })).toBeNull(); + expect(screen.getByRole("button", { name: "Generate status line" })).toBeTruthy(); + }); + it("explains why lane-name generation is disabled for the primary lane", () => { const onRegenerateMetadata = vi.fn(); renderMenu(makeSession(), { onRegenerateMetadata, laneType: "primary" }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index cee640bc5d..521df97dbd 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -32,7 +32,7 @@ import type { TerminalSessionSummary, } from "../../../shared/types"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; -import { isChatToolType } from "../../lib/sessions"; +import { cursorOwnsSessionName, isChatToolType } from "../../lib/sessions"; import { sessionCanonicalUiState, sessionIsMidFlight } from "../../lib/terminalAttention"; import { useSessionMetadataGenerating } from "../../state/sessionMetadataGeneratingStore"; import { @@ -297,6 +297,7 @@ function SessionContextMenuPanel({ const menuPosition = clampedPosition ?? { left: x, top: y }; const isRunning = session.status === "running"; const isChat = isChatToolType(session.toolType); + const isCursorCloud = cursorOwnsSessionName(session); const isPrimaryLane = laneType === "primary"; const isRegeneratingMetadata = Boolean(useSessionMetadataGenerating(session.id)); const canonicalPhase = sessionCanonicalUiState(session).phase; @@ -407,7 +408,11 @@ function SessionContextMenuPanel({ const deletingLabel = deletingSessionId === session.id ? "Deleting…" : null; - const showNameStatusSubmenu = !tagging && isChat && Boolean(onRegenerateMetadata); + const metadataActions = SESSION_METADATA_GENERATION_ACTIONS.filter((action) => { + if (!isCursorCloud) return true; + return !action.fields.includes("title") && !action.primaryFields?.includes("title"); + }); + const showNameStatusSubmenu = !tagging && isChat && Boolean(onRegenerateMetadata) && metadataActions.length > 0; const renderNameStatusContent = (): ReactNode => { if (renaming) return renameInput; const regenerateMetadata = onRegenerateMetadata; @@ -415,20 +420,24 @@ function SessionContextMenuPanel({ return ( <> - - - {SESSION_METADATA_GENERATION_ACTIONS.map((action) => { + {!isCursorCloud ? ( + <> + + + + ) : null} + {metadataActions.map((action) => { const label = isPrimaryLane && action.primaryLabel ? action.primaryLabel : action.label; const fields = isPrimaryLane && action.primaryFields ? action.primaryFields : action.fields; const disabled = isRegeneratingMetadata || (action.laneNameOnly === true && isPrimaryLane); @@ -454,17 +463,17 @@ function SessionContextMenuPanel({ if (showNameStatusSubmenu) { return ( } className={MENU_ITEM_CLASS} data-testid="session-menu-name-status" - title="Rename this chat or refresh its visible metadata with AI" + title={isCursorCloud ? "Refresh status metadata; rename this agent on cursor.com" : "Rename this chat or refresh its visible metadata with AI"} > {renderNameStatusContent()} ); } - if (!renaming && !tagging) { + if (!isCursorCloud && !renaming && !tagging) { return (