From 0d0e5167ea720703d67c1bbe4e807fc3b6c083a5 Mon Sep 17 00:00:00 2001 From: Gabriel Rodrigues Date: Fri, 30 Oct 2026 21:28:49 -0300 Subject: [PATCH 1/3] fix(cursor): keep ambient MCP when sending t3-code servers Bump @cursor/sdk to 1.2.0 so send-level mcpServers no longer clears settingSources plugins/project/user MCP, and pass t3-code on each send again. Co-authored-by: Cursor --- apps/server/package.json | 2 +- .../orchestrator-mcp-server.md | 7 ++- docs/user/cursor.md | 13 ++++- packages/provider-cursor/package.json | 2 +- .../src/server/CursorAgentSdk.ts | 5 ++ .../src/server/adapter.test.ts | 2 +- .../provider-cursor/src/server/adapter.ts | 3 +- pnpm-lock.yaml | 54 +++++++++---------- pnpm-workspace.yaml | 6 +++ 9 files changed, 60 insertions(+), 34 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 0dacbbe90720c..48d8788f40fbf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.276", - "@cursor/sdk": "1.0.38", + "@cursor/sdk": "1.2.0", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.11.0", diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index f46803f9d67b6..261c043483536 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -98,8 +98,11 @@ server headers or token. ### Cursor Agent SDK V2 Cursor receives the same authenticated HTTP MCP endpoint through the SDK's -`mcpServers` agent and send options. The adapter passes the authorization header -to the SDK but projects only redacted option metadata into protocol diagnostics. +`mcpServers` on `Agent.create` / `Agent.resume` and each `send`. With +`@cursor/sdk` 1.0.39+, send-level servers replace only the create-time inline +set; ambient servers from `settingSources` (`project`, `user`, `team`, `mdm`, +`plugins`) stay available. The adapter passes the authorization header to the +SDK but projects only redacted option metadata into protocol diagnostics. ### Grok ACP V2 diff --git a/docs/user/cursor.md b/docs/user/cursor.md index 6a6b1c8f284b3..4263ceb14fd8d 100644 --- a/docs/user/cursor.md +++ b/docs/user/cursor.md @@ -15,6 +15,17 @@ The adapter currently uses the SDK's local-agent runtime so runs operate in the workspace. Cursor cloud agents need repository and cloud-environment configuration that T3 Code does not expose yet. +Custom Cursor MCP servers and plugins are not configured in T3 Code. They load from the environment +machine's Cursor config when the agent opens: project `.cursor/mcp.json`, user `~/.cursor/mcp.json`, +and installed plugins under `~/.cursor/plugins`. Plugin tools +can appear without a login; OAuth MCP servers still need credentials. Run `agent mcp login ` +with the cwd set to the T3 project root (tokens land in +`~/.cursor/projects//mcp-auth.json`). Cursor desktop Connections auth does not write +that file, and the local SDK cannot complete interactive MCP OAuth itself—auth attempts fail with +messages such as "MCP authentication is not supported in local SDK runs" or that the auth server +does not support dynamic client registration. After you add or change skills, plugins, or MCP +servers, or after a successful CLI login, use **Restart agent session**. + ## Sign in Choose **Sign in**, then open the sign-in page and complete it in your browser. T3 Code @@ -36,7 +47,7 @@ The adapter supports: - creating and resuming local Cursor agent threads; - changing the model and model parameters between turns; - assistant text, reasoning, tool activity, plans, and todo streaming; -- thread-scoped T3 Code MCP tools; +- thread-scoped T3 Code MCP tools alongside ambient Cursor MCP/plugins from disk; - image attachments; - interruption, queued app messages, and orchestrator-owned interrupt/restart steering; - provider conversation snapshots through `Agent.messages.list()`; diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b619c3b61fdb4..90122cbe57e66 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -49,7 +49,7 @@ "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { - "@cursor/sdk": "1.0.38", + "@cursor/sdk": "1.2.0", "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 5986d09506d09..466cf20e0648b 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -139,6 +139,8 @@ export interface CursorAgentSdkLoggedAgentOptions { export interface CursorAgentSdkLoggedSendOptions { readonly model?: SendOptions["model"]; readonly mode?: SendOptions["mode"]; + readonly hasMcpServers?: boolean; + readonly mcpServerNames?: ReadonlyArray; readonly local?: { readonly force?: boolean; readonly hasCustomTools?: boolean; @@ -298,9 +300,12 @@ export function loggedCursorSendOptions( if (options === undefined) { return {}; } + const mcpServerNames = + options.mcpServers === undefined ? undefined : Object.keys(options.mcpServers).toSorted(); return { ...(options.model === undefined ? {} : { model: options.model }), ...(options.mode === undefined ? {} : { mode: options.mode }), + ...(mcpServerNames === undefined ? {} : { hasMcpServers: true, mcpServerNames }), ...(options.local === undefined ? {} : { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 790dd0e4415ea..b934fd2ca3493 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -189,7 +189,7 @@ describe("CursorAdapterV2", () => { Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, ); - assert.isFalse(sentWithMcpOverride); + assert.isTrue(sentWithMcpOverride); const rows = events.filter((event) => event.type === "subagent.updated"); assert.equal(rows[0]?.subagent.status, "running"); assert.equal(rows[0]?.subagent.model, model ?? null); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 03f346d9a6d9f..8da8b0099dc11 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2386,13 +2386,14 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ); const pendingUpdates: Array = []; let context: ActiveCursorTurn | null = null; - // MCP send overrides replace the ambient servers loaded when the agent opens. + // Create-time and send-time mcpServers merge with settingSources (SDK >= 1.0.39). const sdkRun = yield* agent.session .send({ message, options: { model: cursorSdkModelSelection(turnInput.modelSelection), mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", + ...(mcpServers === undefined ? {} : { mcpServers }), }, onDelta: (update) => { if (context === null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb4789ac81439..9d7201788f2d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -833,8 +833,8 @@ importers: specifier: ^0.3.276 version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) '@cursor/sdk': - specifier: 1.0.38 - version: 1.0.38(zod@4.6.5) + specifier: 1.2.0 + version: 1.2.0(zod@4.6.5) '@effect/platform-node': specifier: 4.0.5 version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) @@ -1692,8 +1692,8 @@ importers: packages/provider-cursor: dependencies: '@cursor/sdk': - specifier: 1.0.38 - version: 1.0.38(zod@4.6.5) + specifier: 1.2.0 + version: 1.2.0(zod@4.6.5) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -3499,38 +3499,38 @@ packages: resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==} engines: {node: '>=20.19.0'} - '@cursor/sdk-darwin-arm64@1.0.38': - resolution: {integrity: sha512-pXxj4pjbIoDo6ZjgksSf4VTD4EJz6BGDKvFjDVVUMg0PmLCVbceOnp0pqfRO8SVpdUSE45GFrXuLnSZgLag/xw==} + '@cursor/sdk-darwin-arm64@1.2.0': + resolution: {integrity: sha512-UkZ/RdxKCpOQ85nRgzSq+g0h+gIw36/lCEvQvomhs5EkSFZW3OwbeEzkAU3UYglZ7t3RNHj3mc5C5cdDtHguvw==} cpu: [arm64] os: [darwin] hasBin: true - '@cursor/sdk-darwin-x64@1.0.38': - resolution: {integrity: sha512-rutfTMq1f9WZckPFH8R5bzNzHaZg7I/TijVUkfD3GbqIqqc5OKHzdTMqZf+AFAqRDQccGJn4GLWcnQ9wTwIVNA==} + '@cursor/sdk-darwin-x64@1.2.0': + resolution: {integrity: sha512-mJTFdX+qv9dHAm59tT7uq7ci7+BDvFoNk4gdF2zGp9SLULicY+FdFD64p1qLen1bX5Y2soZaX5mWBy7BfObjcg==} cpu: [x64] os: [darwin] hasBin: true - '@cursor/sdk-linux-arm64@1.0.38': - resolution: {integrity: sha512-CqmUnM8W8W2mKucSrdHO0kn4ems82siWuo7kvVJvXCtPkaziVNC++dro5h9hSh2oVgRbB91sWQ4lzsHVIDpkvA==} + '@cursor/sdk-linux-arm64@1.2.0': + resolution: {integrity: sha512-Rs+cd5snuhxMAHkpqRfEGPB1qAsfqSOYJh5SOHZTOWy2LkRVO62WHwd4x80C2emj+dosTV5WAcuF2kMKKcrDJg==} cpu: [arm64] os: [linux] hasBin: true - '@cursor/sdk-linux-x64@1.0.38': - resolution: {integrity: sha512-RL6OJTAF3oozMG+HNtykySQ5BOP94yJNWP65wZ2yzMLTAW9ao80++OnaWJya1lndLacHLYP+QZT4igm5TT+BWQ==} + '@cursor/sdk-linux-x64@1.2.0': + resolution: {integrity: sha512-ainsGAFtkWTBZrLABp82Hvfv7VB/FxmZHE42qMVotFTRDwOY2iduvhSig910bVxv+kw3S00Srmu/9CxklLwhbw==} cpu: [x64] os: [linux] hasBin: true - '@cursor/sdk-win32-x64@1.0.38': - resolution: {integrity: sha512-IUfuCk/S/qzEB+tfT4yWH6iSMz56Lvakn1zNjdk+oAsZ7RJWtWkZcea6eqMSRB1WLmTeQG0uetXKX330w+xI0A==} + '@cursor/sdk-win32-x64@1.2.0': + resolution: {integrity: sha512-p3rkyta6R/nXIukd3/ifJq32otkkGoUi41T2jxnHbPHMhzE2F8OuJQGBVmORuQLMr+bIGGVZmCxertgs4WgzcQ==} cpu: [x64] os: [win32] hasBin: true - '@cursor/sdk@1.0.38': - resolution: {integrity: sha512-8UwPKaprIXvlVR71yHyNNTyFwZCOzfHW7W+FKjPdDS9Ym9FDZc51Jwsu9lAhxHf7n0US3t0pybkDaNFdldrQ+A==} + '@cursor/sdk@1.2.0': + resolution: {integrity: sha512-Nqm2U0zTWrYEDCv3Rdwsfa1gYZWsg+JnY+I2F8BwWSAr3FNo0TZWwyHWoWngZgriF1w02fJ4gjJWaul9zysvgQ==} engines: {node: '>=22.13'} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -13489,22 +13489,22 @@ snapshots: '@csstools/css-tokenizer@4.0.1': {} - '@cursor/sdk-darwin-arm64@1.0.38': + '@cursor/sdk-darwin-arm64@1.2.0': optional: true - '@cursor/sdk-darwin-x64@1.0.38': + '@cursor/sdk-darwin-x64@1.2.0': optional: true - '@cursor/sdk-linux-arm64@1.0.38': + '@cursor/sdk-linux-arm64@1.2.0': optional: true - '@cursor/sdk-linux-x64@1.0.38': + '@cursor/sdk-linux-x64@1.2.0': optional: true - '@cursor/sdk-win32-x64@1.0.38': + '@cursor/sdk-win32-x64@1.2.0': optional: true - '@cursor/sdk@1.0.38(zod@4.6.5)': + '@cursor/sdk@1.2.0(zod@4.6.5)': dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) @@ -13512,11 +13512,11 @@ snapshots: '@statsig/js-client': 3.31.0 zod: 4.6.5 optionalDependencies: - '@cursor/sdk-darwin-arm64': 1.0.38 - '@cursor/sdk-darwin-x64': 1.0.38 - '@cursor/sdk-linux-arm64': 1.0.38 - '@cursor/sdk-linux-x64': 1.0.38 - '@cursor/sdk-win32-x64': 1.0.38 + '@cursor/sdk-darwin-arm64': 1.2.0 + '@cursor/sdk-darwin-x64': 1.2.0 + '@cursor/sdk-linux-arm64': 1.2.0 + '@cursor/sdk-linux-x64': 1.2.0 + '@cursor/sdk-win32-x64': 1.2.0 '@date-fns/tz@1.5.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index be4d3c29ea763..e2865566986d9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -227,6 +227,12 @@ minimumReleaseAgeExclude: - "@pierre/highlights@0.2.0" - "@pierre/theming@1.0.2" - agent-device@0.21.32 + - "@cursor/sdk-darwin-arm64@1.2.0" + - "@cursor/sdk-darwin-x64@1.2.0" + - "@cursor/sdk-linux-arm64@1.2.0" + - "@cursor/sdk-linux-x64@1.2.0" + - "@cursor/sdk-win32-x64@1.2.0" + - "@cursor/sdk@1.2.0" overrides: # Pin every patched package, including transitive dependencies, so lockfile-free From 2edaf454aeccfef45579df98a96a4bb25ce1e34d Mon Sep 17 00:00:00 2001 From: Gabriel Rodrigues Date: Fri, 30 Oct 2026 21:30:01 -0300 Subject: [PATCH 2/3] feat(cursor): compact threads with native SDK summarize Replace the /compress rewrite path with agent.summarize, project summary lifecycle into compaction turn items, and clear instruction-delivery state after compact so the next turn reinjects prompts. Co-authored-by: Cursor --- .../Adapters/CursorAdapterV2.testkit.ts | 34 +- docs/user/cursor.md | 3 +- .../src/server/CursorAgentSdk.ts | 159 ++++++++++ .../src/server/adapter.test.ts | 110 ++++++- .../provider-cursor/src/server/adapter.ts | 296 +++++++++++++++++- .../provider-cursor/src/server/driver.test.ts | 15 + 6 files changed, 602 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 7310cc7c81ea0..8b42b8f5b87d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -288,7 +288,9 @@ export function makeCursorAgentSdkReplayRunner( const waitForRun = ( runId: string, - sendInput: CursorAgentSdk.CursorAgentSdkSendInput, + runInput: { + readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; + }, ): Effect.Effect => Effect.gen(function* () { while (true) { @@ -364,7 +366,7 @@ export function makeCursorAgentSdkReplayRunner( ); } advance(); - yield* (sendInput.onDelta?.(frame.update) ?? Effect.void).pipe( + yield* (runInput.onDelta?.(frame.update) ?? Effect.void).pipe( Effect.mapError((cause) => recordFailure( new CursorReplayRuntimeError({ @@ -446,6 +448,34 @@ export function makeCursorAgentSdkReplayRunner( }, catch: (cause) => replayRunnerError(transcript, cause, "replay.session.send"), }), + summarize: (summarizeInput) => + Effect.try({ + try: () => { + assertOutbound({ + type: "run.summarize", + }); + const started = consumeInbound("run.started"); + const run: CursorAgentSdk.CursorAgentSdkRun = { + runId: started.runId, + agentId: started.agentId, + wait: waitForRun(started.runId, summarizeInput ?? {}).pipe( + Effect.mapError((cause) => + replayRunnerError(transcript, cause, "replay.run.wait"), + ), + ), + cancel: Effect.try({ + try: () => + assertOutbound({ + type: "run.cancel", + runId: started.runId, + }), + catch: (cause) => replayRunnerError(transcript, cause, "replay.run.cancel"), + }), + }; + return run; + }, + catch: (cause) => replayRunnerError(transcript, cause, "replay.session.summarize"), + }), listMessages: Effect.try({ try: () => { assertOutbound({ diff --git a/docs/user/cursor.md b/docs/user/cursor.md index 4263ceb14fd8d..b93ae675e66dc 100644 --- a/docs/user/cursor.md +++ b/docs/user/cursor.md @@ -50,7 +50,8 @@ The adapter supports: - thread-scoped T3 Code MCP tools alongside ambient Cursor MCP/plugins from disk; - image attachments; - interruption, queued app messages, and orchestrator-owned interrupt/restart steering; -- provider conversation snapshots through `Agent.messages.list()`; +- native conversation compaction; +- provider conversation snapshots; - Cursor `task` subagents, projected as read-only child app threads with their tool activity and final result. diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 466cf20e0648b..33f553631c524 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -91,6 +91,10 @@ export interface CursorAgentSdkSendInput { readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; } +export interface CursorAgentSdkSummarizeInput { + readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; +} + export interface CursorAgentSdkRun { readonly runId: string; readonly agentId: string; @@ -103,6 +107,9 @@ export interface CursorAgentSdkSession { readonly send: ( input: CursorAgentSdkSendInput, ) => Effect.Effect; + readonly summarize: ( + input?: CursorAgentSdkSummarizeInput, + ) => Effect.Effect; readonly listMessages: Effect.Effect, CursorAgentSdkRunnerError>; readonly close: Effect.Effect; } @@ -176,6 +183,13 @@ export type CursorAgentSdkProtocolLogEvent = readonly options: CursorAgentSdkLoggedSendOptions; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "run.summarize"; + }; + } | { readonly direction: "incoming"; readonly stage: "decoded"; @@ -318,6 +332,25 @@ export function loggedCursorSendOptions( }; } +function summaryMessageToInteractionUpdate(message: { + readonly type: string; + readonly status?: string; + readonly fallback?: boolean; +}): InteractionUpdate | undefined { + if (message.type !== "summary") return undefined; + if (message.status === "running") { + return { type: "summary-started" }; + } + if (message.status === "completed" || message.status === "error") { + return { + type: "summary-completed", + ...(message.status === "error" ? { failed: true } : {}), + ...(message.fallback === true ? { fallback: true } : {}), + }; + } + return undefined; +} + function makeCursorAgentSdkProtocolLogger(input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; @@ -419,6 +452,106 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu }; let abandonedRunRecoveryAvailable = input.operation === "resume"; + const wrapRun = Effect.fnUntraced(function* (input: { + readonly run: Awaited>; + readonly method: string; + readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; + readonly pumpStream?: boolean; + }) { + const context = yield* Effect.context(); + let callbackFailure: { readonly cause: unknown } | undefined; + let callbackChain = Promise.resolve(); + const dispatchUpdate = (update: InteractionUpdate): Promise => { + callbackChain = callbackChain + .then(() => { + if (callbackFailure !== undefined) { + return; + } + return Effect.runPromiseWith(context)( + log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "interaction.update", + runId: input.run.id, + update, + }, + }).pipe(Effect.andThen(input.onDelta?.(update) ?? Effect.void)), + ); + }) + .catch((cause) => { + callbackFailure ??= { cause }; + }); + return callbackChain; + }; + + // Keep the stream pump off callbackChain — awaiting dispatchUpdate from inside a + // callbackChain task deadlocks when the next link waits for that same task to finish. + const pumpDone = + input.pumpStream === true + ? (async () => { + for await (const message of input.run.stream()) { + const update = summaryMessageToInteractionUpdate(message); + if (update !== undefined) { + await dispatchUpdate(update); + } + } + })().catch((cause) => { + callbackFailure ??= { cause }; + }) + : Promise.resolve(); + + return { + runId: input.run.id, + agentId: input.run.agentId, + wait: Effect.tryPromise({ + try: async () => { + const result = await input.run.wait(); + await Promise.all([callbackChain, pumpDone]); + if (callbackFailure !== undefined) { + throw callbackFailure.cause; + } + return result; + }, + catch: (cause) => runnerError(cause, `${input.method}.wait`), + }).pipe( + Effect.tap((result) => + log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "run.completed", + result, + }, + }), + ), + ), + cancel: log({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "run.cancel", + runId: input.run.id, + }, + }).pipe( + Effect.andThen( + Effect.tryPromise({ + try: async () => { + try { + await input.run.cancel(); + } catch (cause) { + if (!isCursorCancellationError(cause)) { + throw cause; + } + } + }, + catch: (cause) => runnerError(cause, `${input.method}.cancel`), + }), + ), + ), + } satisfies CursorAgentSdkRun; + }); + return { agentId: agent.agentId, send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) { @@ -575,6 +708,32 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu ), } satisfies CursorAgentSdkRun; }), + summarize: Effect.fn("CursorAgentSdkSession.summarize")(function* (summarizeInput) { + yield* log({ + direction: "outgoing", + stage: "decoded", + payload: { type: "run.summarize" }, + }); + const run = yield* Effect.tryPromise({ + try: () => agent.summarize(), + catch: (cause) => runnerError(cause, "run.summarize"), + }); + yield* log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "run.started", + runId: run.id, + agentId: run.agentId, + }, + }); + return yield* wrapRun({ + run, + method: "run.summarize", + ...(summarizeInput?.onDelta === undefined ? {} : { onDelta: summarizeInput.onDelta }), + pumpStream: true, + }); + }), listMessages: log({ direction: "outgoing", stage: "decoded", diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index b934fd2ca3493..b80a49f19f7e9 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -62,7 +62,7 @@ describe("CursorAdapterV2", () => { authorizationHeader: "Bearer synthetic-cursor-token", browserToolsAvailable: true, }); - let sentWithMcpOverride = true; + let sentWithMcpOverride = false; const modelSelection = { instanceId, model: "composer-2.5" }; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", @@ -80,6 +80,20 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-lifecycle", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.succeed({ + agentId: "native-cursor-lifecycle", + runId: "native-cursor-summarize", + wait: Effect.succeed({ + id: "native-cursor-summarize", + requestId: "native-summarize-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + result: "summary", + }), + cancel: Effect.void, + }), send: (input) => Effect.gen(function* () { sentWithMcpOverride = Object.hasOwn(input.options ?? {}, "mcpServers"); @@ -351,6 +365,20 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-failure", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.succeed({ + agentId: "native-cursor-failure", + runId: "native-cursor-summarize", + wait: Effect.succeed({ + id: "native-cursor-summarize", + requestId: "native-summarize-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + result: "summary", + }), + cancel: Effect.void, + }), send: (input) => Effect.gen(function* () { for (const update of updates) { @@ -446,12 +474,13 @@ describe("CursorAdapterV2", () => { ), ); - it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => + it.effect("fails standalone SDK transport diagnostics and compacts with summarize", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); const sentMessages: Array = []; let sendCalls = 0; + let summarizeCalls = 0; const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-transport-error-thread"); const modelSelection = { instanceId, model: "composer-2.5" }; @@ -471,6 +500,23 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-error", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.sync(() => { + summarizeCalls += 1; + return { + agentId: "native-cursor-error", + runId: `native-cursor-summarize-${summarizeCalls}`, + wait: Effect.succeed({ + id: `native-cursor-summarize-${summarizeCalls}`, + requestId: `native-summarize-request-${summarizeCalls}`, + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + result: "Compacted conversation summary.", + }), + cancel: Effect.void, + }; + }), send: (input) => Effect.sync(() => { sendCalls += 1; @@ -582,11 +628,20 @@ describe("CursorAdapterV2", () => { attachments: [], }, }); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, + const compactEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + assert.equal(summarizeCalls, 1); + assert.equal(sentMessages.length, 1); + assert.isTrue( + compactEvents.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ), ); - assert.equal(sentMessages[1], "/compress"); }).pipe( Effect.scoped, Effect.provide( @@ -631,6 +686,20 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-device", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.succeed({ + agentId: "native-cursor-device", + runId: "native-cursor-summarize", + wait: Effect.succeed({ + id: "native-cursor-summarize", + requestId: "native-summarize-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + result: "summary", + }), + cancel: Effect.void, + }), send: (input) => Effect.sync(() => { sentMessages.push( @@ -990,6 +1059,20 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-search", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.succeed({ + agentId: "native-cursor-search", + runId: "native-cursor-summarize", + wait: Effect.succeed({ + id: "native-cursor-summarize", + requestId: "native-summarize-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + result: "summary", + }), + cancel: Effect.void, + }), send: (input) => Effect.gen(function* () { for (const update of updates) { @@ -1258,6 +1341,20 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-task-ls", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.succeed({ + agentId: "native-cursor-task-ls", + runId: "native-cursor-summarize", + wait: Effect.succeed({ + id: "native-cursor-summarize", + requestId: "native-summarize-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + result: "summary", + }), + cancel: Effect.void, + }), send: (input) => Effect.gen(function* () { const args = { @@ -1442,6 +1539,7 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-custom-model", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => Effect.die("unexpected Cursor summarize"), send: (sendInput) => checkModel("run.start", sendInput.options?.model).pipe( Effect.andThen(Effect.die("unexpected Cursor run")), diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 8da8b0099dc11..e181e6e237ebc 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -923,6 +923,15 @@ interface ActiveCursorTextStream { nextSegment: number; } +interface ActiveCursorCompaction { + readonly nativeItemId: string; + readonly startedAt: DateTime.Utc; + summary?: string; + failed?: boolean; + fallback?: boolean; + status: "running" | "completed" | "failed"; +} + interface ActiveCursorTurn { readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; readonly run: CursorAgentSdk.CursorAgentSdkRun; @@ -935,6 +944,8 @@ interface ActiveCursorTurn { readonly assistant: ActiveCursorTextStream; assistantReply: CursorTransportFailure; readonly reasoning: ActiveCursorTextStream; + /** Set when this turn is a native summarize/compaction run. */ + compaction: ActiveCursorCompaction | null; interrupted: boolean; finalized: boolean; /** The agent's latest turn ended and no new work has started since. */ @@ -1984,6 +1995,97 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( return candidates.filter((value): value is string => typeof value === "string").join(""); }; + const emitCompactionItem = Effect.fnUntraced(function* (context: ActiveCursorTurn) { + const compaction = context.compaction; + if (compaction === null) return; + const now = yield* DateTime.now; + const ordinal = yield* resolveItemOrdinal(context, compaction.nativeItemId); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: compaction.nativeItemId, + }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: compaction.nativeItemId, + strength: "weak", + }, + parentItemId: null, + ordinal, + type: "compaction", + driver: CursorAgentSdk.CURSOR_PROVIDER, + status: compaction.status, + title: compaction.failed + ? "Context compaction failed" + : compaction.fallback + ? "Context trimmed" + : "Context compacted", + startedAt: compaction.startedAt, + completedAt: compaction.status === "running" ? null : now, + updatedAt: now, + ...(compaction.summary === undefined ? {} : { summary: compaction.summary }), + }, + }); + }); + + const handleSummaryUpdate = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + update: Extract< + InteractionUpdate, + { type: "summary-started" | "summary" | "summary-completed" } + >, + ) { + if (update.type === "summary-started") { + if (context.compaction === null) { + context.compaction = { + nativeItemId: `${context.providerTurnId}:compaction`, + startedAt: yield* DateTime.now, + status: "running", + }; + } else { + context.compaction.status = "running"; + } + yield* emitCompactionItem(context); + return; + } + if (update.type === "summary") { + if (context.compaction === null) { + context.compaction = { + nativeItemId: `${context.providerTurnId}:compaction`, + startedAt: yield* DateTime.now, + status: "running", + summary: update.summary, + }; + } else { + context.compaction.summary = update.summary; + } + yield* emitCompactionItem(context); + return; + } + if (context.compaction === null) { + context.compaction = { + nativeItemId: `${context.providerTurnId}:compaction`, + startedAt: yield* DateTime.now, + status: update.failed === true ? "failed" : "completed", + ...(update.failed === true ? { failed: true } : {}), + ...(update.fallback === true ? { fallback: true } : {}), + }; + } else { + context.compaction.failed = update.failed === true; + context.compaction.fallback = update.fallback === true; + context.compaction.status = update.failed === true ? "failed" : "completed"; + } + yield* emitCompactionItem(context); + }); + const handleInteractionUpdate = Effect.fnUntraced(function* ( context: ActiveCursorTurn, update: InteractionUpdate, @@ -2070,6 +2172,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); return; } + case "summary-started": + case "summary": + case "summary-completed": + yield* handleSummaryUpdate(context, update); + return; default: return; } @@ -2435,6 +2542,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( current: null, nextSegment: 0, }, + compaction: null, interrupted: false, finalized: false, turnEnded: false, @@ -2466,7 +2574,17 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( yield* sdkRun.wait.pipe( Effect.flatMap((result) => Effect.gen(function* () { - if ( + if (context !== null && context.compaction !== null) { + if (result.result !== undefined && result.result.length > 0) { + context.compaction.summary = result.result; + } + if (context.compaction.status === "running") { + context.compaction.status = + result.status === "finished" ? "completed" : "failed"; + context.compaction.failed = result.status !== "finished"; + yield* emitCompactionItem(context); + } + } else if ( context !== null && context.assistant.nextSegment === 0 && // Text from a resume that made no progress is not the turn's answer. @@ -2657,11 +2775,177 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ), ), startTurn, - compactThread: (turnInput) => - startTurn({ - ...turnInput, - message: { ...turnInput.message, text: "/compress" }, - }), + compactThread: Effect.fn("CursorAdapterV2.compactThread")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + const current = yield* Ref.get(activeTurn); + if (current !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: `Cursor provider turn ${current.providerTurnId} is still active.`, + reason: "previous_turn_active", + }); + } + const agentId = nativeThreadId(turnInput.providerThread); + const agent = yield* openAgent({ + operation: "resume", + agentId, + threadId: turnInput.threadId, + modelSelection: turnInput.modelSelection, + runtimePolicy: turnInput.runtimePolicy, + }); + yield* Ref.set(told, { + agent: agent.nativeThreadId, + orchestration: false, + device: false, + }); + const pendingUpdates: Array = []; + let context: ActiveCursorTurn | null = null; + const sdkRun = yield* agent.session + .summarize({ + onDelta: (update) => { + if (context === null) { + return Effect.sync(() => { + pendingUpdates.push(update); + }); + } + return handleInteractionUpdate(context, update); + }, + }) + .pipe(Effect.mapError(classifyCursorModelRejection(turnInput.modelSelection))); + const startedAt = yield* DateTime.now; + const completed = yield* Deferred.make(); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeTurnId: sdkRun.runId, + }); + context = { + input: turnInput, + run: sdkRun, + providerTurnId, + startedAt, + completed, + itemOrdinals: new Map(), + tools: new Map(), + subagents: new Map(), + assistant: { + current: null, + nextSegment: 0, + }, + assistantReply: new CursorTransportFailure(), + reasoning: { + current: null, + nextSegment: 0, + }, + compaction: { + nativeItemId: `${providerTurnId}:compaction`, + startedAt, + status: "running", + }, + interrupted: false, + finalized: false, + turnEnded: false, + }; + yield* Ref.set(activeTurn, context); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerTurn: providerTurnPayload({ + context, + status: "running", + completedAt: null, + }), + }); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThread: { + ...turnInput.providerThread, + providerSessionId: session.id, + status: "active", + updatedAt: startedAt, + }, + }); + yield* emitCompactionItem(context); + for (const update of pendingUpdates) { + yield* handleInteractionUpdate(context, update); + } + yield* sdkRun.wait.pipe( + Effect.flatMap((result) => + Effect.gen(function* () { + if (context === null) return; + if (result.result !== undefined && result.result.length > 0) { + context.compaction!.summary = result.result; + } + if (context.compaction!.status === "running") { + context.compaction!.status = + result.status === "finished" ? "completed" : "failed"; + context.compaction!.failed = result.status !== "finished"; + yield* emitCompactionItem(context); + } + yield* finalizeTurn({ + context, + status: terminalStatus(context, result), + ...(result.status === "error" + ? { + failure: makeProviderFailure({ + cause: result.error, + message: result.error?.message, + class: "provider_error", + }), + } + : {}), + }); + }), + ), + Effect.catch((cause) => + Effect.gen(function* () { + if (context !== null) { + if (context.compaction !== null && context.compaction.status === "running") { + context.compaction.status = "failed"; + context.compaction.failed = true; + yield* emitCompactionItem(context); + } + yield* finalizeTurn({ + context, + status: context.interrupted ? "interrupted" : "failed", + ...(context.interrupted + ? {} + : { + failure: makeProviderFailure({ + cause, + message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) + ? CursorAgentSdk.cursorSdkFailureMessage(cause) + : undefined, + class: "transport_error", + }), + }), + }); + } + yield* Effect.logWarning("orchestration-v2.cursor-summarize-failed", { + providerSessionId: input.providerSessionId, + providerThreadId: turnInput.providerThread.id, + providerTurnId, + cause, + }); + }), + ), + Effect.forkIn(sessionScope), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ), steerTurn: (turnInput) => Effect.fail( new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 5a3202fd20e4d..7ec192877b355 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -116,6 +116,7 @@ it.layer(layerTest)("CursorDriver", (it) => { return { agentId: "browser-auth-agent", listMessages: Effect.succeed([]), + summarize: () => Effect.die("This test only opens a session"), send: () => Effect.die("This test only opens a session"), close: Effect.sync(() => { closed += 1; @@ -212,6 +213,20 @@ it.layer(layerTest)("CursorDriver", (it) => { agentId: "native-cursor-window", listMessages: Effect.succeed([]), close: Effect.void, + summarize: () => + Effect.succeed({ + agentId: "native-cursor-window", + runId: "native-cursor-window-summarize", + cancel: Effect.void, + wait: Effect.succeed({ + id: "native-cursor-window-summarize", + requestId: "native-cursor-window-summarize", + status: "finished" as const, + model: { id: "claude-opus-5-5" }, + durationMs: 1, + result: "summary", + }), + }), send: (input) => Effect.sync(() => { sentModels.push(input.options?.model); From a7192c6b6cc549ac37650720a53419ca4d0a8b46 Mon Sep 17 00:00:00 2001 From: Gabriel Rodrigues Date: Sat, 31 Oct 2026 15:14:26 -0300 Subject: [PATCH 3/3] fix(cursor): keep summarize wait from stealing send answers Do not treat startTurn result.result as a compaction summary when mid-run summary updates set context.compaction. Emit late summarize wait results, classify usage limits on compact, and cover the stream pump in the SDK runner test. Co-authored-by: Cursor --- .../src/server/CursorAgentSdk.test.ts | 51 +++++++++++++++++++ .../provider-cursor/src/server/adapter.ts | 22 ++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts index d715766a309bc..795ff9e83fd95 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -49,17 +49,34 @@ const cursorSdkMock = vi.hoisted(() => { agentId: "agent-cursor-agent-sdk-test", wait: runWait, cancel: runCancel, + stream: async function* () {}, }; }, ); + const summarize = vi.fn(async () => { + runOperations.push("run.summarize"); + return { + id: "run-cursor-agent-sdk-summarize", + agentId: "agent-cursor-agent-sdk-test", + wait: runWait, + cancel: runCancel, + stream: async function* () { + yield { type: "summary", status: "running" }; + yield { type: "summary", status: "completed" }; + yield { type: "summary", status: "error" }; + }, + }; + }); const create = vi.fn(async () => ({ agentId: "agent-cursor-agent-sdk-test", send, + summarize, close: agentClose, })); const resume = vi.fn(async () => ({ agentId: "agent-cursor-agent-sdk-test", send, + summarize, close: agentClose, })); const listRuns = vi.fn(async () => { @@ -84,6 +101,7 @@ const cursorSdkMock = vi.hoisted(() => { runCancel, runWait, send, + summarize, setAgentCloseFailure: (failure: unknown) => { agentCloseFailure = failure; }, @@ -172,4 +190,37 @@ describe("CursorAgentSdkRunner", () => { ]); }).pipe(Effect.provide(layerTest)), ); + + it.effect("pumps summarize stream updates without deadlocking wait", () => + Effect.gen(function* () { + cursorSdkMock.runOperations.length = 0; + cursorSdkMock.summarize.mockClear(); + cursorSdkMock.setStaleRunActive(false); + + const updates: Array = []; + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "create", + options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, + threadId: ThreadId.make("thread-cursor-agent-sdk-summarize-test"), + providerSessionId: ProviderSessionId.make( + "provider-session-cursor-agent-sdk-summarize-test", + ), + }); + const run = yield* session.summarize({ + onDelta: (update) => + Effect.sync(() => { + updates.push(update); + }), + }); + yield* run.wait; + + assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.summarize"]); + assert.deepStrictEqual(updates, [ + { type: "summary-started" }, + { type: "summary-completed" }, + { type: "summary-completed", failed: true }, + ]); + }).pipe(Effect.provide(layerTest)), + ); }); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index e181e6e237ebc..9730b0df07d0b 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2574,17 +2574,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( yield* sdkRun.wait.pipe( Effect.flatMap((result) => Effect.gen(function* () { - if (context !== null && context.compaction !== null) { - if (result.result !== undefined && result.result.length > 0) { - context.compaction.summary = result.result; - } - if (context.compaction.status === "running") { - context.compaction.status = - result.status === "finished" ? "completed" : "failed"; - context.compaction.failed = result.status !== "finished"; - yield* emitCompactionItem(context); - } - } else if ( + if ( context !== null && context.assistant.nextSegment === 0 && // Text from a resume that made no progress is not the turn's answer. @@ -2881,6 +2871,8 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( result.status === "finished" ? "completed" : "failed"; context.compaction!.failed = result.status !== "finished"; yield* emitCompactionItem(context); + } else if (result.result !== undefined && result.result.length > 0) { + yield* emitCompactionItem(context); } yield* finalizeTurn({ context, @@ -2890,7 +2882,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( failure: makeProviderFailure({ cause: result.error, message: result.error?.message, - class: "provider_error", + class: CursorAgentSdk.isCursorUsageLimitError(result.error) + ? "usage_limit" + : "provider_error", }), } : {}), @@ -2916,7 +2910,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) ? CursorAgentSdk.cursorSdkFailureMessage(cause) : undefined, - class: "transport_error", + class: CursorAgentSdk.isCursorUsageLimitError(cause) + ? "usage_limit" + : "transport_error", }), }), });