diff --git a/README.md b/README.md index 175e52d9de46..2b1aa7ff287f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` -> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` +> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`. To run threads as Cursor Cloud agents instead, add a Cursor API key as `CURSOR_API_KEY` in Cursor's provider settings. > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d4a845c197a0..ce9975bb49c1 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,3 +1,4 @@ +import { cloudProviderSnapshot } from "@t3tools/client-runtime/provider-execution"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; @@ -268,6 +269,17 @@ export function ComposerSurface(props: { } export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const runsInCloud = props.selectedThread.executionTarget === "cloud"; + const threadServerConfig = useMemo( + () => + runsInCloud && props.serverConfig + ? { + ...props.serverConfig, + providers: props.serverConfig.providers.map(cloudProviderSnapshot), + } + : props.serverConfig, + [props.serverConfig, runsInCloud], + ); const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); const { themeVariables: materialTheme } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; @@ -321,15 +333,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const currentRuntimeMode = props.selectedThread.runtimeMode; const modelUnavailable = props.connectionState === "connected" && - isModelSelectionUnavailable(props.serverConfig, currentModelSelection); + isModelSelectionUnavailable(threadServerConfig, currentModelSelection); const selectedProviderStatus = useMemo(() => { - if (!props.serverConfig) return null; + if (!threadServerConfig) return null; return ( - props.serverConfig.providers.find( + threadServerConfig.providers.find( (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, ) ?? null ); - }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + }, [threadServerConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { Keyboard.dismiss(); @@ -350,15 +362,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus !== null && hasProviderUsageLimits( selectedProviderStatus.driver, - props.serverConfig?.providers ?? [], - props.serverConfig?.usageLimitSources ?? [], + threadServerConfig?.providers ?? [], + threadServerConfig?.usageLimitSources ?? [], ); // Answered locally from the last Limits snapshot; the agent never sees it. const openUsageLimits = useCallback(() => { const report = collectProviderUsageLimits( currentModelSelection.instanceId, - props.serverConfig?.providers ?? [], - props.serverConfig?.usageLimitSources ?? [], + threadServerConfig?.providers ?? [], + threadServerConfig?.usageLimitSources ?? [], Date.now(), ); onShowUsageLimits(report); @@ -366,7 +378,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); } return report !== null; - }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); + }, [currentModelSelection.instanceId, onShowUsageLimits, threadServerConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, @@ -520,8 +532,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( - () => buildModelOptions(props.serverConfig, currentModelSelection), - [props.serverConfig, currentModelSelection], + () => + buildModelOptions(threadServerConfig, currentModelSelection).filter( + (option) => + !runsInCloud || + (option.selection.instanceId === currentModelSelection.instanceId && + option.selection.model === currentModelSelection.model), + ), + [threadServerConfig, currentModelSelection, runsInCloud], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); // An existing thread is bound to its harness: sessions can't move between @@ -538,11 +556,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null; const providerOptionDescriptors = useMemo( () => - resolveProviderOptionDescriptors({ - capabilities: currentModelOption?.capabilities, - selections: currentModelSelection.options, - }), - [currentModelOption?.capabilities, currentModelSelection.options], + runsInCloud + ? [] + : resolveProviderOptionDescriptors({ + capabilities: currentModelOption?.capabilities, + selections: currentModelSelection.options, + }), + [currentModelOption?.capabilities, currentModelSelection.options, runsInCloud], ); const settingsOwnerId = composerOwnerKey; const settingsRouteSession = useMemo( diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 98896b990ec4..2bc5ef8da138 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,3 +1,4 @@ +import { cloudProviderSnapshot } from "@t3tools/client-runtime/provider-execution"; import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; @@ -13,6 +14,35 @@ import { } from "./modelOptions"; describe("mobile model options", () => { + it("uses cloud models without requiring a local Cursor CLI", () => { + const selection: ModelSelection = { + instanceId: ProviderInstanceId.make("cursor"), + model: "cloud-model", + }; + const config = { + providers: [ + { + instanceId: "cursor", + driver: "cursor", + enabled: true, + installed: false, + auth: { status: "unauthenticated" }, + models: [{ slug: "local-model", name: "Local", isCustom: false, capabilities: null }], + cloud: { + available: true, + models: [{ slug: "cloud-model", name: "Cloud", isCustom: false, capabilities: null }], + }, + }, + ], + } as unknown as ServerConfig; + const cloudConfig = { ...config, providers: config.providers.map(cloudProviderSnapshot) }; + expect(resolveSelectableModelSelection(cloudConfig, selection)).toEqual(selection); + const options = buildModelOptions(cloudConfig, selection); + expect(options).toHaveLength(1); + expect(options[0]).toMatchObject({ label: "Cloud", selection }); + expect(options[0]?.isUnavailable).not.toBe(true); + }); + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1d0b3da1bdbc..3a7a26e4f742 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -11,6 +11,7 @@ import { ProviderRuntimeEvent, ProviderSession, ProviderInstanceId, + type ThreadExecutionTarget, } from "@t3tools/contracts"; import { CommandId, @@ -307,6 +308,7 @@ describe("CheckpointReactor", () => { readonly localStatusRefName?: string | null; readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; + readonly executionTarget?: ThreadExecutionTarget; readonly gitStatusRefreshCalls?: Array; readonly pullRequestRefreshCalls?: Array; readonly pullRequestRefresh?: Effect.Effect; @@ -458,6 +460,7 @@ describe("CheckpointReactor", () => { branch: options?.threadBranch ?? null, worktreePath: options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd, + ...(options?.executionTarget ? { executionTarget: options.executionTarget } : {}), createdAt, }) .pipe( @@ -1095,6 +1098,42 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("does not checkpoint the local checkout for a cloud thread's turn", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + executionTarget: "cloud", + }); + const createdAt = "2026-01-01T00:00:00.000Z"; + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-remote-turn-started"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-remote"), + }); + // A local edit during the turn is not the remote agent's work. + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "local edit\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-remote-turn-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-remote"), + payload: { state: "completed" }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + expect(thread?.checkpoints).toEqual([]); + for (const turnCount of [0, 1]) { + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), turnCount)), + ).toBe(false); + } + }); + it("refreshes local git status state on turn completion using the session cwd", async () => { const gitStatusRefreshCalls: string[] = []; const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 7814a9eec9e6..7c330ff72305 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -4,6 +4,7 @@ import { EventId, MessageId, type ProjectId, + type ThreadExecutionTarget, ThreadId, TurnId, type OrchestrationEvent, @@ -205,14 +206,22 @@ const make = Effect.gen(function* () { // Resolves the workspace CWD for checkpoint operations, preferring the // active provider session CWD and falling back to the thread/project config. - // Returns undefined when no CWD can be determined or the workspace is not - // a git repository. + // Returns undefined when no CWD can be determined, the workspace is not + // a git repository, or the thread's agent works in a cloud workspace that + // the local checkout does not reflect. const resolveCheckpointCwd = Effect.fn("resolveCheckpointCwd")(function* (input: { readonly threadId: ThreadId; - readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; + readonly thread: { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly executionTarget?: ThreadExecutionTarget | undefined; + }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; readonly preferSessionRuntime: boolean; }): Effect.fn.Return { + if (input.thread.executionTarget === "cloud") { + return undefined; + } const fromSession = yield* resolveSessionRuntimeForThread(input.threadId); const fromThread = resolveThreadWorkspaceCwd({ thread: input.thread, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1acb3c360b15..d9a0726b7ed4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -619,6 +619,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + executionTarget: event.payload.executionTarget ?? null, linkedPullRequest: null, branchPullRequest: null, latestTurnId: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 07d30e47e27b..209ac5467ba6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -368,6 +368,11 @@ function mapLatestTurn( }; } +// Only cloud threads carry the field; its absence means local. +function mapExecutionTarget(row: Schema.Schema.Type) { + return row.executionTarget === "cloud" ? { executionTarget: "cloud" as const } : {}; +} + function mapTitleRegeneration(row: Schema.Schema.Type) { return row.titleRegenerationRequestId != null && row.titleRegenerationStartedAt != null ? { @@ -574,6 +579,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_target AS "executionTarget", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -622,6 +628,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_target AS "executionTarget", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -697,6 +704,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_target AS "executionTarget", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -1301,6 +1309,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_target AS "executionTarget", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -2373,6 +2382,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapExecutionTarget(row), ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, @@ -2619,6 +2629,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapExecutionTarget(row), ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, @@ -2780,6 +2791,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapExecutionTarget(row), branchPullRequest: row.branchPullRequest, ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], @@ -2966,6 +2978,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapExecutionTarget(row), branchPullRequest: row.branchPullRequest, ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], @@ -3312,6 +3325,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...mapExecutionTarget(threadRow.value), ...mapThreadPullRequests( pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId, @@ -3614,6 +3628,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...mapExecutionTarget(threadRow.value), ...mapThreadPullRequests( pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 87f9bd03d46c..99aaeed70548 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -174,6 +174,7 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly executionTarget?: "cloud"; readonly unreadableHistory?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; @@ -528,6 +529,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + ...(input?.executionTarget ? { executionTarget: input.executionTarget } : {}), createdAt: now, }), ); @@ -2969,12 +2971,15 @@ describe("ProviderCommandReactor", () => { }); }); - effectIt.effect( - "rejects changing models after start when the provider requires a new thread", - () => + for (const restriction of ["provider", "cloud"] as const) { + effectIt.effect(`rejects changing models after start for ${restriction} threads`, () => Effect.gen(function* () { const harness = yield* Effect.promise(() => - createHarness({ requiresNewThreadForModelChange: true }), + createHarness( + restriction === "cloud" + ? { executionTarget: "cloud" } + : { requiresNewThreadForModelChange: true }, + ), ); const now = "2026-01-01T00:00:00.000Z"; @@ -2993,7 +2998,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* Effect.promise(() => harness.drain()); yield* harness.engine.dispatch({ type: "thread.turn.start", @@ -3014,19 +3019,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }); - yield* Effect.promise(() => - waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.make("thread-1"), - ); - return ( - thread?.activities.some( - (activity) => activity.kind === "provider.turn.start.failed", - ) ?? false - ); - }), - ); + yield* Effect.promise(() => harness.drain()); expect(harness.sendTurn).toHaveBeenCalledTimes(1); const readModel = yield* Effect.promise(() => harness.readModel()); @@ -3041,7 +3034,8 @@ describe("ProviderCommandReactor", () => { }, }); }), - ); + ); + } it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 77022a8518d7..40b8f1f7346f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,6 +11,7 @@ import { ThreadId, type ProviderSession, type RuntimeMode, + type ThreadExecutionTarget, type TurnId, } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; @@ -539,6 +540,7 @@ const make = Effect.gen(function* () { const rejectStartedThreadModelChangeIfRequired = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; + readonly executionTarget: ThreadExecutionTarget | undefined; readonly currentModelSelection: ModelSelection; readonly requestedModelSelection: ModelSelection | undefined; }) { @@ -552,6 +554,7 @@ const make = Effect.gen(function* () { } const providers = yield* providerRegistry.getProviders; const requiresNewThread = + input.executionTarget === "cloud" || providers.find((snapshot) => snapshot.instanceId === input.currentModelSelection.instanceId) ?.requiresNewThreadForModelChange === true || providers.find((snapshot) => snapshot.instanceId === requestedModelSelection.instanceId) @@ -671,6 +674,7 @@ const make = Effect.gen(function* () { if (thread.session !== null) { yield* rejectStartedThreadModelChangeIfRequired({ threadId, + executionTarget: thread.executionTarget, currentModelSelection: activeSession?.model !== undefined ? { @@ -737,6 +741,12 @@ const make = Effect.gen(function* () { ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(sessionTitle ? { title: sessionTitle } : {}), modelSelection: desiredModelSelection, + ...(thread.executionTarget === "cloud" + ? { + executionTarget: "cloud" as const, + ...(thread.branch ? { branch: thread.branch } : {}), + } + : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9b5b56309749..062866895fc9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4455,6 +4455,34 @@ describe("ProviderRuntimeIngestion", () => { expect(thread?.title).toBe("User-set title"); }); + it("links a pull request the provider reports opening", async () => { + const harness = await createHarness(); + const report = (eventId: string) => + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId(eventId), + provider: ProviderDriverKind.make("cursor"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + payload: { pullRequestUrl: "https://github.com/Acme/Widgets/pull/42" }, + }); + + report("evt-pr-reported"); + // A second report of the same PR is a no-op, not an ingestion failure. + report("evt-pr-reported-again"); + + const thread = await waitForThread(harness.readModel, (entry) => entry.pullRequests.length > 0); + await harness.drain(); + expect((await harness.readModel()).threads[0]?.pullRequests).toHaveLength(1); + expect(thread.pullRequests[0]).toMatchObject({ + host: "github.com", + repository: "acme/widgets", + number: 42, + url: "https://github.com/Acme/Widgets/pull/42", + source: "agent", + }); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 072ac5110b9d..076f4355be87 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -30,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Stream from "effect/Stream"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { formatTokens } from "@t3tools/shared/usageFormat"; @@ -2471,6 +2472,26 @@ const make = Effect.gen(function* () { } } + if (event.type === "thread.metadata.updated" && event.payload.pullRequestUrl) { + const url = event.payload.pullRequestUrl; + const pullRequest = parseChangeRequestUrl(url); + if (pullRequest) { + yield* orchestrationEngine + .dispatch({ + type: "thread.pull-request.link", + commandId: yield* providerCommandId(event, "pull-request-link"), + threadId: thread.id, + host: pullRequest.host, + repository: pullRequest.repository, + number: pullRequest.number, + url, + source: "agent", + }) + // The decider rejects a second link of the same PR; the thread already has it. + .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void })); + } + } + if (event.type === "task.started" || event.type === "task.progress") { const description = event.payload.description?.trim(); if (description) { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 21df39a162e6..8734433ee858 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -404,6 +404,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.executionTarget !== undefined + ? { executionTarget: command.executionTarget } + : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index dd418b95b9bc..c8c0bdd3eff4 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -445,6 +445,9 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + ...(payload.executionTarget !== undefined + ? { executionTarget: payload.executionTarget } + : {}), pullRequests: [], branchPullRequest: null, latestTurn: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 595bc0b5594c..294ff51f2ffd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -40,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + execution_target, linked_pull_request_json, branch_pull_request_json, latest_turn_id, @@ -73,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.executionTarget ?? null}, ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)}, ${row.latestTurnId}, @@ -106,6 +108,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + execution_target = excluded.execution_target, linked_pull_request_json = excluded.linked_pull_request_json, branch_pull_request_json = excluded.branch_pull_request_json, latest_turn_id = excluded.latest_turn_id, @@ -146,6 +149,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_target AS "executionTarget", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c837ae3f4c50..4e63b9363ec9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -66,6 +66,7 @@ import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; +import Migration0055 from "./Migrations/055_ProjectionThreadsExecutionTarget.ts"; /** * Migration loader with all migrations defined inline. @@ -132,6 +133,7 @@ const migrationEntries = [ [52, "ProjectionThreadTitleState", Migration0052], [53, "PullRequestFilesViewed", Migration0053], [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054], + [55, "ProjectionThreadsExecutionTarget", Migration0055], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_ProjectionThreadsExecutionTarget.ts b/apps/server/src/persistence/Migrations/055_ProjectionThreadsExecutionTarget.ts new file mode 100644 index 000000000000..06cfe6024570 --- /dev/null +++ b/apps/server/src/persistence/Migrations/055_ProjectionThreadsExecutionTarget.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "execution_target")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN execution_target TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index abd964382a09..f226dd36e260 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadExecutionTarget, ThreadLinkedPullRequest, ThreadTitleState, ThreadId, @@ -36,6 +37,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + executionTarget: Schema.optional(Schema.NullOr(ThreadExecutionTarget)), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 521c1b943e84..f1ef0b7edd62 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -9,6 +9,10 @@ * drives `runtime.prompt` with a structured-output schema and collects the * agent's `agent_message_chunk` stream into a single JSON blob. * + * Threads created with the `cloud` execution target run as Cursor Cloud + * Agents through Cursor's HTTP API instead of the CLI; the snapshot's `cloud` + * field reports whether the instance's API key allows that. + * * @module provider/Drivers/CursorDriver */ import { CursorSettings, ProviderDriverKind } from "@t3tools/contracts"; @@ -26,6 +30,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; +import { makeCursorCloudAdapter, routeCursorExecution } from "../Layers/CursorCloudAdapter.ts"; +import { checkCursorCloudStatus } from "../Layers/CursorCloudProvider.ts"; import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts"; import { buildInitialCursorProviderSnapshot, @@ -158,6 +164,13 @@ export const CursorDriver: ProviderDriver = { return { ...snapshot, usageLimits }; }), ), + // Cloud threads need only an API key, so they are offered even when + // the local CLI is missing or signed out. + Effect.filterOrElse( + () => !effectiveConfig.enabled, + (snapshot) => + Effect.map(checkCursorCloudStatus(processEnv), (cloud) => ({ ...snapshot, cloud })), + ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(Crypto.Crypto, crypto), @@ -208,7 +221,7 @@ export const CursorDriver: ProviderDriver = { const { snapshot, onAvailableCommands, snapshotForCwd } = yield* makeCursorCommandCatalog(managedSnapshot); - const adapter = yield* makeCursorAdapter(effectiveConfig, { + const localAdapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, @@ -219,6 +232,11 @@ export const CursorDriver: ProviderDriver = { Effect.flatMap((skills) => onAvailableCommands(commands, cwd, skills)), ), }); + const cloudAdapter = yield* makeCursorCloudAdapter(effectiveConfig, { + environment: processEnv, + instanceId, + }); + const adapter = routeCursorExecution(localAdapter, cloudAdapter); return { instanceId, diff --git a/apps/server/src/provider/Layers/CursorCloudAdapter.test.ts b/apps/server/src/provider/Layers/CursorCloudAdapter.test.ts new file mode 100644 index 000000000000..5a95e1d8cf99 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorCloudAdapter.test.ts @@ -0,0 +1,566 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeChildProcess from "node:child_process"; +import { expect, it } from "@effect/vitest"; +import { + ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { HttpClient, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../../config.ts"; +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { makeCursorCloudAdapter, routeCursorExecution } from "./CursorCloudAdapter.ts"; + +const INSTANCE_ID = ProviderInstanceId.make("cursorCloud"); +const THREAD_ID = ThreadId.make("thread-cloud"); +const SETTINGS = { cloudAutoCreatePR: true }; +const ENVIRONMENT = { ...process.env, CURSOR_API_KEY: "key_test" }; + +interface RecordedRequest { + readonly method: string; + readonly path: string; + readonly lastEventId: string | undefined; + readonly body: unknown; +} + +type Route = (request: RecordedRequest) => Response | undefined; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }); + +const sse = (events: ReadonlyArray<{ id?: string; event: string; data: unknown }>) => + new Response( + events + .map( + (entry) => + `${entry.id ? `id: ${entry.id}\n` : ""}event: ${entry.event}\ndata: ${JSON.stringify(entry.data)}\n\n`, + ) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ); + +function fakeCursorApi(routes: ReadonlyArray) { + const requests: RecordedRequest[] = []; + const decoder = new TextDecoder(); + const client = HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + const body = + request.body._tag === "Uint8Array" + ? JSON.parse(decoder.decode(request.body.body)) + : undefined; + const recorded: RecordedRequest = { + method: request.method, + path: new URL(request.url).pathname, + lastEventId: request.headers["last-event-id"], + body, + }; + requests.push(recorded); + for (const route of routes) { + const response = route(recorded); + if (response) return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + } + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))); + }); + return { client, requests }; +} + +const route = + (method: string, path: string, respond: (request: RecordedRequest) => Response): Route => + (request) => + request.method === method && request.path === path ? respond(request) : undefined; + +const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-cursor-cloud-" }).pipe( + Layer.provideMerge(NodeServices.layer), +); + +/** A checkout on `main` that is already pushed to a GitHub `origin`. */ +const makePushedRepository = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-cloud-repo-" }); + const git = (...args: string[]) => + NodeChildProcess.execFileSync("git", args, { cwd, stdio: "pipe" }).toString().trim(); + git("init", "--quiet", "-b", "main"); + git( + "-c", + "user.name=T3", + "-c", + "user.email=t3@example.com", + "commit", + "--allow-empty", + "-qm", + "init", + ); + git("remote", "add", "origin", "https://github.com/acme/widgets.git"); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + git("config", "branch.main.remote", "origin"); + git("config", "branch.main.merge", "refs/heads/main"); + return cwd; +}); + +const collectUntilTurnCompleted = (streamEvents: Stream.Stream) => + Effect.gen(function* () { + const events: ProviderRuntimeEvent[] = []; + const done = yield* Deferred.make(); + const fiber = yield* Stream.runForEach(streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.type === "turn.completed" ? Deferred.succeed(done, undefined) : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + return { + events, + completed: Deferred.await(done).pipe( + Effect.andThen(Fiber.interrupt(fiber)), + Effect.as(events), + ), + }; + }); + +it.layer(testLayer)("CursorCloudAdapter", (it) => { + it.effect("creates an agent from the chosen branch and streams its run into the thread", () => + Effect.gen(function* () { + const cwd = yield* makePushedRepository; + const api = fakeCursorApi([ + route("POST", "/v1/agents", () => + json({ + agent: { id: "bc-1", url: "https://cursor.com/agents/bc-1", status: "ACTIVE" }, + run: { id: "run-1", agentId: "bc-1", status: "CREATING" }, + }), + ), + route("GET", "/v1/agents/bc-1/runs/run-1/stream", () => + sse([ + { event: "status", data: { runId: "run-1", status: "RUNNING" } }, + { id: "1", event: "assistant", data: { text: "Listing files." } }, + { + id: "2", + event: "tool_call", + data: { + callId: "c1", + name: "run_terminal_cmd", + status: "running", + args: { command: "ls" }, + }, + }, + { + id: "3", + event: "tool_call", + data: { + callId: "c1", + name: "run_terminal_cmd", + status: "completed", + args: { command: "ls" }, + }, + }, + { + id: "4", + event: "result", + data: { + runId: "run-1", + status: "FINISHED", + text: "Listing files.", + git: { + branches: [ + { + repoUrl: "github.com/acme/widgets", + branch: "cursor/list-files", + prUrl: "https://github.com/acme/widgets/pull/7", + }, + ], + }, + }, + }, + { id: "5", event: "done", data: {} }, + ]), + ), + ]); + const adapter = yield* makeCursorCloudAdapter(SETTINGS, { + environment: ENVIRONMENT, + instanceId: INSTANCE_ID, + }).pipe(Effect.provideService(HttpClient.HttpClient, api.client)); + const collector = yield* collectUntilTurnCompleted(adapter.streamEvents); + + yield* adapter.startSession({ + threadId: THREAD_ID, + providerInstanceId: INSTANCE_ID, + cwd, + branch: "main", + runtimeMode: "full-access", + modelSelection: { + instanceId: INSTANCE_ID, + model: "composer-2", + options: [{ id: "fast", value: true }], + }, + }); + const admitted = yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "List the files", + interactionMode: "plan", + }); + expect(admitted.resumeCursor).toMatchObject({ + activeRun: { runId: "run-1", turnId: admitted.turnId }, + }); + const events = yield* collector.completed; + + expect(api.requests.find((request) => request.path === "/v1/agents")?.body).toEqual({ + prompt: { text: "List the files" }, + model: { id: "composer-2", params: [{ id: "fast", value: "true" }] }, + repos: [{ url: "https://github.com/acme/widgets", startingRef: "main" }], + autoCreatePR: true, + mode: "plan", + }); + expect( + events + .filter( + (event) => event.type !== "session.started" && event.type !== "session.state.changed", + ) + .map((event) => event.type), + ).toEqual([ + "thread.started", + "turn.started", + "item.started", + "content.delta", + "item.completed", + "item.started", + "item.completed", + "thread.metadata.updated", + "turn.completed", + ]); + const tool = events.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + expect(tool?.type === "item.completed" ? tool.payload.detail : undefined).toBe("ls"); + const turnCompleted = events.at(-1); + expect( + turnCompleted?.type === "turn.completed" ? turnCompleted.payload.state : undefined, + ).toBe("completed"); + const pullRequest = events.find((event) => event.type === "thread.metadata.updated"); + expect( + pullRequest?.type === "thread.metadata.updated" + ? pullRequest.payload.pullRequestUrl + : undefined, + ).toBe("https://github.com/acme/widgets/pull/7"); + // The settled cursor no longer names a run, so recovery will not reattach to it. + const [session] = yield* adapter.listSessions(); + expect(session?.resumeCursor).toEqual({ + schemaVersion: 1, + kind: "cloud", + agentId: "bc-1", + agentUrl: "https://cursor.com/agents/bc-1", + model: "composer-2", + }); + }).pipe(Effect.scoped), + ); + + it.effect("resumes a dropped run stream from the last event it received", () => + Effect.gen(function* () { + let streamRequests = 0; + const api = fakeCursorApi([ + route("POST", "/v1/agents/bc-1/runs", () => + json({ run: { id: "run-2", agentId: "bc-1", status: "CREATING" } }), + ), + route("GET", "/v1/agents/bc-1/runs/run-2", () => + json({ id: "run-2", agentId: "bc-1", status: "RUNNING" }), + ), + route("GET", "/v1/agents/bc-1/runs/run-2/stream", () => { + streamRequests += 1; + return streamRequests === 1 + ? sse([{ id: "7", event: "assistant", data: { text: "Hel" } }]) + : sse([ + { id: "8", event: "assistant", data: { text: "lo" } }, + { + id: "9", + event: "result", + data: { runId: "run-2", status: "FINISHED", text: "Hello" }, + }, + ]); + }), + ]); + const adapter = yield* makeCursorCloudAdapter(SETTINGS, { + environment: ENVIRONMENT, + instanceId: INSTANCE_ID, + }).pipe(Effect.provideService(HttpClient.HttpClient, api.client)); + const collector = yield* collectUntilTurnCompleted(adapter.streamEvents); + + yield* adapter.startSession({ + threadId: THREAD_ID, + providerInstanceId: INSTANCE_ID, + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, kind: "cloud", agentId: "bc-1" }, + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "Say hello" }); + const events = yield* collector.completed; + + expect( + api.requests + .filter((request) => request.path.endsWith("/stream")) + .map((request) => request.lastEventId), + ).toEqual([undefined, "7"]); + expect( + events.flatMap((event) => (event.type === "content.delta" ? [event.payload.delta] : [])), + ).toEqual(["Hel", "lo"]); + expect(api.requests.find((request) => request.path === "/v1/agents/bc-1/runs")?.body).toEqual( + { + prompt: { text: "Say hello" }, + mode: "agent", + }, + ); + }).pipe(Effect.scoped), + ); + + for (const streamedText of ["Hel", "Hello world."]) { + for (const recovery of ["finished run", "expired cursor"] as const) { + it.effect(`preserves one final reply after streaming ${streamedText} and ${recovery}`, () => + Effect.gen(function* () { + let connections = 0; + const api = fakeCursorApi([ + route("POST", "/v1/agents/bc-1/runs", () => + json({ run: { id: "run-2", agentId: "bc-1", status: "RUNNING" } }), + ), + route("GET", "/v1/agents/bc-1/runs/run-2", () => + json({ + id: "run-2", + agentId: "bc-1", + status: recovery === "finished run" ? "FINISHED" : "RUNNING", + result: "Hello world.", + }), + ), + route("GET", "/v1/agents/bc-1/runs/run-2/stream", () => { + connections += 1; + if (connections === 1) + return sse([{ id: "7", event: "assistant", data: { text: streamedText } }]); + if (connections === 2) return new Response("{}", { status: 410 }); + return sse([ + { id: "7", event: "assistant", data: { text: "Hello world." } }, + { id: "8", event: "result", data: { status: "FINISHED", text: "Hello world." } }, + ]); + }), + ]); + const adapter = yield* makeCursorCloudAdapter(SETTINGS, { + environment: ENVIRONMENT, + instanceId: INSTANCE_ID, + }).pipe(Effect.provideService(HttpClient.HttpClient, api.client)); + const collector = yield* collectUntilTurnCompleted(adapter.streamEvents); + yield* adapter.startSession({ + threadId: THREAD_ID, + providerInstanceId: INSTANCE_ID, + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, kind: "cloud", agentId: "bc-1" }, + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "Say hello" }); + const events = yield* collector.completed; + const completions = events.filter( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + expect(completions).toHaveLength(1); + expect(completions[0]).toMatchObject({ itemId: "run-2:assistant:1" }); + expect( + events + .flatMap((event) => (event.type === "content.delta" ? [event.payload.delta] : [])) + .join(""), + ).toBe("Hello world."); + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { state: "completed" }, + }); + expect( + api.requests + .filter((request) => request.path.endsWith("/stream")) + .map((request) => request.lastEventId), + ).toEqual(recovery === "finished run" ? [undefined] : [undefined, "7", undefined]); + }).pipe(Effect.scoped), + ); + } + } + + it.effect("reattaches to a run left in flight and reports only its result", () => + Effect.gen(function* () { + const api = fakeCursorApi([ + route("GET", "/v1/agents/bc-1/runs/run-9/stream", () => + sse([ + { id: "1", event: "assistant", data: { text: "Already shown before the restart." } }, + { + id: "2", + event: "result", + data: { runId: "run-9", status: "FINISHED", text: "Done." }, + }, + ]), + ), + ]); + const adapter = yield* makeCursorCloudAdapter(SETTINGS, { + environment: ENVIRONMENT, + instanceId: INSTANCE_ID, + }).pipe(Effect.provideService(HttpClient.HttpClient, api.client)); + const collector = yield* collectUntilTurnCompleted(adapter.streamEvents); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + providerInstanceId: INSTANCE_ID, + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { + schemaVersion: 1, + kind: "cloud", + agentId: "bc-1", + activeRun: { runId: "run-9", turnId: "turn-9" }, + }, + }); + expect(session.status).toBe("running"); + const events = yield* collector.completed; + + expect(events.some((event) => event.type === "content.delta")).toBe(false); + const reply = events.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + expect(reply?.type === "item.completed" ? reply.payload.detail : undefined).toBe("Done."); + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: TurnId.make("turn-9"), + }); + }).pipe(Effect.scoped), + ); + + it.effect("cancels the active run when the turn is interrupted", () => + Effect.gen(function* () { + let markCancelled!: () => void; + const cancelled = new Promise((resolve) => { + markCancelled = resolve; + }); + const api = fakeCursorApi([ + route("POST", "/v1/agents/bc-1/runs", () => + json({ run: { id: "run-3", agentId: "bc-1", status: "CREATING" } }), + ), + route("POST", "/v1/agents/bc-1/runs/run-3/cancel", () => { + markCancelled(); + return json({ id: "run-3" }); + }), + // The run only ends once Cursor has been asked to cancel it. + route( + "GET", + "/v1/agents/bc-1/runs/run-3/stream", + () => + new Response( + new ReadableStream({ + start: (controller) => { + void cancelled.then(() => { + controller.enqueue( + new TextEncoder().encode( + `id: 1\nevent: result\ndata: ${JSON.stringify({ runId: "run-3", status: "CANCELLED" })}\n\n`, + ), + ); + controller.close(); + }); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ]); + const adapter = yield* makeCursorCloudAdapter(SETTINGS, { + environment: ENVIRONMENT, + instanceId: INSTANCE_ID, + }).pipe(Effect.provideService(HttpClient.HttpClient, api.client)); + const collector = yield* collectUntilTurnCompleted(adapter.streamEvents); + + yield* adapter.startSession({ + threadId: THREAD_ID, + providerInstanceId: INSTANCE_ID, + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, kind: "cloud", agentId: "bc-1" }, + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "Start something long" }); + yield* adapter.interruptTurn(THREAD_ID); + const events = yield* collector.completed; + + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { state: "cancelled" }, + }); + }).pipe(Effect.scoped), + ); +}); + +function recordingAdapter( + calls: string[], + name: string, +): ProviderAdapterShape { + const live = new Set(); + const record = (method: string) => Effect.sync(() => void calls.push(`${name}.${method}`)); + return { + provider: ProviderDriverKind.make("cursor"), + capabilities: { sessionModelSwitch: "in-session" }, + compaction: { type: "slash-command", command: "/compress" }, + startSession: (input) => + record("startSession").pipe( + Effect.tap(() => Effect.sync(() => live.add(input.threadId))), + Effect.as({ threadId: input.threadId } as ProviderSession), + ), + sendTurn: (input) => + record("sendTurn").pipe(Effect.as({ threadId: input.threadId, turnId: TurnId.make("t") })), + interruptTurn: () => record("interruptTurn"), + respondToRequest: () => record("respondToRequest"), + respondToUserInput: () => record("respondToUserInput"), + stopSession: () => record("stopSession"), + listSessions: () => Effect.succeed([]), + hasSession: (threadId) => Effect.sync(() => live.has(threadId)), + readThread: (threadId) => Effect.succeed({ threadId, turns: [] }), + rollbackThread: (threadId) => Effect.succeed({ threadId, turns: [] }), + stopAll: () => Effect.void, + streamEvents: Stream.empty, + }; +} + +it.effect("routes each Cursor thread to the CLI or the cloud for its whole life", () => + Effect.gen(function* () { + const calls: string[] = []; + const adapter = routeCursorExecution( + recordingAdapter(calls, "local"), + recordingAdapter(calls, "cloud"), + ); + const local = ThreadId.make("thread-local"); + const cloud = ThreadId.make("thread-cloud"); + const resumed = ThreadId.make("thread-resumed"); + + yield* adapter.startSession({ threadId: local, runtimeMode: "full-access" }); + yield* adapter.startSession({ + threadId: cloud, + runtimeMode: "full-access", + executionTarget: "cloud", + }); + // Recovery passes only the persisted cursor, which identifies a cloud thread by itself. + yield* adapter.startSession({ + threadId: resumed, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, kind: "cloud", agentId: "bc-1" }, + }); + yield* adapter.sendTurn({ threadId: local, input: "hi" }); + yield* adapter.interruptTurn(cloud); + const compacted = yield* Effect.flip(adapter.sendTurn({ threadId: cloud, input: "/compress" })); + + expect(calls).toEqual([ + "local.startSession", + "cloud.startSession", + "cloud.startSession", + "local.sendTurn", + "cloud.interruptTurn", + ]); + expect(compacted.message).toMatch(/cannot be compacted/); + }), +); diff --git a/apps/server/src/provider/Layers/CursorCloudAdapter.ts b/apps/server/src/provider/Layers/CursorCloudAdapter.ts new file mode 100644 index 000000000000..c61f6a8535ca --- /dev/null +++ b/apps/server/src/provider/Layers/CursorCloudAdapter.ts @@ -0,0 +1,904 @@ +/** + * CursorCloudAdapter — Cursor Cloud Agents over Cursor's HTTP API. + * + * The first turn creates a cloud agent on the thread's GitHub repository; + * every later turn is a run on that agent. Runs execute in Cursor-hosted VMs, + * so this adapter never touches the local checkout and a run keeps going + * while T3 is disconnected. The resume cursor records the agent and any run + * T3 has not yet seen finish, which is what lets recovery reattach. + * + * @module CursorCloudAdapter + */ +import { + type ChatAttachment, + CURSOR_CLOUD_DEFAULT_MODEL, + type CursorSettings, + EventId, + type ModelSelection, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + ProviderDriverKind, + type ProviderInstanceId, + RuntimeItemId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + type CursorCloudApi, + CursorCloudApiError, + type CursorCloudGit, + type CursorCloudPrompt, + type CursorCloudStreamEvent, + isTerminalRunStatus, + makeCursorCloudApi, +} from "../cursorCloudApi.ts"; +import { resolveCursorCloudRepository } from "../cursorCloudWorkspace.ts"; +import { + type ProviderAdapterError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { + CURSOR_CLOUD_MISSING_KEY_MESSAGE, + resolveCursorCloudApiKey, +} from "./CursorCloudProvider.ts"; + +const PROVIDER = ProviderDriverKind.make("cursor"); +const RESUME_VERSION = 1 as const; +const RESUME_KIND = "cloud" as const; +const MAX_PROMPT_IMAGES = 5; +const MAX_RECONNECT_DELAY_MS = 30_000; + +interface ActiveRun { + readonly runId: string; + readonly turnId: TurnId; +} + +interface RunOutcome { + readonly status: string; + readonly text: string | undefined; + readonly git: CursorCloudGit | undefined; + readonly errorMessage?: string; +} + +/** The run or its agent is gone, or the key no longer works; retrying cannot help. */ +const UNRECOVERABLE_STATUSES = new Set([401, 403, 404]); + +interface ResumeCursor { + readonly schemaVersion: typeof RESUME_VERSION; + /** Distinguishes a cloud cursor from the local Cursor CLI's in the same driver. */ + readonly kind: typeof RESUME_KIND; + readonly agentId: string; + readonly agentUrl?: string; + /** Fixed when the agent is created; a session without it looks like a model change. */ + readonly model?: string; + readonly activeRun?: ActiveRun; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseResumeCursor(raw: unknown): ResumeCursor | undefined { + if (!isRecord(raw) || raw.schemaVersion !== RESUME_VERSION || raw.kind !== RESUME_KIND) { + return undefined; + } + if (typeof raw.agentId !== "string" || raw.agentId.length === 0) return undefined; + const run = isRecord(raw.activeRun) ? raw.activeRun : undefined; + return { + schemaVersion: RESUME_VERSION, + kind: RESUME_KIND, + agentId: raw.agentId, + ...(typeof raw.agentUrl === "string" ? { agentUrl: raw.agentUrl } : {}), + ...(typeof raw.model === "string" ? { model: raw.model } : {}), + ...(typeof run?.runId === "string" && typeof run.turnId === "string" + ? { activeRun: { runId: run.runId, turnId: TurnId.make(run.turnId) } } + : {}), + }; +} + +interface CursorCloudSessionContext { + readonly threadId: ThreadId; + readonly cwd: string; + /** The thread's chosen starting branch; the checkout decides when absent. */ + readonly branch: string | undefined; + readonly api: CursorCloudApi; + readonly lock: Semaphore.Semaphore; + /** Owns run followers; closing it stops tracking without touching the cloud run. */ + readonly scope: Scope.Closeable; + session: ProviderSession; + agentId: string | undefined; + agentUrl: string | undefined; + /** The thread's selection at session start, used if the first turn does not repeat it. */ + readonly modelSelection: ModelSelection | undefined; + activeRun: ActiveRun | undefined; + readonly linkedPullRequests: Set; + readonly turns: Array<{ id: TurnId; items: Array }>; + stopped: boolean; +} + +function resumeCursorFor(ctx: CursorCloudSessionContext): ResumeCursor | undefined { + if (ctx.agentId === undefined) return undefined; + return { + schemaVersion: RESUME_VERSION, + kind: RESUME_KIND, + agentId: ctx.agentId, + ...(ctx.agentUrl ? { agentUrl: ctx.agentUrl } : {}), + ...(ctx.session.model ? { model: ctx.session.model } : {}), + ...(ctx.activeRun ? { activeRun: ctx.activeRun } : {}), + }; +} + +const TOOL_ITEM_TYPES: ReadonlyArray< + readonly [RegExp, "command_execution" | "file_change" | "web_search" | "mcp_tool_call"] +> = [ + [/terminal|shell|command/u, "command_execution"], + [/edit|write|replace|delete|patch|create_file/u, "file_change"], + [/web_search|search_web/u, "web_search"], + [/^mcp/u, "mcp_tool_call"], +]; + +function toolItemType(name: string) { + return TOOL_ITEM_TYPES.find(([pattern]) => pattern.test(name))?.[1] ?? "dynamic_tool_call"; +} + +const SUMMARY_ARG_KEYS = ["command", "path", "target_file", "file_path", "query", "pattern", "url"]; + +/** Keep only short identifying arguments; file bodies and patches stay out of the event log. */ +function summarizeToolArgs(args: unknown): Record { + if (!isRecord(args)) return {}; + return Object.fromEntries( + SUMMARY_ARG_KEYS.flatMap((key) => { + const value = args[key]; + return typeof value === "string" && value.trim() ? [[key, value.trim().slice(0, 500)]] : []; + }), + ); +} + +/** The API takes every model parameter as a string, including booleans. */ +function modelParams( + options: ModelSelection["options"] | undefined, +): Array<{ id: string; value: string }> { + return (Array.isArray(options) ? options : []).map((option) => ({ + id: option.id, + value: String(option.value), + })); +} + +function humanizeToolName(name: string): string { + const words = name.replace(/[_-]+/gu, " ").trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : "Tool"; +} + +function turnStateForRunStatus(status: string): "completed" | "cancelled" | "failed" { + switch (status.toUpperCase()) { + case "FINISHED": + return "completed"; + case "CANCELLED": + return "cancelled"; + default: + return "failed"; + } +} + +interface CursorCloudAdapterOptions { + readonly environment: NodeJS.ProcessEnv; + readonly instanceId: ProviderInstanceId; +} + +export const makeCursorCloudAdapter = Effect.fn("makeCursorCloudAdapter")(function* ( + settings: Pick, + options: CursorCloudAdapterOptions, +) { + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const serverConfig = yield* ServerConfig; + const adapterScope = yield* Effect.scope; + + const sessions = new Map(); + const events = yield* PubSub.unbounded(); + + const randomId = crypto.randomUUIDv4.pipe(Effect.orDie); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const stamp = Effect.all({ eventId: Effect.map(randomId, EventId.make), createdAt: nowIso }); + + type RuntimeEventInput = ProviderRuntimeEvent extends infer Event + ? Event extends ProviderRuntimeEvent + ? Omit + : never + : never; + const emit = (event: RuntimeEventInput) => + Effect.flatMap(stamp, (base) => + PubSub.publish(events, { ...base, provider: PROVIDER, ...event } as ProviderRuntimeEvent), + ).pipe(Effect.asVoid); + + const requestError = (method: string, cause: CursorCloudApiError) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: + cause.code === "agent_busy" + ? "Cursor Cloud is still working on the previous message." + : cause.message, + cause, + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + return ctx && !ctx.stopped + ? Effect.succeed(ctx) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + + const touch = (ctx: CursorCloudSessionContext, patch: Partial) => + Effect.map(nowIso, (updatedAt) => { + const { resumeCursor: _previous, ...rest } = ctx.session; + ctx.session = { ...rest, ...patch, updatedAt }; + const resumeCursor = resumeCursorFor(ctx); + if (resumeCursor) ctx.session = { ...ctx.session, resumeCursor }; + }); + + const linkPullRequests = (ctx: CursorCloudSessionContext, turnId: TurnId, git: CursorCloudGit) => + Effect.forEach( + git.branches.flatMap((branch) => (branch.prUrl ? [branch.prUrl] : [])), + (pullRequestUrl) => { + if (ctx.linkedPullRequests.has(pullRequestUrl)) return Effect.void; + ctx.linkedPullRequests.add(pullRequestUrl); + return emit({ + type: "thread.metadata.updated", + threadId: ctx.threadId, + turnId, + payload: { pullRequestUrl }, + }); + }, + { discard: true }, + ); + + const finishRun = Effect.fn("CursorCloudAdapter.finishRun")(function* ( + ctx: CursorCloudSessionContext, + run: ActiveRun, + outcome: RunOutcome & { readonly streamedReply: boolean }, + ) { + if (outcome.text && !outcome.streamedReply) { + yield* emit({ + type: "item.completed", + threadId: ctx.threadId, + turnId: run.turnId, + itemId: RuntimeItemId.make(`${run.runId}:result`), + payload: { itemType: "assistant_message", status: "completed", detail: outcome.text }, + }); + } + if (outcome.git) yield* linkPullRequests(ctx, run.turnId, outcome.git); + + const state = turnStateForRunStatus(outcome.status); + ctx.activeRun = undefined; + ctx.turns.push({ id: run.turnId, items: [{ runId: run.runId, status: outcome.status }] }); + yield* touch(ctx, { status: "ready", activeTurnId: undefined }); + yield* emit({ + type: "turn.completed", + threadId: ctx.threadId, + turnId: run.turnId, + payload: { + state, + stopReason: outcome.status.toLowerCase(), + ...(state === "failed" + ? { + errorMessage: `${ + outcome.errorMessage ?? `Cursor Cloud run ended with status ${outcome.status}.` + }${ctx.agentUrl ? ` Details: ${ctx.agentUrl}` : ""}`, + } + : {}), + }, + }); + }); + + /** + * Follow one run to its end, reconnecting from the last event id after a + * dropped connection. With `replayingHistory`, earlier output may already + * be in the thread, so only the final result is reported. + */ + const followRun = ( + ctx: CursorCloudSessionContext, + agentId: string, + run: ActiveRun, + replayingHistory: boolean, + ): Effect.Effect => { + let replaying = replayingHistory; + let lastEventId: string | undefined; + let assistantText = ""; + let assistantItem: RuntimeItemId | undefined; + let assistantItemCount = 0; + const startedTools = new Set(); + let terminal: RunOutcome | undefined; + + const closeAssistantItem = Effect.suspend(() => { + if (!assistantItem) return Effect.void; + const itemId = assistantItem; + assistantItem = undefined; + return emit({ + type: "item.completed", + threadId: ctx.threadId, + turnId: run.turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + }); + + const handle = (event: CursorCloudStreamEvent): Effect.Effect => { + if (event.type === "result") { + terminal = { + status: event.result.status, + text: event.result.text ?? undefined, + git: event.result.git ?? undefined, + }; + return Effect.void; + } + if (replaying) return Effect.void; + switch (event.type) { + case "assistant": + return Effect.gen(function* () { + if (!assistantItem) { + assistantText = ""; + assistantItemCount += 1; + assistantItem = RuntimeItemId.make(`${run.runId}:assistant:${assistantItemCount}`); + yield* emit({ + type: "item.started", + threadId: ctx.threadId, + turnId: run.turnId, + itemId: assistantItem, + payload: { itemType: "assistant_message", status: "inProgress" }, + }); + } + assistantText += event.text; + yield* emit({ + type: "content.delta", + threadId: ctx.threadId, + turnId: run.turnId, + itemId: assistantItem, + payload: { streamKind: "assistant_text", delta: event.text }, + }); + }); + case "thinking": + return emit({ + type: "content.delta", + threadId: ctx.threadId, + turnId: run.turnId, + payload: { streamKind: "reasoning_text", delta: event.text }, + }); + case "tool_call": { + const completed = event.call.status === "completed"; + const started = startedTools.has(event.call.callId); + startedTools.add(event.call.callId); + const args = summarizeToolArgs(event.call.args); + const detail = + args.command ?? args.path ?? args.target_file ?? args.file_path ?? args.query; + return closeAssistantItem.pipe( + Effect.andThen( + emit({ + type: completed ? "item.completed" : started ? "item.updated" : "item.started", + threadId: ctx.threadId, + turnId: run.turnId, + itemId: RuntimeItemId.make(`${run.runId}:tool:${event.call.callId}`), + payload: { + itemType: toolItemType(event.call.name), + status: completed ? "completed" : "inProgress", + title: humanizeToolName(event.call.name), + ...(detail ? { detail } : {}), + data: { toolName: event.call.name, toolCallId: event.call.callId, ...args }, + }, + }), + ), + ); + } + case "error": + return Effect.logWarning("Cursor Cloud run stream reported an error.", { + runId: run.runId, + code: event.code, + }); + default: + return Effect.void; + } + }; + + const connect = () => + Stream.runForEach(ctx.api.streamRun(agentId, run.runId, lastEventId), (item) => { + if (item.id !== undefined) lastEventId = item.id; + return handle(item.event); + }).pipe( + Effect.catch((error: CursorCloudApiError) => + Effect.sync(() => { + // An expired or rejected resume id cannot be retried. Start over + // and report only the result, since earlier output was shown. + if (error.status === 400 || error.status === 410) { + lastEventId = undefined; + replaying = true; + } + }), + ), + ); + + const loop = (attempt: number): Effect.Effect => + Effect.gen(function* () { + const before = lastEventId; + yield* connect(); + if (terminal) return; + // The stream closed without a result. Ask the run directly before reconnecting. + const current = yield* Effect.result(ctx.api.getRun(agentId, run.runId)); + if (Result.isSuccess(current) && isTerminalRunStatus(current.success.status)) { + terminal = { + status: current.success.status, + text: current.success.result ?? undefined, + git: current.success.git ?? undefined, + }; + return; + } + if (Result.isFailure(current) && UNRECOVERABLE_STATUSES.has(current.failure.status ?? 0)) { + terminal = { + status: "ERROR", + text: undefined, + git: undefined, + errorMessage: current.failure.message, + }; + return; + } + const nextAttempt = lastEventId !== before ? 0 : attempt + 1; + if (nextAttempt > 0) { + yield* Effect.sleep(Math.min(1_000 * 2 ** (nextAttempt - 1), MAX_RECONNECT_DELAY_MS)); + } + return yield* loop(nextAttempt); + }); + + return loop(0).pipe( + Effect.andThen( + Effect.gen(function* () { + if (!terminal) return; + // A recovered result may extend the last partial message. Append only + // its missing suffix before closing that item; never duplicate a reply. + let streamedReply = terminal.text === assistantText; + if (assistantItem && terminal.text?.startsWith(assistantText)) { + const delta = terminal.text.slice(assistantText.length); + if (delta) { + yield* emit({ + type: "content.delta", + threadId: ctx.threadId, + turnId: run.turnId, + itemId: assistantItem, + payload: { streamKind: "assistant_text", delta }, + }); + } + streamedReply = true; + } + yield* closeAssistantItem; + yield* finishRun(ctx, run, { ...terminal, streamedReply }); + }), + ), + Effect.catchCause((cause) => + Effect.logError("Cursor Cloud run follower failed.", { runId: run.runId, cause }), + ), + ); + }; + + const startFollowing = ( + ctx: CursorCloudSessionContext, + agentId: string, + run: ActiveRun, + replaying: boolean, + ) => followRun(ctx, agentId, run, replaying).pipe(Effect.forkIn(ctx.scope), Effect.asVoid); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + const cwd = input.cwd?.trim(); + if (!cwd) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Cursor Cloud needs the project's local checkout to find its GitHub repository.", + }); + } + const apiKey = resolveCursorCloudApiKey(options.environment); + if (!apiKey) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: CURSOR_CLOUD_MISSING_KEY_MESSAGE, + }); + } + + // Replacing a session continues the same thread, so it does not announce an exit. + const existing = sessions.get(input.threadId); + if (existing) yield* stopSessionInternal(existing, { announce: false }); + + const resume = parseResumeCursor(input.resumeCursor); + const model = + resume?.model ?? + (input.modelSelection?.instanceId === options.instanceId + ? input.modelSelection.model + : undefined); + const now = yield* nowIso; + const ctx: CursorCloudSessionContext = { + threadId: input.threadId, + cwd, + branch: input.branch, + api: makeCursorCloudApi({ apiKey, httpClient }), + lock: yield* Semaphore.make(1), + scope: yield* Scope.fork(adapterScope, "sequential"), + session: { + provider: PROVIDER, + providerInstanceId: options.instanceId, + status: resume?.activeRun ? "running" : "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(model ? { model } : {}), + threadId: input.threadId, + ...(resume?.activeRun ? { activeTurnId: resume.activeRun.turnId } : {}), + createdAt: now, + updatedAt: now, + }, + agentId: resume?.agentId, + agentUrl: resume?.agentUrl, + modelSelection: + input.modelSelection?.instanceId === options.instanceId + ? input.modelSelection + : undefined, + activeRun: resume?.activeRun, + linkedPullRequests: new Set(), + turns: [], + stopped: false, + }; + yield* touch(ctx, {}); + sessions.set(input.threadId, ctx); + + yield* emit({ type: "session.started", threadId: input.threadId, payload: {} }); + // Without a recorded run nothing is in flight, even if the thread last showed one. + yield* emit({ + type: "session.state.changed", + threadId: input.threadId, + payload: { state: ctx.activeRun ? "running" : "ready" }, + }); + if (ctx.agentId) { + yield* emit({ + type: "thread.started", + threadId: input.threadId, + payload: { providerThreadId: ctx.agentId }, + }); + } + // A run recorded in the cursor has not been seen to finish; it may have + // kept running, or finished, while this server was away. + if (ctx.agentId && ctx.activeRun) { + yield* startFollowing(ctx, ctx.agentId, ctx.activeRun, true); + } + return ctx.session; + }); + + const readPromptImages = (attachments: ReadonlyArray) => + Effect.gen(function* () { + const images: Array<{ data: string; mimeType: string }> = []; + for (const attachment of attachments) { + if (attachment.type !== "image") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "Cursor Cloud agents cannot read local files. Attach images, or paste the text into your message.", + }); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: cause.message, + cause, + }), + ), + ); + images.push({ data: Buffer.from(bytes).toString("base64"), mimeType: attachment.mimeType }); + } + if (images.length > MAX_PROMPT_IMAGES) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Cursor Cloud accepts at most ${MAX_PROMPT_IMAGES} images per message.`, + }); + } + return images; + }); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + return yield* ctx.lock.withPermit( + Effect.gen(function* () { + if (ctx.activeRun) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: + "Cursor Cloud is still working on the previous message. Wait for it to finish, or stop it first.", + }); + } + const images = yield* readPromptImages(input.attachments ?? []); + const text = input.input?.trim() ?? ""; + if (!text && images.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + const prompt: CursorCloudPrompt = { text, ...(images.length > 0 ? { images } : {}) }; + const mode = input.interactionMode === "plan" ? "plan" : "agent"; + const turnId = TurnId.make(yield* randomId); + + let runId: string; + let agentId = ctx.agentId; + if (agentId === undefined) { + const repository = yield* resolveCursorCloudRepository( + ctx.cwd, + ctx.branch, + options.environment, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: cause.detail, + cause, + }), + ), + ); + const selection = + input.modelSelection?.instanceId === options.instanceId + ? input.modelSelection + : ctx.modelSelection; + const model = selection?.model ?? ctx.session.model; + const params = modelParams(selection?.options); + const created = yield* ctx.api + .createAgent({ + prompt, + ...(model && model !== CURSOR_CLOUD_DEFAULT_MODEL + ? { model: { id: model, ...(params.length > 0 ? { params } : {}) } } + : {}), + repos: [{ url: repository.url, startingRef: repository.startingRef }], + autoCreatePR: settings.cloudAutoCreatePR, + mode, + }) + .pipe(Effect.mapError((cause) => requestError("createAgent", cause))); + agentId = created.agent.id; + ctx.agentId = agentId; + ctx.agentUrl = created.agent.url ?? undefined; + runId = created.run.id; + yield* touch(ctx, model ? { model } : {}); + yield* emit({ + type: "thread.started", + threadId: ctx.threadId, + payload: { providerThreadId: created.agent.id }, + }); + if (repository.hasUnpushedLocalWork) { + yield* emit({ + type: "runtime.warning", + threadId: ctx.threadId, + turnId, + payload: { + message: `Cursor Cloud started from ${repository.startingRef.slice(0, 40)} on GitHub. Local changes that are not pushed are not included.`, + }, + }); + } + if (ctx.session.runtimeMode !== "full-access") { + yield* emit({ + type: "runtime.warning", + threadId: ctx.threadId, + turnId, + payload: { + message: + "Cursor Cloud agents do not ask for approval. They run with full access inside their cloud VM.", + }, + }); + } + } else { + const run = yield* ctx.api + .createRun(agentId, { prompt, mode }) + .pipe(Effect.mapError((cause) => requestError("createRun", cause))); + runId = run.id; + } + + const run: ActiveRun = { runId, turnId }; + ctx.activeRun = run; + yield* touch(ctx, { status: "running", activeTurnId: turnId }); + yield* emit({ + type: "turn.started", + threadId: ctx.threadId, + turnId, + payload: + ctx.session.model && ctx.session.model !== CURSOR_CLOUD_DEFAULT_MODEL + ? { model: ctx.session.model } + : {}, + }); + const resumeCursor = ctx.session.resumeCursor; + yield* startFollowing(ctx, agentId, run, false); + return { + threadId: ctx.threadId, + turnId, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + } satisfies ProviderTurnStartResult; + }), + ); + }); + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!ctx.agentId || !ctx.activeRun) return; + // The follower reports the CANCELLED result as the turn's end. + yield* ctx.api + .cancelRun(ctx.agentId, ctx.activeRun.runId) + .pipe( + Effect.catch((cause) => + cause.code === "run_not_cancellable" + ? Effect.void + : Effect.fail(requestError("cancelRun", cause)), + ), + ); + }); + + const unsupported = (method: string, detail: string) => + Effect.fail(new ProviderAdapterRequestError({ provider: PROVIDER, method, detail })); + + function stopSessionInternal( + ctx: CursorCloudSessionContext, + options?: { readonly announce?: boolean }, + ) { + return Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + sessions.delete(ctx.threadId); + yield* Scope.close(ctx.scope, Exit.void); + if (options?.announce !== false) { + yield* emit({ + type: "session.exited", + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + } + }); + } + + // Shutdown and instance rebuilds stop tracking only. The cloud run keeps + // going, and the thread must stay running so recovery can reattach. + yield* Effect.addFinalizer(() => + Effect.forEach([...sessions.values()], (ctx) => stopSessionInternal(ctx, { announce: false }), { + discard: true, + }).pipe(Effect.andThen(PubSub.shutdown(events))), + ); + + return { + provider: PROVIDER, + // The router reports the instance's capabilities; these describe the agent itself. + capabilities: { sessionModelSwitch: "unsupported", supportsConversationRollback: false }, + startSession, + sendTurn, + interruptTurn, + respondToRequest: () => + unsupported("respondToRequest", "Cursor Cloud agents do not ask for approval."), + respondToUserInput: () => + unsupported("respondToUserInput", "Cursor Cloud agents do not ask questions."), + stopSession: (threadId) => + requireSession(threadId).pipe(Effect.flatMap((ctx) => stopSessionInternal(ctx))), + listSessions: () => + Effect.sync(() => [...sessions.values()].map((ctx) => ({ ...ctx.session }))), + hasSession: (threadId) => Effect.sync(() => sessions.get(threadId)?.stopped === false), + readThread: (threadId) => + requireSession(threadId).pipe(Effect.map((ctx) => ({ threadId, turns: ctx.turns }))), + rollbackThread: () => + unsupported("rollbackThread", "Cursor Cloud agents cannot roll back their conversation."), + stopAll: () => + Effect.forEach([...sessions.values()], (ctx) => stopSessionInternal(ctx), { discard: true }), + streamEvents: Stream.fromPubSub(events), + } satisfies ProviderAdapterShape; +}); + +type Adapter = ProviderAdapterShape; + +/** + * One Cursor instance runs each thread either through the local CLI or as a + * cloud agent. A thread whose execution target is `cloud`, or whose resume + * cursor is a cloud cursor, goes to the cloud adapter for its whole life. + */ +export function routeCursorExecution(local: Adapter, cloud: Adapter): Adapter { + const adapterFor = (threadId: ThreadId) => + Effect.map(cloud.hasSession(threadId), (isCloud) => (isCloud ? cloud : local)); + const route = + , A>( + use: ( + adapter: Adapter, + ) => (threadId: ThreadId, ...args: Args) => Effect.Effect, + ) => + (threadId: ThreadId, ...args: Args) => + Effect.flatMap(adapterFor(threadId), (adapter) => use(adapter)(threadId, ...args)); + const compactionCommand = + local.compaction?.type === "slash-command" ? local.compaction.command : undefined; + + return { + provider: local.provider, + capabilities: local.capabilities, + ...(local.compaction ? { compaction: local.compaction } : {}), + startSession: (input) => + parseResumeCursor(input.resumeCursor) !== undefined || input.executionTarget === "cloud" + ? cloud.startSession(input) + : local.startSession(input), + sendTurn: (input) => + Effect.flatMap(adapterFor(input.threadId), (adapter) => + adapter === cloud && + compactionCommand !== undefined && + input.input?.trim() === compactionCommand + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Cloud agents manage their own context and cannot be compacted.", + }), + ) + : adapter.sendTurn(input), + ), + interruptTurn: route((adapter) => adapter.interruptTurn), + respondToRequest: route((adapter) => adapter.respondToRequest), + respondToUserInput: route((adapter) => adapter.respondToUserInput), + stopSession: route((adapter) => adapter.stopSession), + readThread: route((adapter) => adapter.readThread), + rollbackThread: route((adapter) => adapter.rollbackThread), + listSessions: () => + Effect.map(Effect.all([local.listSessions(), cloud.listSessions()]), (sessions) => + sessions.flat(), + ), + hasSession: (threadId) => + Effect.map( + Effect.all([local.hasSession(threadId), cloud.hasSession(threadId)]), + ([onLocal, onCloud]) => onLocal || onCloud, + ), + stopAll: () => Effect.all([local.stopAll(), cloud.stopAll()], { discard: true }), + streamEvents: Stream.merge(local.streamEvents, cloud.streamEvents), + }; +} diff --git a/apps/server/src/provider/Layers/CursorCloudProvider.test.ts b/apps/server/src/provider/Layers/CursorCloudProvider.test.ts new file mode 100644 index 000000000000..79d0cedabf8d --- /dev/null +++ b/apps/server/src/provider/Layers/CursorCloudProvider.test.ts @@ -0,0 +1,90 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { checkCursorCloudStatus } from "./CursorCloudProvider.ts"; + +const withApi = (respond: (path: string) => Response) => + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, respond(new URL(request.url).pathname))), + ), + ); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +it.effect("asks for an API key before contacting Cursor", () => + Effect.gen(function* () { + const cloud = yield* checkCursorCloudStatus({}).pipe( + withApi(() => { + throw new Error("no request without a key"); + }), + ); + expect(cloud.available).toBe(false); + expect(cloud.message).toMatch(/CURSOR_API_KEY/); + }), +); + +it.effect("reports a rejected key as unavailable", () => + Effect.gen(function* () { + const cloud = yield* checkCursorCloudStatus({ CURSOR_API_KEY: "bad" }).pipe( + withApi(() => json({ code: "unauthorized", message: "Invalid API key" }, 401)), + ); + expect(cloud.available).toBe(false); + expect(cloud.message).toMatch(/rejected/); + }), +); + +it.effect("offers the account default and each model's parameters with their defaults", () => + Effect.gen(function* () { + const cloud = yield* checkCursorCloudStatus({ CURSOR_API_KEY: "key" }).pipe( + withApi((path) => + path === "/v1/me" + ? json({ apiKeyName: "Laptop" }) + : json({ + items: [ + { + id: "composer-2", + displayName: "Composer 2", + parameters: [ + { id: "fast", values: [{ value: "false" }, { value: "true" }] }, + { + id: "effort", + displayName: "Effort", + values: [{ value: "low" }, { value: "high", displayName: "High" }], + }, + ], + variants: [ + { + params: [ + { id: "fast", value: "true" }, + { id: "effort", value: "high" }, + ], + isDefault: true, + }, + ], + }, + ], + }), + ), + ); + + expect(cloud.available).toBe(true); + expect(cloud.models.map((model) => model.slug)).toEqual(["default", "composer-2"]); + expect(cloud.models[1]?.capabilities?.optionDescriptors).toEqual([ + { id: "fast", label: "fast", type: "boolean", currentValue: true }, + { + id: "effort", + label: "Effort", + type: "select", + options: [ + { id: "low", label: "low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + ]); + }), +); diff --git a/apps/server/src/provider/Layers/CursorCloudProvider.ts b/apps/server/src/provider/Layers/CursorCloudProvider.ts new file mode 100644 index 000000000000..a2b3a6baad9a --- /dev/null +++ b/apps/server/src/provider/Layers/CursorCloudProvider.ts @@ -0,0 +1,129 @@ +import { + CURSOR_CLOUD_DEFAULT_MODEL, + type ProviderOptionDescriptor, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; + +import { + CURSOR_CLOUD_API_KEY_ENV, + type CursorCloudModel, + makeCursorCloudApi, +} from "../cursorCloudApi.ts"; + +type CursorCloudStatus = NonNullable; + +const DEFAULT_MODEL: ServerProviderModel = { + slug: CURSOR_CLOUD_DEFAULT_MODEL, + name: "Default", + isCustom: false, + isDefault: true, + capabilities: createModelCapabilities({ optionDescriptors: [] }), +}; + +export const CURSOR_CLOUD_MISSING_KEY_MESSAGE = `Add a Cursor API key as the ${CURSOR_CLOUD_API_KEY_ENV} environment variable on this provider. Create one in the Cursor dashboard under API Keys.`; + +export function resolveCursorCloudApiKey(environment: NodeJS.ProcessEnv): string | undefined { + return environment[CURSOR_CLOUD_API_KEY_ENV]?.trim() || undefined; +} + +function optionDescriptorsForModel( + model: CursorCloudModel, +): ReadonlyArray { + const defaults = model.variants?.find((variant) => variant.isDefault)?.params ?? []; + return (model.parameters ?? []).flatMap((parameter): ProviderOptionDescriptor[] => { + const values = parameter.values.map((entry) => entry.value); + if (values.length === 0) return []; + const label = parameter.displayName?.trim() || parameter.id; + const defaultValue = defaults.find((param) => param.id === parameter.id)?.value; + if (values.length === 2 && values.includes("true") && values.includes("false")) { + return [ + { + id: parameter.id, + label, + type: "boolean", + ...(defaultValue !== undefined ? { currentValue: defaultValue === "true" } : {}), + }, + ]; + } + return [ + { + id: parameter.id, + label, + type: "select", + options: parameter.values.map((entry) => ({ + id: entry.value, + label: entry.displayName?.trim() || entry.value, + ...(entry.value === defaultValue ? { isDefault: true } : {}), + })), + ...(defaultValue !== undefined ? { currentValue: defaultValue } : {}), + }, + ]; + }); +} + +/** The account's default first, then every model `/v1/models` recommends. */ +function cursorCloudModelsFromApi( + models: ReadonlyArray, +): ReadonlyArray { + const seen = new Set([CURSOR_CLOUD_DEFAULT_MODEL]); + const listed = models.flatMap((model): ServerProviderModel[] => { + const slug = model.id.trim(); + if (!slug || seen.has(slug)) return []; + seen.add(slug); + return [ + { + slug, + name: model.displayName?.trim() || slug, + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: optionDescriptorsForModel(model), + }), + }, + ]; + }); + return [DEFAULT_MODEL, ...listed]; +} + +/** + * Whether this Cursor instance can run cloud threads. Validates the key with + * `/v1/me` and reads the cloud model catalog; never starts an agent. + */ +export const checkCursorCloudStatus = Effect.fn("checkCursorCloudStatus")(function* ( + environment: NodeJS.ProcessEnv, +): Effect.fn.Return { + const apiKey = resolveCursorCloudApiKey(environment); + if (!apiKey) { + return { available: false, message: CURSOR_CLOUD_MISSING_KEY_MESSAGE, models: [] }; + } + + const api = makeCursorCloudApi({ apiKey, httpClient: yield* HttpClient.HttpClient }); + const me = yield* Effect.result(api.me); + if (Result.isFailure(me)) { + const rejected = me.failure.status === 401 || me.failure.status === 403; + return { + available: false, + message: rejected + ? `Cursor rejected the ${CURSOR_CLOUD_API_KEY_ENV} API key.` + : `Could not reach Cursor Cloud: ${me.failure.detail}`, + models: [], + }; + } + + const models = yield* Effect.result(api.listModels); + if (Result.isFailure(models)) { + yield* Effect.logWarning("Cursor Cloud model listing failed.", { + status: models.failure.status, + }); + return { + available: true, + message: "Cursor Cloud's model list could not be loaded. New threads use your default model.", + models: [DEFAULT_MODEL], + }; + } + return { available: true, models: cursorCloudModelsFromApi(models.success) }; +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index aa5207f3aef2..afc32fd2f0da 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -305,6 +305,7 @@ const baseCursorSettings: CursorSettings = { enabled: true, binaryPath: "cursor-agent", apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], }; const cursorAcpDiscoveryFailedMessage = [ @@ -690,6 +691,7 @@ describe("checkCursorProviderStatus", () => { enabled: true, binaryPath: missingCursorBinaryPath, apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], }), ); @@ -711,6 +713,7 @@ describe("checkCursorProviderStatus", () => { enabled: true, binaryPath: wrapperPath, apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], }, { @@ -740,6 +743,7 @@ describe("discoverCursorModelsViaAcp", () => { enabled: true, binaryPath: wrapperPath, apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], }; const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { @@ -779,6 +783,7 @@ describe("discoverCursorModelsViaAcp", () => { enabled: true, binaryPath: wrapperPath, apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], }).pipe(Effect.scoped), ); @@ -801,6 +806,7 @@ describe("discoverCursorModelsViaAcp", () => { enabled: true, binaryPath: wrapperPath, apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], }), ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index c33b1dfc690a..40e20ef43c90 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -122,6 +122,7 @@ const makeCursorConfig = (overrides: Partial): CursorSettings => enabled: false, binaryPath: "cursor-agent", apiEndpoint: "", + cloudAutoCreatePR: true, customModels: [], ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7f0465b9c401..989d15a4b120 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -839,70 +839,80 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", () }), ); -it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => - Effect.gen(function* () { - const codex = makeFakeCodexAdapter(); - const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); - const registryBase = makeAdapterRegistryMock({ - [CODEX_DRIVER]: codex.adapter, - [CLAUDE_AGENT_DRIVER]: claude.adapter, - }); - const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { - ...registryBase, - getInstanceInfo: (instanceId) => - instanceId === claudeAgentInstanceId - ? Effect.succeed({ - instanceId, - driverKind: CLAUDE_AGENT_DRIVER, - displayName: undefined, - enabled: false, - continuationIdentity: { +for (const restriction of ["disabled", "cloud"] as const) { + it.effect(`ProviderServiceLive rejects ${restriction} sessions for unsupported providers`, () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const registryBase = makeAdapterRegistryMock({ + [CODEX_DRIVER]: codex.adapter, + [CLAUDE_AGENT_DRIVER]: claude.adapter, + }); + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { + ...registryBase, + getInstanceInfo: (instanceId) => + instanceId === claudeAgentInstanceId + ? Effect.succeed({ + instanceId, driverKind: CLAUDE_AGENT_DRIVER, - continuationKey: "claudeAgent:instance:claudeAgent", - }, - }) - : registryBase.getInstanceInfo(instanceId), - }; - const providerAdapterLayer = Layer.succeed( - ProviderAdapterRegistry.ProviderAdapterRegistry, - registry, - ); - const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( - Layer.provide(SqlitePersistenceMemory), - ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); - const providerLayer = makeProviderServiceLive().pipe( - Layer.provide(NodeServices.layer), - Layer.provide(providerAdapterLayer), - Layer.provide(directoryLayer), - Layer.provide(defaultServerSettingsLayer), - Layer.provide(serverConfigTestLayer), - Layer.provide(AnalyticsService.layerTest), - Layer.provide( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, + displayName: undefined, + enabled: restriction !== "disabled", + continuationIdentity: { + driverKind: CLAUDE_AGENT_DRIVER, + continuationKey: "claudeAgent:instance:claudeAgent", + }, + }) + : registryBase.getInstanceInfo(instanceId), + }; + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), ), - ), - ); + ); - const failure = yield* Effect.flip( - Effect.gen(function* () { - const provider = yield* ProviderService.ProviderService; - return yield* provider.startSession(asThreadId("thread-disabled"), { - provider: ProviderDriverKind.make("claudeAgent"), - providerInstanceId: claudeAgentInstanceId, - threadId: asThreadId("thread-disabled"), - runtimeMode: "full-access", - }); - }).pipe(Effect.provide(providerLayer)), - ); + const failure = yield* Effect.flip( + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + return yield* provider.startSession(asThreadId("thread-disabled"), { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-disabled"), + runtimeMode: "full-access", + ...(restriction === "cloud" ? { executionTarget: "cloud" as const } : {}), + }); + }).pipe(Effect.provide(providerLayer)), + ); - assert.instanceOf(failure, ProviderValidationError); - assert.include(failure.issue, "Provider instance 'claudeAgent' is disabled"); - assert.equal(claude.startSession.mock.calls.length, 0); - }).pipe(Effect.provide(NodeServices.layer)), -); + assert.instanceOf(failure, ProviderValidationError); + assert.include( + failure.issue, + restriction === "disabled" + ? "Provider instance 'claudeAgent' is disabled" + : "does not support cloud execution", + ); + assert.equal(claude.startSession.mock.calls.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), + ); +} it.effect( "ProviderServiceLive allows enabled custom instances when legacy driver is disabled", diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f4e7b0b39bdb..becb993f68c6 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1108,35 +1108,33 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( canonicalEvent.type === "turn.aborted" ) { yield* recordTurnCompletedAnalytics(source, canonicalEvent); - if (source.provider === "claudeAgent") { - // Background Claude turns have no sendTurn response to persist their - // new native boundary. Save it before clients can checkpoint the turn. - yield* Effect.gen(function* () { - const adapter = yield* registry.getByInstance(source.instanceId); - const session = (yield* adapter.listSessions()).find( - (session) => session.threadId === canonicalEvent.threadId, - ); - if (session?.resumeCursor !== undefined) { - const binding = yield* directory.getBinding(session.threadId); - if ( - Option.isNone(binding) || - binding.value.providerInstanceId !== source.instanceId - ) { - return; - } - yield* directory.upsert({ - threadId: session.threadId, - provider: source.provider, - providerInstanceId: source.instanceId, - resumeCursor: session.resumeCursor, - }); - } - }).pipe( - Effect.catch((cause) => - Effect.logWarning("failed to persist Claude turn resume state", { cause }), - ), + // Save the adapter's settled cursor at every turn boundary. Background + // Claude turns have no sendTurn response to persist their new native + // boundary, and a cloud cursor names the run in flight until it ends; + // without this, recovery would reattach to a finished run. + yield* Effect.gen(function* () { + const adapter = yield* registry.getByInstance(source.instanceId); + const session = (yield* adapter.listSessions()).find( + (session) => session.threadId === canonicalEvent.threadId, ); - } + if (session?.resumeCursor === undefined) { + return; + } + const binding = yield* directory.getBinding(session.threadId); + if (Option.isNone(binding) || binding.value.providerInstanceId !== source.instanceId) { + return; + } + yield* directory.upsert({ + threadId: session.threadId, + provider: source.provider, + providerInstanceId: source.instanceId, + resumeCursor: session.resumeCursor, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to persist turn resume state", { cause }), + ), + ); } else if (canonicalEvent.type === "session.exited") { yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId); } @@ -1425,6 +1423,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); const resolvedProvider = instanceInfo.driverKind; metricProvider = resolvedProvider; + if (parsed.executionTarget === "cloud" && resolvedProvider !== "cursor") { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' does not support cloud execution.`, + ); + } if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { return yield* toValidationError( "ProviderService.startSession", diff --git a/apps/server/src/provider/cursorCloudApi.ts b/apps/server/src/provider/cursorCloudApi.ts new file mode 100644 index 000000000000..4ac2ef689e98 --- /dev/null +++ b/apps/server/src/provider/cursorCloudApi.ts @@ -0,0 +1,412 @@ +/** + * Minimal client for the Cursor Cloud Agents API (v1). + * + * An agent is a durable conversation plus a Cursor-hosted workspace; each + * prompt is a run on it, and only one run can be active per agent. Run + * progress arrives as Server-Sent Events that resume from `Last-Event-ID`. + * See https://cursor.com/docs/cloud-agent/api/endpoints. + * + * @module provider/cursorCloudApi + */ +import * as Effect from "effect/Effect"; +import * as Filter from "effect/Filter"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Sse from "effect/unstable/encoding/Sse"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +const CURSOR_CLOUD_API_URL = "https://api.cursor.com"; +export const CURSOR_CLOUD_API_KEY_ENV = "CURSOR_API_KEY"; +const STREAM_IDLE_TIMEOUT = "90 seconds"; + +export class CursorCloudApiError extends Schema.TaggedError()( + "CursorCloudApiError", + { + operation: Schema.String, + status: Schema.optional(Schema.Number), + code: Schema.optional(Schema.String), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const status = this.status === undefined ? "" : ` (HTTP ${this.status})`; + return `Cursor Cloud ${this.operation} failed${status}: ${this.detail}`; + } +} + +const optionalNullable = (schema: S) => + Schema.optional(Schema.NullOr(schema)); + +const CursorCloudModelParameter = Schema.Struct({ + id: Schema.String, + displayName: optionalNullable(Schema.String), + values: Schema.Array( + Schema.Struct({ value: Schema.String, displayName: optionalNullable(Schema.String) }), + ), +}); + +const CursorCloudModelParam = Schema.Struct({ id: Schema.String, value: Schema.String }); +type CursorCloudModelParam = typeof CursorCloudModelParam.Type; + +const CursorCloudModel = Schema.Struct({ + id: Schema.String, + displayName: optionalNullable(Schema.String), + parameters: optionalNullable(Schema.Array(CursorCloudModelParameter)), + variants: optionalNullable( + Schema.Array( + Schema.Struct({ + params: Schema.Array(CursorCloudModelParam), + isDefault: optionalNullable(Schema.Boolean), + }), + ), + ), +}); +export type CursorCloudModel = typeof CursorCloudModel.Type; + +const CursorCloudModelList = Schema.Struct({ items: Schema.Array(CursorCloudModel) }); + +const CursorCloudMe = Schema.Struct({ + apiKeyName: optionalNullable(Schema.String), + userEmail: optionalNullable(Schema.String), +}); +type CursorCloudMe = typeof CursorCloudMe.Type; + +const CursorCloudGit = Schema.Struct({ + branches: Schema.Array( + Schema.Struct({ + repoUrl: Schema.String, + branch: optionalNullable(Schema.String), + prUrl: optionalNullable(Schema.String), + }), + ), +}); +export type CursorCloudGit = typeof CursorCloudGit.Type; + +const CursorCloudRun = Schema.Struct({ + id: Schema.String, + agentId: Schema.String, + status: Schema.String, + result: optionalNullable(Schema.String), + git: optionalNullable(CursorCloudGit), +}); +type CursorCloudRun = typeof CursorCloudRun.Type; + +const CursorCloudAgent = Schema.Struct({ + id: Schema.String, + url: optionalNullable(Schema.String), + status: Schema.String, + latestRunId: optionalNullable(Schema.String), +}); +type CursorCloudAgent = typeof CursorCloudAgent.Type; + +const CursorCloudCreateAgentResponse = Schema.Struct({ + agent: CursorCloudAgent, + run: CursorCloudRun, +}); +const CursorCloudCreateRunResponse = Schema.Struct({ run: CursorCloudRun }); + +const CursorCloudErrorBody = Schema.Struct({ + code: optionalNullable(Schema.String), + message: optionalNullable(Schema.String), + error: optionalNullable( + Schema.Union([ + Schema.String, + Schema.Struct({ + code: optionalNullable(Schema.String), + message: optionalNullable(Schema.String), + }), + ]), + ), +}); +const decodeErrorBody = Schema.decodeOption(Schema.fromJsonString(CursorCloudErrorBody)); + +/** Terminal run states. Anything else (`CREATING`, `RUNNING`, …) is still in flight. */ +const TERMINAL_RUN_STATUSES = new Set(["FINISHED", "ERROR", "CANCELLED", "EXPIRED"]); +export const isTerminalRunStatus = (status: string): boolean => + TERMINAL_RUN_STATUSES.has(status.toUpperCase()); + +export interface CursorCloudPrompt { + readonly text: string; + readonly images?: ReadonlyArray<{ readonly data: string; readonly mimeType: string }>; +} + +interface CursorCloudCreateAgentBody { + readonly prompt: CursorCloudPrompt; + readonly model?: { + readonly id: string; + readonly params?: ReadonlyArray; + }; + readonly repos: ReadonlyArray<{ readonly url: string; readonly startingRef: string }>; + readonly autoCreatePR: boolean; + readonly mode: "agent" | "plan"; +} + +interface CursorCloudCreateRunBody { + readonly prompt: CursorCloudPrompt; + readonly mode: "agent" | "plan"; +} + +const ToolCallData = Schema.Struct({ + callId: Schema.String, + name: Schema.String, + status: Schema.String, + args: Schema.optional(Schema.Unknown), + result: Schema.optional(Schema.Unknown), +}); +const ResultData = Schema.Struct({ + status: Schema.String, + text: optionalNullable(Schema.String), + git: optionalNullable(CursorCloudGit), +}); +const TextData = Schema.Struct({ text: Schema.String }); +const StatusData = Schema.Struct({ status: Schema.String }); +const ErrorData = Schema.Struct({ + code: optionalNullable(Schema.String), + message: optionalNullable(Schema.String), +}); + +export type CursorCloudStreamEvent = + | { readonly type: "status"; readonly status: string } + | { readonly type: "assistant"; readonly text: string } + | { readonly type: "thinking"; readonly text: string } + | { readonly type: "tool_call"; readonly call: typeof ToolCallData.Type } + | { readonly type: "result"; readonly result: typeof ResultData.Type } + | { readonly type: "error"; readonly code: string | undefined; readonly message: string } + | { readonly type: "done" }; + +interface CursorCloudStreamItem { + /** Opaque resume position; absent on the sticky `status` framing event. */ + readonly id: string | undefined; + readonly event: CursorCloudStreamEvent; +} + +const decodeData = + >(schema: S) => + (data: string) => + Schema.decodeOption(Schema.fromJsonString(schema))(data); + +const decodeToolCall = decodeData(ToolCallData); +const decodeResult = decodeData(ResultData); +const decodeText = decodeData(TextData); +const decodeStatus = decodeData(StatusData); +const decodeError = decodeData(ErrorData); + +/** Heartbeats, `interaction_update` (a richer duplicate of the simple events), and unknown events decode to `undefined`. */ +function decodeCursorCloudStreamEvent( + event: Pick, +): CursorCloudStreamEvent | undefined { + switch (event.event) { + case "status": + return Option.getOrUndefined( + Option.map(decodeStatus(event.data), ({ status }) => ({ type: "status", status }) as const), + ); + case "assistant": + case "thinking": { + const type = event.event; + return Option.getOrUndefined( + Option.map(decodeText(event.data), ({ text }) => ({ type, text }) as const), + ); + } + case "tool_call": + return Option.getOrUndefined( + Option.map(decodeToolCall(event.data), (call) => ({ type: "tool_call", call }) as const), + ); + case "result": + return Option.getOrUndefined( + Option.map(decodeResult(event.data), (result) => ({ type: "result", result }) as const), + ); + case "error": { + const decoded = Option.getOrUndefined(decodeError(event.data)); + return { + type: "error", + code: decoded?.code ?? undefined, + message: decoded?.message ?? "Cursor reported a stream error.", + }; + } + case "done": + return { type: "done" }; + default: + return undefined; + } +} + +export interface CursorCloudApi { + readonly me: Effect.Effect; + readonly listModels: Effect.Effect, CursorCloudApiError>; + readonly createAgent: ( + body: CursorCloudCreateAgentBody, + ) => Effect.Effect< + { readonly agent: CursorCloudAgent; readonly run: CursorCloudRun }, + CursorCloudApiError + >; + readonly createRun: ( + agentId: string, + body: CursorCloudCreateRunBody, + ) => Effect.Effect; + readonly getRun: ( + agentId: string, + runId: string, + ) => Effect.Effect; + readonly cancelRun: (agentId: string, runId: string) => Effect.Effect; + /** + * One connection to a run's event stream. It ends when the server closes + * the connection; callers reconnect with the last received id until they + * see a terminal `result`. + */ + readonly streamRun: ( + agentId: string, + runId: string, + lastEventId: string | undefined, + ) => Stream.Stream; +} + +export function makeCursorCloudApi(input: { + readonly apiKey: string; + readonly httpClient: HttpClient.HttpClient; +}): CursorCloudApi { + const segment = encodeURIComponent; + + const failedResponse = (operation: string, response: HttpClientResponse.HttpClientResponse) => + response.text.pipe( + Effect.orElseSucceed(() => ""), + Effect.flatMap((text) => { + const body = Option.getOrUndefined(decodeErrorBody(text)); + const nested = typeof body?.error === "object" ? body.error : undefined; + const code = body?.code ?? nested?.code ?? undefined; + const detail = + body?.message ?? + nested?.message ?? + (typeof body?.error === "string" ? body.error : undefined) ?? + `Unexpected response status ${response.status}.`; + return Effect.fail( + new CursorCloudApiError({ + operation, + status: response.status, + ...(code ? { code } : {}), + detail, + }), + ); + }), + ); + + const send = (operation: string, request: HttpClientRequest.HttpClientRequest) => + input.httpClient + .execute( + request.pipe( + HttpClientRequest.prependUrl(CURSOR_CLOUD_API_URL), + HttpClientRequest.bearerToken(input.apiKey), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new CursorCloudApiError({ + operation, + detail: "Could not reach the Cursor API.", + cause, + }), + ), + ); + + const json = >( + operation: string, + request: HttpClientRequest.HttpClientRequest, + schema: S, + ): Effect.Effect => + send(operation, request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap( + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new CursorCloudApiError({ + operation, + status: success.status, + detail: "Cursor returned an unexpected response.", + cause, + }), + ), + ), + orElse: (failed) => failedResponse(operation, failed), + }), + ), + ); + + const streamRun: CursorCloudApi["streamRun"] = (agentId, runId, lastEventId) => + send( + "stream run", + HttpClientRequest.get(`/v1/agents/${segment(agentId)}/runs/${segment(runId)}/stream`).pipe( + HttpClientRequest.accept("text/event-stream"), + lastEventId === undefined + ? (request) => request + : HttpClientRequest.setHeader("Last-Event-ID", lastEventId), + ), + ).pipe( + Effect.filterOrElse( + (response) => response.status >= 200 && response.status < 300, + (response) => failedResponse("stream run", response), + ), + Effect.map((response) => + response.stream.pipe( + Stream.decodeText(), + Stream.pipeThroughChannel(Sse.decode()), + // Heartbeats still count here, so only a dead connection goes quiet this long. + Stream.timeout(STREAM_IDLE_TIMEOUT), + Stream.filterMap( + Filter.fromPredicateOption((event: Sse.Event) => + Option.map( + Option.fromUndefinedOr(decodeCursorCloudStreamEvent(event)), + (decoded): CursorCloudStreamItem => ({ id: event.id, event: decoded }), + ), + ), + ), + Stream.mapError( + (cause) => + new CursorCloudApiError({ + operation: "stream run", + detail: "The run stream disconnected.", + cause, + }), + ), + ), + ), + Stream.unwrap, + ); + + return { + me: json("read API key", HttpClientRequest.get("/v1/me"), CursorCloudMe), + listModels: json("list models", HttpClientRequest.get("/v1/models"), CursorCloudModelList).pipe( + Effect.map((response) => response.items), + ), + createAgent: (body) => + json( + "create agent", + HttpClientRequest.post("/v1/agents").pipe(HttpClientRequest.bodyJsonUnsafe(body)), + CursorCloudCreateAgentResponse, + ), + createRun: (agentId, body) => + json( + "create run", + HttpClientRequest.post(`/v1/agents/${segment(agentId)}/runs`).pipe( + HttpClientRequest.bodyJsonUnsafe(body), + ), + CursorCloudCreateRunResponse, + ).pipe(Effect.map((response) => response.run)), + getRun: (agentId, runId) => + json( + "read run", + HttpClientRequest.get(`/v1/agents/${segment(agentId)}/runs/${segment(runId)}`), + CursorCloudRun, + ), + cancelRun: (agentId, runId) => + json( + "cancel run", + HttpClientRequest.post(`/v1/agents/${segment(agentId)}/runs/${segment(runId)}/cancel`), + Schema.Unknown, + ).pipe(Effect.asVoid), + streamRun, + }; +} diff --git a/apps/server/src/provider/cursorCloudWorkspace.test.ts b/apps/server/src/provider/cursorCloudWorkspace.test.ts new file mode 100644 index 000000000000..11e52038e28b --- /dev/null +++ b/apps/server/src/provider/cursorCloudWorkspace.test.ts @@ -0,0 +1,155 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { resolveCursorCloudRepository } from "./cursorCloudWorkspace.ts"; + +/** A checkout on `main` whose tip is on `origin/main`, plus a git runner for it. */ +const makeRepository = (remoteUrl: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-cloud-workspace-" }); + const git = (...args: string[]) => + NodeChildProcess.execFileSync("git", args, { cwd, stdio: "pipe" }).toString().trim(); + const commit = (message: string) => + git( + "-c", + "user.name=T3", + "-c", + "user.email=t3@example.com", + "commit", + "--allow-empty", + "-qm", + message, + ); + git("init", "--quiet", "-b", "main"); + commit("init"); + git("remote", "add", "origin", remoteUrl); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + git("config", "branch.main.remote", "origin"); + git("config", "branch.main.merge", "refs/heads/main"); + return { cwd, git, commit }; + }); + +it.layer(NodeServices.layer)("resolveCursorCloudRepository", (it) => { + it.effect("starts from the branch when the checkout sits on its pushed tip", () => + Effect.gen(function* () { + const { cwd } = yield* makeRepository("git@github.com:Acme/Widgets.git"); + expect(yield* resolveCursorCloudRepository(cwd)).toEqual({ + url: "https://github.com/Acme/Widgets", + startingRef: "main", + hasUnpushedLocalWork: false, + }); + }).pipe(Effect.scoped), + ); + + it.effect("starts from a chosen pushed branch and flags local work it leaves behind", () => + Effect.gen(function* () { + const { cwd, commit } = yield* makeRepository("https://github.com/acme/widgets.git"); + commit("local only"); + const repository = yield* resolveCursorCloudRepository(cwd, "main"); + expect(repository.startingRef).toBe("main"); + expect(repository.hasUnpushedLocalWork).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("uses the selected remote's repository and branch", () => + Effect.gen(function* () { + const { cwd, git } = yield* makeRepository("https://github.com/fork/widgets.git"); + git("remote", "add", "upstream", "https://github.com/acme/widgets.git"); + git("update-ref", "refs/remotes/upstream/feature/nested", "HEAD"); + expect(yield* resolveCursorCloudRepository(cwd, "upstream/feature/nested")).toEqual({ + url: "https://github.com/acme/widgets", + startingRef: "feature/nested", + hasUnpushedLocalWork: false, + }); + expect(git("symbolic-ref", "--short", "HEAD")).toBe("main"); + }).pipe(Effect.scoped), + ); + + it.effect("prefers a local branch over a matching remote prefix", () => + Effect.gen(function* () { + const { cwd, git } = yield* makeRepository("https://github.com/fork/widgets.git"); + git("remote", "add", "upstream", "https://github.com/acme/widgets.git"); + git("branch", "upstream/main"); + git("update-ref", "refs/remotes/origin/upstream/main", "HEAD"); + expect(yield* resolveCursorCloudRepository(cwd, "upstream/main")).toMatchObject({ + url: "https://github.com/fork/widgets", + startingRef: "upstream/main", + }); + }).pipe(Effect.scoped), + ); + + for (const selection of ["feature", "origin/main"]) { + it.effect(`warns about unpublished work when selecting ${selection}`, () => + Effect.gen(function* () { + const { cwd, git, commit } = yield* makeRepository("https://github.com/acme/widgets.git"); + git("checkout", "-qb", "feature"); + git("config", "branch.feature.remote", "origin"); + git("config", "branch.feature.merge", "refs/heads/main"); + commit("unpublished feature work"); + git("checkout", "-q", "main"); + expect((yield* resolveCursorCloudRepository(cwd, selection)).hasUnpushedLocalWork).toBe( + true, + ); + }).pipe(Effect.scoped), + ); + } + + it.effect("warns about a dirty checkout when selecting its remote branch", () => + Effect.gen(function* () { + const { cwd, git } = yield* makeRepository("https://github.com/acme/widgets.git"); + yield* (yield* FileSystem.FileSystem).writeFileString(NodePath.join(cwd, "a.txt"), "x"); + git("add", "a.txt"); + expect((yield* resolveCursorCloudRepository(cwd, "origin/main")).hasUnpushedLocalWork).toBe( + true, + ); + }).pipe(Effect.scoped), + ); + + it.effect("refuses a chosen branch that is not on GitHub", () => + Effect.gen(function* () { + const { cwd, git } = yield* makeRepository("https://github.com/acme/widgets.git"); + git("branch", "feature"); + const error = yield* Effect.flip(resolveCursorCloudRepository(cwd, "feature")); + expect(error.detail).toMatch(/Branch 'feature' is not on GitHub yet/); + }).pipe(Effect.scoped), + ); + + it.effect("starts from the exact commit when the branch has moved on without it", () => + Effect.gen(function* () { + const { cwd, git, commit } = yield* makeRepository("https://github.com/acme/widgets.git"); + const pushed = git("rev-parse", "HEAD"); + commit("pushed later"); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + git("reset", "--quiet", "--hard", pushed); + yield* (yield* FileSystem.FileSystem).writeFileString(NodePath.join(cwd, "a.txt"), "x"); + git("add", "a.txt"); + + const repository = yield* resolveCursorCloudRepository(cwd); + expect(repository.startingRef).toBe(pushed); + expect(repository.hasUnpushedLocalWork).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("refuses a commit that is not on GitHub yet", () => + Effect.gen(function* () { + const { cwd, commit } = yield* makeRepository("https://github.com/acme/widgets.git"); + commit("local only"); + const error = yield* Effect.flip(resolveCursorCloudRepository(cwd)); + expect(error.detail).toMatch(/is not on GitHub yet/); + }).pipe(Effect.scoped), + ); + + it.effect("refuses a repository hosted somewhere other than GitHub", () => + Effect.gen(function* () { + const { cwd } = yield* makeRepository("https://gitlab.com/acme/widgets.git"); + const error = yield* Effect.flip(resolveCursorCloudRepository(cwd)); + expect(error.detail).toMatch(/only works with GitHub/); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/cursorCloudWorkspace.ts b/apps/server/src/provider/cursorCloudWorkspace.ts new file mode 100644 index 000000000000..079916705225 --- /dev/null +++ b/apps/server/src/provider/cursorCloudWorkspace.ts @@ -0,0 +1,149 @@ +import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { ChildProcess } from "effect/unstable/process"; + +import { spawnAndCollect } from "./providerSnapshot.ts"; + +class CursorCloudWorkspaceError extends Schema.TaggedError()( + "CursorCloudWorkspaceError", + { detail: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return this.detail; + } +} + +interface CursorCloudRepository { + /** `https://github.com//`, the form the API accepts. */ + readonly url: string; + /** A branch on the remote, or a commit SHA when inferred from a checkout off any tip. */ + readonly startingRef: string; + /** The checkout has work on the starting branch that the cloud agent will not see. */ + readonly hasUnpushedLocalWork: boolean; +} + +/** + * Resolve where a cloud agent starts. The agent clones from GitHub, so the + * starting point must already be there; anything else would silently start + * from different code. `selectedBranch` is the thread's chosen branch; without + * one, the local checkout's position decides. + */ +export const resolveCursorCloudRepository = Effect.fn("resolveCursorCloudRepository")(function* ( + cwd: string, + selectedBranch?: string, + env?: NodeJS.ProcessEnv, +) { + const git = (args: ReadonlyArray) => + spawnAndCollect("git", ChildProcess.make("git", args, { cwd, ...(env ? { env } : {}) })).pipe( + Effect.map((result) => (result.code === 0 ? result.stdout.trim() : undefined)), + Effect.mapError( + (cause) => new CursorCloudWorkspaceError({ detail: "Could not run git.", cause }), + ), + ); + + const head = yield* git(["rev-parse", "HEAD"]); + if (!head) { + return yield* new CursorCloudWorkspaceError({ + detail: "Cursor Cloud needs a git repository with at least one commit.", + }); + } + + const checkedOut = yield* git(["symbolic-ref", "--quiet", "--short", "HEAD"]); + const localBranch = selectedBranch + ? yield* git(["rev-parse", "--verify", "--quiet", `refs/heads/${selectedBranch}`]) + : undefined; + const remotes = (yield* git(["remote"]))?.split("\n") ?? []; + const selectedRemote = + localBranch === undefined && selectedBranch + ? remotes + .sort((a, b) => b.length - a.length) + .find((name) => selectedBranch.startsWith(`${name}/`)) + : undefined; + const branch = selectedRemote ? undefined : (selectedBranch ?? checkedOut); + const trackedRemote = branch ? yield* git(["config", `branch.${branch}.remote`]) : undefined; + const remote = + selectedRemote ?? (trackedRemote && trackedRemote !== "." ? trackedRemote : "origin"); + const remoteUrl = yield* git(["remote", "get-url", remote]); + const nameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl ?? null); + if (!nameWithOwner) { + return yield* new CursorCloudWorkspaceError({ + detail: remoteUrl + ? `Cursor Cloud only works with GitHub repositories, and remote '${remote}' is not on GitHub.` + : `Cursor Cloud needs a GitHub remote named '${remote}'.`, + }); + } + + const mergeRef = branch ? yield* git(["config", `branch.${branch}.merge`]) : undefined; + // The branch picker can hand over a remote ref such as `origin/feature`. + const remoteQualified = + selectedRemote && selectedBranch ? selectedBranch.slice(selectedRemote.length + 1) : undefined; + const upstreamBranch = mergeRef?.startsWith("refs/heads/") + ? mergeRef.slice("refs/heads/".length) + : (remoteQualified ?? selectedBranch); + const upstreamTip = upstreamBranch + ? yield* git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${upstreamBranch}`]) + : undefined; + const status = yield* git(["status", "--porcelain", "--untracked-files=no"]); + const dirty = (status ?? "").length > 0; + + if (selectedBranch !== undefined) { + if (!upstreamBranch || !upstreamTip) { + return yield* new CursorCloudWorkspaceError({ + detail: `Branch '${selectedBranch}' is not on GitHub yet. Push it, or pick a branch that is.`, + }); + } + const localRefs = selectedRemote + ? ( + (yield* git(["for-each-ref", "--format=%(refname:short)\t%(upstream)", "refs/heads/"])) ?? + "" + ) + .split("\n") + .flatMap((line) => { + const [name, upstream] = line.split("\t"); + return name && upstream === `refs/remotes/${remote}/${upstreamBranch}` ? [name] : []; + }) + : localBranch + ? [selectedBranch] + : []; + let hasUnpushedLocalWork = false; + for (const name of localRefs) { + const ahead = yield* git(["rev-list", "--count", `${upstreamTip}..refs/heads/${name}`]); + if ((name === checkedOut && dirty) || Number(ahead ?? 0) > 0) { + hasUnpushedLocalWork = true; + break; + } + } + return { + url: `https://github.com/${nameWithOwner}`, + startingRef: upstreamBranch, + hasUnpushedLocalWork, + } satisfies CursorCloudRepository; + } + + let startingRef: string; + if (upstreamBranch && upstreamTip === head) { + startingRef = upstreamBranch; + } else { + const containing = yield* git([ + "for-each-ref", + "--contains", + head, + "--count=1", + "--format=%(refname)", + `refs/remotes/${remote}/`, + ]); + if (!containing) { + return yield* new CursorCloudWorkspaceError({ + detail: `Commit ${head.slice(0, 7)} is not on GitHub yet. Push it, then send your message again.`, + }); + } + startingRef = head; + } + + return { + url: `https://github.com/${nameWithOwner}`, + startingRef, + hasUnpushedLocalWork: dirty, + } satisfies CursorCloudRepository; +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 43595dbafce1..593bf9308ce2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11321,6 +11321,74 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const bootstrap of [false, true]) { + it.effect( + `rejects unsupported cloud execution before ${bootstrap ? "bootstrap" : "thread creation"}`, + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + const createdAt = "2026-01-01T00:00:00.000Z"; + const createThread = { + projectId: defaultProjectId, + title: "Cloud Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + executionTarget: "cloud" as const, + branch: null, + worktreePath: null, + createdAt, + }; + const wsUrl = yield* getWsServerUrl("/ws"); + const failure = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.flip( + client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + bootstrap + ? { + type: "thread.turn.start", + commandId: CommandId.make("cmd-cloud-bootstrap"), + threadId: ThreadId.make("thread-cloud"), + message: { + messageId: MessageId.make("message-cloud"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { createThread }, + createdAt, + } + : { + type: "thread.create", + commandId: CommandId.make("cmd-cloud-create"), + threadId: ThreadId.make("thread-cloud"), + ...createThread, + }, + ), + ), + ), + ); + assert.include(failure.message, "does not support cloud execution"); + assert.deepEqual(dispatchedCommands, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + } + it.effect( "bootstraps first-send worktree turns on the server before dispatching turn start", () => diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 050650b11515..a04694ffcaef 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ProviderSendTurnInput, + type ProviderSessionStartInput, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -989,3 +990,50 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( }); }), ); + +it.effect("reattaches a cloud thread's turn instead of settling it as interrupted", () => + Effect.gen(function* () { + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const thread = { + ...makeThread("thread-cloud", "running", TurnId.make("turn-cloud")), + executionTarget: "cloud" as const, + }; + const reattached = yield* Deferred.make(); + const dispatched: OrchestrationCommand[] = []; + const binding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: thread.id, + provider: ProviderDriverKind.make("cursor"), + providerInstanceId: cursorInstanceId, + status: "running", + resumeCursor: { agentId: "bc-1" }, + runtimePayload: { activeTurnId: thread.session.activeTurnId }, + }; + + yield* runReconciliation({ + threads: [thread], + providerService: { + ...makeProviderService(), + startSession: (threadId, input) => + Deferred.succeed(reattached, input).pipe(Effect.as({ threadId } as never)), + }, + directory: { + getBinding: () => Effect.succeedSome(binding), + upsert: () => Effect.die("a reattached binding is not rewritten"), + recordImportedTranscript: () => Effect.die("unused"), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe( + Effect.as({ sequence: dispatched.length }), + ), + }); + + const input = yield* Deferred.await(reattached); + assert.equal(input.providerInstanceId, cursorInstanceId); + assert.equal(input.executionTarget, "cloud"); + assert.equal(input.resumeCursor, undefined, "the persisted cursor is used"); + assert.deepStrictEqual(dispatched, [], "the running turn is left for the provider to finish"); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 138dbcedfa85..71cea63ce189 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -643,6 +643,40 @@ export const reconcileProviderSessions = Effect.gen(function* () { ); }); + const cloudInstanceId = + thread.executionTarget === "cloud" && + Option.isSome(binding) && + binding.value.resumeCursor != null && + thread.archivedAt === null && + thread.deletedAt === null + ? binding.value.providerInstanceId + : undefined; + if (cloudInstanceId !== undefined) { + // The turn kept running in the cloud workspace. Reattach to it rather + // than treating it as interrupted or prompting it to continue. + yield* forkParked( + providerService + .startSession(thread.id, { + threadId: thread.id, + providerInstanceId: cloudInstanceId, + modelSelection: thread.modelSelection, + executionTarget: "cloud", + runtimeMode: session.runtimeMode, + }) + .pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to reattach cloud provider session after restart", { + threadId: thread.id, + cause, + }).pipe(Effect.andThen(settleAsError(ORPHANED_PROVIDER_SESSION_ERROR))), + ), + ), + ); + continue; + } + if ( Option.isSome(binding) && (continuationMarked || interruptedByRestart) && diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 077087a7d84e..c7365b4eee28 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1431,6 +1431,9 @@ const makeWsRpcLayer = ( interactionMode: bootstrap.createThread.interactionMode, branch: bootstrap.createThread.branch, worktreePath: bootstrap.createThread.worktreePath, + ...(bootstrap.createThread.executionTarget !== undefined + ? { executionTarget: bootstrap.createThread.executionTarget } + : {}), createdAt: bootstrap.createThread.createdAt, }); // The successful create is a fence in the engine command queue: @@ -1793,8 +1796,38 @@ const makeWsRpcLayer = ( ), ); + const validatedDispatch = Effect.gen(function* () { + const createThread = + normalizedCommand.type === "thread.create" + ? normalizedCommand + : normalizedCommand.type === "thread.turn.start" + ? normalizedCommand.bootstrap?.createThread + : undefined; + if (createThread?.executionTarget === "cloud") { + const providers = yield* providerRegistry.getProviders; + const selections = [createThread.modelSelection]; + if ( + normalizedCommand.type === "thread.turn.start" && + normalizedCommand.modelSelection + ) { + selections.push(normalizedCommand.modelSelection); + } + for (const selection of selections) { + if ( + !providers.some( + (provider) => provider.instanceId === selection.instanceId && provider.cloud, + ) + ) { + return yield* new OrchestrationDispatchCommandError({ + message: `Provider instance '${selection.instanceId}' does not support cloud execution.`, + }); + } + } + } + return yield* dispatchEffect; + }); return startup - .enqueueCommand(dispatchEffect) + .enqueueCommand(validatedDispatch) .pipe( Effect.mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command"), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index eca4fab601d0..cd16c3f32619 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadExecutionTarget, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, FolderGit2Icon, @@ -39,6 +39,7 @@ import { } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { BranchToolbarExecutionTargetSelector } from "./BranchToolbarExecutionTargetSelector"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { ComposerControl } from "./chat/ComposerControl"; import { @@ -88,9 +89,16 @@ interface BranchToolbarProps { onEnvironmentChange?: (environmentId: EnvironmentId) => void; composerControlsHostRef?: (element: HTMLDivElement | null) => void; contextStripVisible?: boolean; + executionTarget?: ThreadExecutionTarget; + /** The selected provider's cloud status; absent when it cannot run in the cloud. */ + cloud?: { readonly available: boolean; readonly message?: string | undefined } | null; + onExecutionTargetChange?: (target: ThreadExecutionTarget) => void; + onCloudSetup?: () => void; } interface MobileRunContextSelectorProps { + /** False for a cloud thread, which has no local workspace to pick. */ + showWorkspace: boolean; forceNewWorktree: boolean; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; @@ -110,6 +118,7 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + showWorkspace, forceNewWorktree, autoEnvironmentLabel, onAutoEnvironment, @@ -145,15 +154,15 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); - const isLocked = envLocked || envModeLocked; - const workspaceIcon = ( + const isLocked = showWorkspace ? envLocked || envModeLocked : envLocked || !showEnvironmentPicker; + const workspaceIcon = showWorkspace ? ( }> {workspaceLabel} - ); + ) : null; const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. @@ -213,7 +222,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-context-control data-composer-shortcut={[ showEnvironmentPicker && !envLocked ? "composer.host" : "", - !envModeLocked ? "composer.workspace" : "", + showWorkspace && !envModeLocked ? "composer.workspace" : "", ].join(" ")} > {triggerContent} @@ -269,44 +278,50 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ))} - + {showWorkspace ? : null} ) : null} - - Workspace - { - if (value === "previous-worktree") { - onUsePreviousWorktree(); - return; - } - onEnvModeChange(value as EnvMode); - }} - > - - - {activeWorktreePath ? ( - - ) : ( - - )} - - - - - - - {resolveEnvModeLabel("worktree")} - - - {previousWorktreeLabel ? ( - - + {showWorkspace ? ( + + Workspace + { + if (value === "previous-worktree") { + onUsePreviousWorktree(); + return; + } + onEnvModeChange(value as EnvMode); + }} + > + + + {activeWorktreePath ? ( + + ) : ( + + )} + + + + + + + {resolveEnvModeLabel("worktree")} + - ) : null} - - + {previousWorktreeLabel ? ( + + + + ) : null} + + + ) : null} ); @@ -525,8 +540,16 @@ export const BranchToolbar = memo(function BranchToolbar({ onEnvironmentChange, composerControlsHostRef, contextStripVisible = true, + executionTarget = "local", + cloud, + onExecutionTargetChange, + onCloudSetup, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); + const runsInCloud = executionTarget === "cloud"; + // Parallel model runs each need their own local worktree. + const showExecutionTarget = + !forceNewWorktree && (runsInCloud || (cloud != null && onExecutionTargetChange !== undefined)); const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -629,9 +652,29 @@ export const BranchToolbar = memo(function BranchToolbar({ !contextStripVisible && "pointer-events-none invisible absolute inset-x-0 top-full", )} > - {showGitControls ? ( + {showGitControls && showExecutionTarget ? ( + <> + onExecutionTargetChange?.(target)} + onCloudSetup={() => onCloudSetup?.()} + /> + {!runsInCloud || showEnvironmentIndicator ? ( + + ) : null} + + ) : null} + {showGitControls && (!runsInCloud || showEnvironmentIndicator) ? (
) : null} - {showGitControls || showEnvironmentIndicator ? ( + {(showGitControls && !runsInCloud) || showEnvironmentIndicator ? (
- {showGitControls ? ( + {showGitControls && !runsInCloud ? ( )} - {showGitControls ? ( + {/* A cloud agent works in its own clone, so there is no local workspace to pick. */} + {showGitControls && !runsInCloud ? ( void; startFromOrigin: boolean; @@ -110,6 +115,7 @@ export function BranchToolbarBranchSelector({ draftId, envLocked, effectiveEnvModeOverride, + selectsCloudStartingBranch = false, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, startFromOrigin, @@ -162,13 +168,16 @@ export function BranchToolbarBranchSelector({ const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; - const effectiveEnvMode = - effectiveEnvModeOverride ?? - resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread, - draftThreadEnvMode: draftThread?.envMode, - }); + // A cloud starting branch is picked like a new worktree's base: recorded on + // the thread, never checked out. + const effectiveEnvMode = selectsCloudStartingBranch + ? "worktree" + : (effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread, + draftThreadEnvMode: draftThread?.envMode, + })); // --------------------------------------------------------------------------- // Thread branch mutation (colocated — only this component calls it) @@ -204,7 +213,7 @@ export function BranchToolbarBranchSelector({ setDraftThreadContext(draftId ?? threadRef, { branch, worktreePath, - envMode: nextDraftEnvMode, + envMode: selectsCloudStartingBranch ? (draftThread?.envMode ?? "local") : nextDraftEnvMode, environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); @@ -221,6 +230,8 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + selectsCloudStartingBranch, + draftThread?.envMode, draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, @@ -647,7 +658,7 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode, resolvedActiveBranch, resolvedActiveBranchIsRemote, - startFromOrigin, + startFromOrigin: startFromOrigin && !selectsCloudStartingBranch, }); // Branch status is the fallback when this thread has no linked pull requests. @@ -810,7 +821,12 @@ export function BranchToolbarBranchSelector({ // No press-scale: the popup aligns live to this trigger, so a // momentary 0.97 shrink would drag the open popup ~3px sideways. className="min-w-0 max-w-full active:scale-100" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + // A started cloud thread's workspace already exists; its branch is history. + disabled={ + isInitialBranchesLoadPending || + isBranchActionPending || + (selectsCloudStartingBranch && envLocked) + } >
- {isSelectingWorktreeBase ? ( + {isSelectingWorktreeBase && !selectsCloudStartingBranch ? ( = { local: "Local", cloud: "Cloud" }; +const TARGET_DESCRIPTION: Record = { + local: "The agent works in this project's checkout.", + cloud: "The agent works in a cloud workspace cloned from GitHub.", +}; + +interface BranchToolbarExecutionTargetSelectorProps { + envLocked: boolean; + executionTarget: ThreadExecutionTarget; + cloudAvailable: boolean; + /** Why Cloud is unavailable, shown in place of its description. */ + cloudMessage?: string | undefined; + onExecutionTargetChange: (target: ThreadExecutionTarget) => void; + onCloudSetup: () => void; +} + +export const BranchToolbarExecutionTargetSelector = memo( + function BranchToolbarExecutionTargetSelector({ + envLocked, + executionTarget, + cloudAvailable, + cloudMessage, + onExecutionTargetChange, + onCloudSetup, + }: BranchToolbarExecutionTargetSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); + const items = useMemo( + () => [ + { value: "local", label: TARGET_LABEL.local }, + { value: "cloud", label: TARGET_LABEL.cloud }, + ...(cloudAvailable ? [] : [{ value: CLOUD_SETUP_VALUE, label: "Set up Cloud" }]), + ], + [cloudAvailable], + ); + const Icon = executionTarget === "cloud" ? CloudIcon : LaptopIcon; + + if (envLocked) { + return ( + + } + className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6" + data-composer-context-control + > + + + + {TARGET_LABEL[executionTarget]} + + + + {TARGET_DESCRIPTION[executionTarget]} + + ); + } + + return ( + + ); + }, +); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 528024de4393..6d46ac86a673 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -490,6 +490,7 @@ export function buildLocalDraftThread( latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, + ...(draftThread.executionTarget ? { executionTarget: draftThread.executionTarget } : {}), checkpoints: [], pullRequests: [], activities: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61f1770c4bef..0c649f363e49 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -32,6 +32,7 @@ import { type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, + type ThreadExecutionTarget, type ThreadId, type ThreadLinkedPullRequest, type TurnId, @@ -257,6 +258,7 @@ import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, + cloudProviderSnapshot, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, sortProviderInstanceEntries, @@ -2597,7 +2599,7 @@ export default function ChatView(props: ChatViewProps) { const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; + const environmentProviderStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -2607,7 +2609,7 @@ export default function ChatView(props: ChatViewProps) { thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, - providers: providerStatuses, + providers: environmentProviderStatuses, }); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; @@ -2838,17 +2840,20 @@ export default function ChatView(props: ChatViewProps) { versionMismatchThreadContinuation, versionMismatchServerLabel, ]); - const providerInstanceEntries = useMemo( + const environmentProviderInstanceEntries = useMemo( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(environmentProviderStatuses), + settings, + ), ), - [providerStatuses, settings], + [environmentProviderStatuses, settings], ); - const { selectedProviderEntry, requestedDriverKind } = useMemo( + const { selectedProviderEntry: environmentProviderEntry, requestedDriverKind } = useMemo( () => resolveComposerProviderSelection({ - entries: providerInstanceEntries, + entries: environmentProviderInstanceEntries, candidateInstanceIds: [ selectedProviderByThreadId, activeThread?.session?.providerInstanceId, @@ -2864,13 +2869,43 @@ export default function ChatView(props: ChatViewProps) { activeThread?.modelSelection.instanceId, activeThread?.session?.providerInstanceId, lockedProvider, - providerInstanceEntries, + environmentProviderInstanceEntries, selectedProviderByThreadId, ], ); + // Resolve the instance before projecting its execution target. A local-only + // draft must not inherit cloud models or CLI readiness from a stale target. + const executionTarget: ThreadExecutionTarget = + activeThread?.executionTarget === "cloud" && + (isServerThread || environmentProviderEntry?.snapshot.cloud !== undefined) + ? "cloud" + : "local"; + const providerStatuses = useMemo( + () => + executionTarget === "cloud" + ? environmentProviderStatuses.map(cloudProviderSnapshot) + : environmentProviderStatuses, + [environmentProviderStatuses, executionTarget], + ); + const providerInstanceEntries = useMemo( + () => + executionTarget === "cloud" + ? sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), + ) + : environmentProviderInstanceEntries, + [environmentProviderInstanceEntries, executionTarget, providerStatuses, settings], + ); + const selectedProviderEntry = providerInstanceEntries.find( + (entry) => entry.instanceId === environmentProviderEntry?.instanceId, + ); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; + const activeProviderCloud = activeProviderStatus?.cloud ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, provider: activeProviderStatus, @@ -5847,13 +5882,18 @@ export default function ChatView(props: ChatViewProps) { ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? activeProjectSettings.settings.newWorktreesStartFromOrigin) : false; - const sendEnvMode = resolveSendEnvMode({ - requestedEnvMode: envMode, - isGitRepo, - }); + // A cloud agent clones the remote itself; no local worktree is prepared for it. + const sendEnvMode = + executionTarget === "cloud" + ? "local" + : resolveSendEnvMode({ + requestedEnvMode: envMode, + isGitRepo, + }); + // A cloud thread's branch names where its workspace started, not the checkout. const localCheckoutBranchMismatch = useMemo( () => - isServerThread + isServerThread && executionTarget !== "cloud" ? resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: envMode, activeWorktreePath, @@ -5861,7 +5901,14 @@ export default function ChatView(props: ChatViewProps) { currentGitBranch: gitStatusQuery.data?.refName ?? null, }) : null, - [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], + [ + activeThreadBranch, + activeWorktreePath, + envMode, + executionTarget, + gitStatusQuery.data?.refName, + isServerThread, + ], ); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { @@ -8346,6 +8393,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode: sendInteractionMode, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, + ...(executionTarget === "cloud" ? { executionTarget } : {}), createdAt: activeThread.createdAt, }, } @@ -9329,6 +9377,29 @@ export default function ChatView(props: ChatViewProps) { ], ); + // Only a draft can choose; the target is fixed once the thread exists. + const onExecutionTargetChange = useCallback( + (target: ThreadExecutionTarget) => { + if (!isLocalDraftThread) return; + setDraftThreadContext(composerDraftTarget, { + executionTarget: target, + // A cloud agent clones the remote; it cannot reuse a local worktree. + ...(target === "cloud" && draftThread?.worktreePath ? { worktreePath: null } : {}), + }); + scheduleComposerFocus(); + }, + [ + composerDraftTarget, + draftThread?.worktreePath, + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + ], + ); + const onCloudSetup = useCallback(() => { + if (activeProviderInstanceId) openProviderSetup(activeProviderInstanceId); + }, [activeProviderInstanceId, openProviderSetup]); + // "Work locally" on the setup card: cancel the bootstrap and remember the // draft. The cancelled dispatch deletes the half-made thread and puts the // message back in the composer; the effect below then flips the draft to @@ -10156,6 +10227,10 @@ export default function ChatView(props: ChatViewProps) { : undefined } availableEnvironments={logicalProjectEnvironments} + executionTarget={executionTarget} + cloud={activeProviderCloud} + {...(isLocalDraftThread ? { onExecutionTargetChange } : {})} + onCloudSetup={onCloudSetup} composerControlsHostRef={setRestingComposerControlsHost} contextStripVisible={showComposerContextStrip} /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4edce393883c..6329be13713d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -542,7 +542,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", + "agents cli codex claude cursor cloud agents grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ce2c51fe5247..b4412ed967f3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -16,6 +16,7 @@ import { PastedTextAttachmentSource, type PreviewAnnotationPayload, RuntimeMode, + type ThreadExecutionTarget, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -323,6 +324,7 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + executionTarget: Schema.optionalKey(Schema.Literal("cloud")), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -450,6 +452,8 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + /** Present when the thread will run in its provider's cloud rather than locally. */ + executionTarget?: "cloud"; promotedTo?: ScopedThreadRef | null; } @@ -553,6 +557,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + executionTarget?: ThreadExecutionTarget; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; @@ -1560,6 +1565,7 @@ function createDraftThreadState( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + ...(existingThread?.executionTarget ? { executionTarget: existingThread.executionTarget } : {}), promotedTo: null, }; } @@ -1594,6 +1600,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + left.executionTarget === right.executionTarget && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -2487,6 +2494,7 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + ...(persistedDraftThread.executionTarget ? { executionTarget: "cloud" as const } : {}), ...(persistedDraftThread.environmentSelection ? { environmentSelection: persistedDraftThread.environmentSelection } : {}), @@ -2762,6 +2770,12 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const nextExecutionTarget = + options.executionTarget === undefined + ? existing.executionTarget + : options.executionTarget === "cloud" + ? "cloud" + : undefined; const environmentSelection = options.environmentSelection ?? (options.branch != null || options.worktreePath != null @@ -2790,6 +2804,7 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + ...(nextExecutionTarget ? { executionTarget: nextExecutionTarget } : {}), promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2805,6 +2820,7 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.executionTarget === existing.executionTarget && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index cfd834a56541..5570d948bfc7 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, + cloudProviderSnapshot, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, @@ -541,3 +542,35 @@ describe("resolveDefaultProviderModelSelection", () => { ).toBeNull(); }); }); + +describe("cloudProviderSnapshot", () => { + const cloudModel = { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: null, + }; + + it("offers a cloud-capable instance's cloud models even when its CLI is missing", () => { + const cursor: ServerProvider = { + ...provider({ + provider: ProviderDriverKind.make("cursor"), + instanceId: "cursor", + status: "error", + }), + installed: false, + message: "Cursor CLI is not installed.", + cloud: { available: true, models: [cloudModel] }, + }; + const entry = deriveProviderInstanceEntries([cloudProviderSnapshot(cursor)])[0]!; + expect(isProviderInstancePickerReady(entry)).toBe(true); + expect(entry.models).toEqual([cloudModel]); + expect(entry.snapshot.message).toBeUndefined(); + expect(entry.snapshot.requiresNewThreadForModelChange).toBe(true); + }); + + it("leaves providers without a cloud alone", () => { + const codex = provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }); + expect(cloudProviderSnapshot(codex)).toBe(codex); + }); +}); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 88ea8a257240..dd8e95fbe2b9 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -31,6 +31,7 @@ import { } from "@t3tools/client-runtime/state/provider-instance-display"; export { normalizeProviderAccentColor, shouldShowInstanceBadge }; +export { cloudProviderSnapshot } from "@t3tools/client-runtime/provider-execution"; /** * Local-only placeholder used while a draft has no provider it can safely @@ -91,6 +92,7 @@ export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): b * first, synthesized defaults after) so callers that want "default first" * should sort with `sortProviderInstanceEntries` below. */ + export function deriveProviderInstanceEntries( providers: ReadonlyArray, ): ReadonlyArray { diff --git a/docs/README.md b/docs/README.md index 09c6269ed589..afa01f2bc834 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Cursor Cloud agents](./user/providers-cursor-cloud.md) --- diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b843b26cb0e8..e5f175330593 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -95,6 +95,20 @@ checkpoints but cannot roll back its conversation. The [checkpoint boundary](./o therefore rejects revert before touching files. Native permission and question option IDs must also survive normalization; a display label is not necessarily a valid reply. +## Cloud threads + +A thread's `executionTarget` is fixed at creation. A `cloud` thread runs in a +provider-hosted clone of the remote, so it belongs to the same instance and account +as a local one; the instance routes it to a different adapter rather than being a +separate provider. See the [Cursor router](../../apps/server/src/provider/Layers/CursorCloudAdapter.ts). +Local checkpoints would record the checkout rather than the agent's changes, so the +[checkpoint reactor](../../apps/server/src/orchestration/Layers/CheckpointReactor.ts) +skips cloud threads. A cloud turn also outlives this server: startup +[reconciliation](../../apps/server/src/serverRuntimeStartup.ts) reattaches the session +from its resume cursor instead of settling the turn as interrupted, and adapter +shutdown must not announce `session.exited`. The cursor may only name a run T3 has +not seen finish, so ProviderService saves the settled cursor at every turn boundary. + ## Attachments and stored history Attachments live outside the project workspace. [ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) diff --git a/docs/user/install.md b/docs/user/install.md index 53ad5fbbed8d..def476e0858f 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -123,7 +123,8 @@ computer. Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. Cursor's executable is `cursor-agent`, although its login command is -`agent login`. Antigravity can use its managed runtime without a `PATH` entry. +`agent login`. Cursor can also run threads as cloud agents, which need only a Cursor +API key. Antigravity can use its managed runtime without a `PATH` entry. T3 Code warns when a provider version has known compatibility problems with your release. Check **Settings → Providers** on that environment for the recommended @@ -144,8 +145,8 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), -[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and -[Antigravity](./providers-antigravity.md). +[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), +[Antigravity](./providers-antigravity.md), and [Cursor Cloud agents](./providers-cursor-cloud.md). ## Next steps diff --git a/docs/user/providers-cursor-cloud.md b/docs/user/providers-cursor-cloud.md new file mode 100644 index 000000000000..ed787e7ea191 --- /dev/null +++ b/docs/user/providers-cursor-cloud.md @@ -0,0 +1,52 @@ +# Cursor Cloud agents + +A Cursor thread can run on your machine through the Cursor CLI, or as a Cursor +Cloud agent on a Cursor-hosted machine that works from your GitHub repository. +Hand off work that should keep going while your computer sleeps or T3 Code +restarts, and follow it in the same thread. + +## Set up + +1. Create an API key in the Cursor dashboard under **API Keys**. +2. In **Settings > Providers**, enable **Cursor** and add a `CURSOR_API_KEY` + environment variable with that key. Keep it marked sensitive. Cloud agents do + not need the Cursor CLI to be installed. +3. Give Cursor access to the repository through its GitHub app. + +The Cursor CLI also signs in with `CURSOR_API_KEY` when it is set. + +## Start a cloud thread + +In the web or desktop app, with Cursor selected in a new thread, choose **Cloud** instead of **Local** at the +start of the bar below the composer. If Cloud is not set up yet, **Set up Cloud** +opens the provider settings. The choice is fixed once you send the first message. +You can follow up in an existing cloud thread from the mobile app; its model stays +fixed for the conversation. + +The branch picker chooses the pushed branch the agent starts from; it never +changes your checkout. The project must be a git checkout with a GitHub remote, +and the branch must already be on GitHub. Local changes that are not pushed are not +included, and T3 Code warns when you have some. + +The agent pushes its work to a new `cursor/…` branch. With **Cloud pull requests** +on, Cursor opens a pull request and T3 Code links it to the thread. + +## Working with a cloud agent + +- Send one message at a time. Wait for the run to finish, or stop it, before + following up. Stopping cancels the run in Cursor. +- The model picker lists Cursor's cloud models. To change the model, start a new + thread. **Default** uses your Cursor default model. +- Cloud agents do not ask for approval. They run with full access inside their + machine, whatever permission mode the thread uses. +- Attach up to five images. Other files stay on your machine, so paste their + contents instead. +- Checkpoints, revert, and compaction are not available. The terminal, diffs, and + git actions show your local checkout, not the agent's. Review the agent's work in + its branch or pull request. + +## Lost connections and restarts + +T3 Code reconnects to a running agent after a dropped connection without losing +output. When the T3 Code server restarts during a run, it reattaches on startup +and shows the run's final reply; activity from while it was down is not replayed. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 80d36759e13b..8d312079c77a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./provider-execution": { + "types": "./src/providerExecution.ts", + "default": "./src/providerExecution.ts" + }, "./device/stream": { "types": "./src/device/stream.ts", "default": "./src/device/stream.ts" diff --git a/packages/client-runtime/src/providerExecution.ts b/packages/client-runtime/src/providerExecution.ts new file mode 100644 index 000000000000..3178dcb86d35 --- /dev/null +++ b/packages/client-runtime/src/providerExecution.ts @@ -0,0 +1,24 @@ +import type { ServerProvider } from "@t3tools/contracts"; + +/** + * A provider as a cloud thread sees it. An instance that can run in the cloud + * offers its cloud models and is ready whenever cloud is set up, whatever its + * local CLI's state. Instances without cloud support are unchanged. + */ +export function cloudProviderSnapshot(snapshot: ServerProvider): ServerProvider { + const { cloud, message: _localMessage, ...rest } = snapshot; + if (!cloud) return snapshot; + return { + ...rest, + cloud, + installed: true, + status: cloud.available ? "ready" : "error", + auth: { status: cloud.available ? "authenticated" : "unauthenticated" }, + ...(cloud.message ? { message: cloud.message } : {}), + models: cloud.models, + // A cloud agent keeps the model it was created with, and has no local commands. + requiresNewThreadForModelChange: true, + slashCommands: [], + skills: [], + }; +} diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 2efe1076dc4b..02ee1c33b6a5 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -211,6 +211,7 @@ describe("environment entity projections", () => { ...THREAD_SHELL, environmentId: ENVIRONMENT_ID, title: "Cached thread", + executionTarget: "cloud", branch: "stale-branch", worktreePath: "/repo/stale-worktree", activeOrderKey: "t", @@ -240,6 +241,7 @@ describe("environment entity projections", () => { activeOrderKey: "f", unsettledAt: "2026-03-09T12:00:00.000Z", }); + expect(merged?.executionTarget).toBe("local"); expect(merged?.messages).toBe(messages); }); diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index d32329a741b4..c51011ed6fba 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -52,6 +52,7 @@ export function mergeEnvironmentThread( interactionMode: shell.interactionMode, branch: shell.branch, worktreePath: shell.worktreePath, + executionTarget: shell.executionTarget ?? "local", latestTurn: shell.latestTurn, createdAt: shell.createdAt, updatedAt: shell.updatedAt, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 66f78a0464ec..5d6a6e30302a 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -122,6 +122,9 @@ export function applyThreadDetailEvent( interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + ...(event.payload.executionTarget !== undefined + ? { executionTarget: event.payload.executionTarget } + : {}), branchPullRequest: null, latestTurn: null, createdAt: event.payload.createdAt, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 31e5f9d63305..78bdbaf0639d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -164,6 +164,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; +/** Let Cursor resolve the user's, then the team's, default model. Never sent to the API. */ +export const CURSOR_CLOUD_DEFAULT_MODEL = "default"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1ae704d3f666..5ecdbdb65fbe 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -133,6 +133,14 @@ export const RuntimeMode = Schema.Literals([ ]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; + +/** + * Where a thread's agent works. `cloud` runs in a provider-hosted workspace + * cloned from the project's remote, so the local checkout does not reflect the + * agent's changes. Fixed when the thread is created; absent means `local`. + */ +export const ThreadExecutionTarget = Schema.Literals(["local", "cloud"]); +export type ThreadExecutionTarget = typeof ThreadExecutionTarget.Type; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; @@ -801,6 +809,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionTarget: Schema.optional(ThreadExecutionTarget), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), // Optional so payloads from pre-link servers still decode. pullRequests: Schema.Array(ThreadPullRequestLink).pipe( @@ -892,6 +901,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionTarget: Schema.optional(ThreadExecutionTarget), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), pullRequests: Schema.Array(ThreadPullRequestLink).pipe( Schema.withDecodingDefault(Effect.succeed([])), @@ -1129,6 +1139,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionTarget: Schema.optional(ThreadExecutionTarget), createdAt: IsoDateTime, historyImport: Schema.optional(Schema.Literal(true)), }); @@ -1297,6 +1308,7 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionTarget: Schema.optional(ThreadExecutionTarget), createdAt: IsoDateTime, }); @@ -1772,6 +1784,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionTarget: Schema.optional(ThreadExecutionTarget), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 8753ac2cf8e8..7b7e72f0ee46 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -21,6 +21,7 @@ import { ProviderUserInputAnswers, UserInputAttachments, RuntimeMode, + ThreadExecutionTarget, } from "./orchestration.ts"; import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; @@ -59,6 +60,9 @@ export const ProviderSessionStartInput = Schema.Struct({ cwd: Schema.optional(TrimmedNonEmptyString), title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), + executionTarget: Schema.optional(ThreadExecutionTarget), + /** The thread's branch. A cloud session starts its workspace from it on the remote. */ + branch: Schema.optional(TrimmedNonEmptyString), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 309b61935485..9e5f9ca4cab2 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -257,6 +257,8 @@ export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; const ThreadMetadataUpdatedPayload = Schema.Struct({ name: Schema.optional(TrimmedNonEmptyStringSchema), metadata: Schema.optional(UnknownRecordSchema), + /** A pull request the provider opened for this thread outside the local checkout. */ + pullRequestUrl: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c137cac9ac7a..9ad4857c8992 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -243,6 +243,16 @@ export const ServerProvider = Schema.Struct({ // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + // Present when the driver can also run threads in a provider-hosted cloud + // workspace. `available` stays false until the account is set up for it; + // `message` then says how. + cloud: Schema.optional( + Schema.Struct({ + available: Schema.Boolean, + message: Schema.optional(TrimmedNonEmptyString), + models: Schema.Array(ServerProviderModel), + }), + ), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3fd27b7f7379..8fd939e9d9c2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -716,13 +716,23 @@ export const CursorSettings = makeProviderSettingsSchema( }, }), ), + // Cloud threads authenticate with the instance's `CURSOR_API_KEY` + // environment variable, which Settings stores as a secret. + cloudAutoCreatePR: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Cloud pull requests", + description: "Cloud agents open a pull request when a run pushes changes.", + providerSettingsForm: { control: "switch" }, + }), + ), customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { - order: ["binaryPath", "apiEndpoint"], + order: ["binaryPath", "apiEndpoint", "cloudAutoCreatePR"], }, ); export type CursorSettings = typeof CursorSettings.Type; @@ -1431,6 +1441,7 @@ const CursorSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), apiEndpoint: Schema.optionalKey(TrimmedString), + cloudAutoCreatePR: Schema.optionalKey(Schema.Boolean), customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), });