Skip to content

Add Cursor provider sessions and model selection#1355

Draft
juliusmarminge wants to merge 1 commit intomainfrom
t3code/greeting
Draft

Add Cursor provider sessions and model selection#1355
juliusmarminge wants to merge 1 commit intomainfrom
t3code/greeting

Conversation

@juliusmarminge
Copy link
Member

@juliusmarminge juliusmarminge commented Mar 24, 2026

Summary

  • Adds Cursor as a first-class provider with ACP session lifecycle support, health checks, and adapter wiring in the server.
  • Implements Cursor model selection, including fast/plan mode mapping and session restart behavior when model options change.
  • Preserves provider/thread model state through orchestration, projection, and turn dispatch paths.
  • Updates the web app to surface Cursor traits, provider/model selection, and session drafting behavior.
  • Expands runtime ingestion so completed tool events retain structured tool metadata.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • Added and updated tests across server, contracts, shared, and web layers for Cursor adapter behavior, orchestration routing, session model changes, and UI state handling.
  • Not run: bun run test

Note

Medium Risk
Introduces a new provider adapter and session lifecycle (spawned CLI process + JSON-RPC) and changes orchestration routing/session restart behavior, which can affect turn execution and session persistence across providers.

Overview
Adds Cursor as a first-class provider end-to-end: a new ACP JSON-RPC transport (AcpJsonRpcConnection) plus CursorAdapterLive that spawns agent acp, manages session/new|load|prompt, maps ACP updates/tool calls/approvals into runtime events, and restarts sessions when the effective Cursor model changes.

Updates orchestration to persist thread.provider, route turns by explicit provider even for shared model slugs, treat Cursor model switching as restart-session, preserve resumeCursor across Cursor restarts, and merge per-thread modelOptions across turns.

Extends runtime ingestion to include structured data on tool.completed activities, wires Cursor into the adapter registry, server layers, session directory persistence, and provider health checks.

Updates the web app to support Cursor settings and UX: adds customCursorModels, Cursor model-family picker + trait controls (reasoning/fast/thinking/opus tier), avoids redundant tool entry previews, and persists a sticky provider/model selection when creating new threads.

Written by Cursor Bugbot for commit 12f825a. This will update automatically on new commits. Configure here.

Note

Add Cursor provider sessions and model selection across client and server

  • Adds a full Cursor provider integration: server-side CursorAdapterLive spawns the Cursor agent via ACP JSON-RPC, maps ACP errors to provider adapter errors, and is registered in the default ProviderAdapterRegistry.
  • Introduces AcpJsonRpcConnection — a new JSON-RPC framing layer over a child process with serialized writes, in-flight request tracking, and a notifications stream.
  • Adds Cursor model family constants, CursorModelOptions schema (reasoning, fastMode, thinking, claudeOpusTier), and model slug/alias resolution in packages/contracts and packages/shared.
  • Extends the composer draft store with stickyProvider persistence and Cursor model options normalization; the ProviderCommandReactor now routes turns by explicit provider and merges per-provider modelOptions across turns.
  • Adds CursorTraitsMenuContent and CursorTraitsPicker UI components for selecting Opus tier, reasoning level, and fast mode; the ProviderModelPicker now displays and dispatches Cursor family slugs.
  • Extends provider health checks to report Cursor CLI status (installed, authenticated, ready) via checkCursorProviderStatus.
  • Risk: session restart behavior changed — resumeCursor is now preserved when only the model changes and cleared only on provider change.
📊 Macroscope summarized 12f825a. 42 files reviewed, 14 issues evaluated, 7 issues filtered, 3 comments posted

🗂️ Filtered Issues

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts — 0 comments posted, 2 evaluated, 2 filtered
  • line 255: The projector.ts change unconditionally sets provider: payload.provider when constructing the OrchestrationThread object for thread.created events. For events persisted before this change (or events where the thread.create command did not include provider), payload.provider will be undefined. The decider in decider.ts conditionally spreads provider into the event payload (...(command.provider !== undefined ? { provider: command.provider } : {})), meaning the key is intentionally absent when not specified. However, the projector now always injects the key with a potentially undefined value. If the OrchestrationThread schema defines provider as an optionalKey (key can be absent but must be a string when present) rather than optional (which also accepts undefined), this will cause a decodeForEvent failure during event replay at startup for any existing thread.created events that lack a provider field. [ Out of scope (triage) ]
  • line 682: The test file now contains two identical test cases named "preserves completed tool metadata on projected tool activities" within the same describe("ProviderRuntimeIngestion", ...) block. The diff inserts a complete copy of the test (lines 682–737 after patch) immediately before the original test that already existed. Both tests have the same name, emit the same events, and assert the same expectations. While vitest will run both without crashing, this is an unintentional duplication that bloats test execution time and will cause confusion when one fails. [ Failed validation ]
apps/server/src/provider/Layers/CursorAdapter.test.ts — 0 comments posted, 2 evaluated, 1 filtered
  • line 281: Environment variable process.env.T3_ACP_EMIT_TOOL_CALLS is set at line 281, but the Effect.ensuring cleanup (lines 405-415) only wraps program, not the setup code at lines 283-313. If the adapter lookup, Deferred.make, or Stream.runForEach(...).pipe(Effect.forkChild) fails before program runs, the environment variable will not be restored. This could pollute subsequent tests with the modified T3_ACP_EMIT_TOOL_CALLS value. [ Cross-file consolidated ]
apps/server/src/provider/Layers/CursorAdapter.ts — 1 comment posted, 4 evaluated, 2 filtered
  • line 478: In the partial match phase (line 478), the alias is only lowercased but not normalized (special characters not replaced with spaces), while normalizeModeSearchText(mode) replaces all non-alphanumeric characters with spaces. This asymmetry means an alias like "plan-mode" would fail to match a mode whose normalized text contains "plan mode" because "plan mode".includes("plan-mode") is false. If any of the alias constants (e.g., ACP_PLAN_MODE_ALIASES) contain hyphens or other special characters, partial matches will silently fail. [ Out of scope (triage) ]
  • line 649: In parseSessionUpdate, when params.update is a record containing modeId/currentModeId but sessionUpdate is undefined and content is a record without a text string property, the parsed modeId is discarded at line 649. The function returns {} even though modeId was successfully parsed at lines 561-566. This causes the caller's if (p.modeId) check to fail, missing a mode state update. The final return {} at line 649 should include modeId like the other return paths do. [ Cross-file consolidated ]
apps/web/src/composerDraftStore.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 445: The cursorClaudeOpusTier value "high" is validated as valid (line 431-432) but never included in the output. Line 445 only includes claudeOpusTier when it equals "max", meaning "high" is silently dropped. The condition should be cursorClaudeOpusTier ? { claudeOpusTier: cursorClaudeOpusTier } : {} to preserve both valid values. [ Out of scope (triage) ]
packages/shared/src/model.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 352: In normalizeCursorModelOptions, the logic for persisting thinking only handles sel.thinking === false, but for claude-4.6-sonnet the defaultThinking is false (line 111). If a user enables thinking for sonnet (sel.thinking === true), this non-default value is not included in the returned options. The condition at line 352 should compare against the model's specific default: sel.thinking !== cap.defaultThinking rather than just checking for false. [ Cross-file consolidated ]

- Introduce Cursor ACP adapter and model selection probe
- Preserve cursor session resume state across model changes
- Propagate provider and runtime tool metadata through orchestration and UI
@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5753d845-2782-4425-aa58-e3c1c732facf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/greeting

Comment @coderabbitai help to get the list of available commands and usage tips.

@juliusmarminge juliusmarminge marked this pull request as draft March 24, 2026 07:29
@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 24, 2026
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Merge never clears cached provider model options
    • Replaced ?? fallback with key in incoming check so explicitly-present-but-undefined provider keys now clear cached values, and added cache deletion when merge produces undefined.
  • ✅ Fixed: Mock agent test uses strict equal with extra fields
    • Changed toEqual to toMatchObject so the assertion tolerates the extra modes field returned by the mock agent.

Create PR

Or push these changes by commenting:

@cursor push beb68c40d7
Preview (beb68c40d7)
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -50,20 +50,17 @@
   cached: ProviderModelOptions | undefined,
   incoming: ProviderModelOptions | undefined,
 ): ProviderModelOptions | undefined {
-  if (!cached && !incoming) {
-    return undefined;
+  if (incoming === undefined) return cached;
+  if (cached === undefined) return incoming;
+
+  const providerKeys = ["codex", "claudeAgent", "cursor"] as const;
+  const next: Record<string, unknown> = {};
+  for (const key of providerKeys) {
+    const value = key in incoming ? incoming[key] : cached[key];
+    if (value !== undefined) {
+      next[key] = value;
+    }
   }
-  const next = {
-    ...(incoming?.codex !== undefined || cached?.codex !== undefined
-      ? { codex: incoming?.codex ?? cached?.codex }
-      : {}),
-    ...(incoming?.claudeAgent !== undefined || cached?.claudeAgent !== undefined
-      ? { claudeAgent: incoming?.claudeAgent ?? cached?.claudeAgent }
-      : {}),
-    ...(incoming?.cursor !== undefined || cached?.cursor !== undefined
-      ? { cursor: incoming?.cursor ?? cached?.cursor }
-      : {}),
-  } satisfies Partial<ProviderModelOptions>;
   return Object.keys(next).length > 0 ? (next as ProviderModelOptions) : undefined;
 }
 
@@ -405,8 +402,12 @@
       threadModelOptions.get(input.threadId),
       input.modelOptions,
     );
-    if (mergedModelOptions !== undefined) {
-      threadModelOptions.set(input.threadId, mergedModelOptions);
+    if (input.modelOptions !== undefined) {
+      if (mergedModelOptions !== undefined) {
+        threadModelOptions.set(input.threadId, mergedModelOptions);
+      } else {
+        threadModelOptions.delete(input.threadId);
+      }
     }
     const normalizedInput = toNonEmptyProviderInput(input.messageText);
     const normalizedAttachments = input.attachments ?? [];

diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
--- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
+++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
@@ -32,7 +32,7 @@
         cwd: process.cwd(),
         mcpServers: [],
       });
-      expect(newResult).toEqual({ sessionId: "mock-session-1" });
+      expect(newResult).toMatchObject({ sessionId: "mock-session-1" });
 
       const promptResult = yield* conn.request("session/prompt", {
         sessionId: "mock-session-1",

: {}),
} satisfies Partial<ProviderModelOptions>;
return Object.keys(next).length > 0 ? (next as ProviderModelOptions) : undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge never clears cached provider model options

Medium Severity

mergeThreadModelOptions uses incoming?.codex ?? cached?.codex for each provider key. The ?? operator treats both undefined and the absence of a key identically, so there's no way for a turn to clear a previously-cached provider's model options back to undefined. Once a provider's options are set (e.g., cursor: { fastMode: true }), subsequent turns that omit that provider key will always inherit the stale cached value. This matters because mergedModelOptions is forwarded to providerService.sendTurn, so traits like fastMode become permanently sticky at the orchestration layer even when the user explicitly removes them.

Fix in Cursor Fix in Web

cwd: process.cwd(),
mcpServers: [],
});
expect(newResult).toEqual({ sessionId: "mock-session-1" });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock agent test uses strict equal with extra fields

Low Severity

The test asserts expect(newResult).toEqual({ sessionId: "mock-session-1" }) but the mock agent's session/new handler returns { sessionId, modes: modeState() } which includes an additional modes property. toEqual performs a deep strict equality check, so this assertion will fail. It likely needs toMatchObject instead.

Fix in Cursor Fix in Web

Comment on lines +1217 to +1219
const nf = yield* Stream.runDrain(
Stream.mapEffect(conn.notifications, handleNotification),
).pipe(Effect.forkChild);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/CursorAdapter.ts:1217

The notification fiber nf is forked on line 1217 but its errors are never observed — if handleNotification throws (e.g., due to a failed offerRuntimeEvent), the fiber terminates silently and notification processing stops without any error reporting or recovery. Consider adding error handling or supervision to ensure the stream continues or failures are logged.

-        const nf = yield* Stream.runDrain(
+        const nf = yield* Stream.runDrain(
           Stream.mapEffect(conn.notifications, handleNotification),
-        ).pipe(Effect.forkChild);
+        ).pipe(
+          Effect.tapError((e) => Effect.logError(`Notification stream error: ${e}`)),
+          Effect.retry({ schedule: Schedule.spaced(Duration.seconds(1)) }),
+          Effect.forkChild,
+        );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/CursorAdapter.ts around lines 1217-1219:

The notification fiber `nf` is forked on line 1217 but its errors are never observed — if `handleNotification` throws (e.g., due to a failed `offerRuntimeEvent`), the fiber terminates silently and notification processing stops without any error reporting or recovery. Consider adding error handling or supervision to ensure the stream continues or failures are logged.

Evidence trail:
apps/server/src/provider/Layers/CursorAdapter.ts lines 1217-1221 (fiber forked and stored without observation), line 672 (fiber typed as `Fiber.Fiber<void, never>`), lines 851-852 (fiber only interrupted, never joined/awaited), lines 702-703 (offerRuntimeEvent implementation with `never` error type)

readonly notifications: Stream.Stream<AcpInboundMessage, never>;
}

export function spawnAcpChildProcess(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low acp/AcpJsonRpcConnection.ts:43

spawnAcpChildProcess wraps spawn() in Effect.try, which only catches synchronous exceptions. spawn() failures like ENOENT emit asynchronously via the 'error' event, so the function returns success with a ChildProcess that will immediately fail. This causes the 'close' handler in attachAcpJsonRpcConnection to report AcpProcessExitedError instead of AcpSpawnError, and the unhandled 'error' event can emit an uncaught warning. Consider listening for the 'error' event and failing the Effect with AcpSpawnError when it fires.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/acp/AcpJsonRpcConnection.ts around line 43:

`spawnAcpChildProcess` wraps `spawn()` in `Effect.try`, which only catches synchronous exceptions. `spawn()` failures like ENOENT emit asynchronously via the 'error' event, so the function returns success with a `ChildProcess` that will immediately fail. This causes the 'close' handler in `attachAcpJsonRpcConnection` to report `AcpProcessExitedError` instead of `AcpSpawnError`, and the unhandled 'error' event can emit an uncaught warning. Consider listening for the 'error' event and failing the Effect with `AcpSpawnError` when it fires.

Evidence trail:
 apps/ ser ver/ sr c/ pro vider/ ac p/ Ac pJs on Rp cC onne ction. ts: 42- 60 (`spa wn Ac pC hild Proc ess` function using `Eff ect. try` with `spa wn()`), lines 186- 194 (` clo se` event handler using `Ac pP rocess Exit ed Error`). No 'err or' event lis tener is attached to the child process in the entire file.

const writeSerialized = (payload: Record<string, unknown>) =>
writeLock.withPermits(1)(writeRawLine(payload));

const sendRequest = (method: string, params?: unknown) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low acp/AcpJsonRpcConnection.ts:120

The deferred is added to the pending map on line 127 before writeRawLine executes on line 128. If writeRawLine throws an AcpSpawnError, the Effect fails and the caller receives the error, but the deferred remains in pending and is never resolved or removed. Over time with repeated write failures, orphaned deferreds accumulate in memory. Consider wrapping the write in a try/finally or restructuring so the deferred is only added to pending after the write succeeds, or ensure cleanup on failure.

Also found in 1 other location(s)

apps/server/src/provider/Layers/CursorAdapter.test.ts:281

Environment variable process.env.T3_ACP_EMIT_TOOL_CALLS is set at line 281, but the Effect.ensuring cleanup (lines 405-415) only wraps program, not the setup code at lines 283-313. If the adapter lookup, Deferred.make, or Stream.runForEach(...).pipe(Effect.forkChild) fails before program runs, the environment variable will not be restored. This could pollute subsequent tests with the modified T3_ACP_EMIT_TOOL_CALLS value.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/acp/AcpJsonRpcConnection.ts around line 120:

The deferred is added to the `pending` map on line 127 before `writeRawLine` executes on line 128. If `writeRawLine` throws an `AcpSpawnError`, the Effect fails and the caller receives the error, but the deferred remains in `pending` and is never resolved or removed. Over time with repeated write failures, orphaned deferreds accumulate in memory. Consider wrapping the write in a try/finally or restructuring so the deferred is only added to `pending` after the write succeeds, or ensure cleanup on failure.

Evidence trail:
apps/server/src/provider/acp/AcpJsonRpcConnection.ts lines 120-133: sendRequest function where deferred is added to pending (line 127) before writeRawLine is called (line 128). Lines 104-114: writeRawLine implementation that can throw AcpSpawnError. Lines 93-100: failAllPending function. Lines 212-217: close event handler that calls failAllPending - only triggered on process exit, not on write failure.

Also found in 1 other location(s):
- apps/server/src/provider/Layers/CursorAdapter.test.ts:281 -- Environment variable `process.env.T3_ACP_EMIT_TOOL_CALLS` is set at line 281, but the `Effect.ensuring` cleanup (lines 405-415) only wraps `program`, not the setup code at lines 283-313. If the adapter lookup, `Deferred.make`, or `Stream.runForEach(...).pipe(Effect.forkChild)` fails before `program` runs, the environment variable will not be restored. This could pollute subsequent tests with the modified `T3_ACP_EMIT_TOOL_CALLS` value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant