Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ export function makeCursorAgentSdkReplayRunner(
},
catch: (cause) => replayRunnerError(transcript, cause, "replay.session.send"),
}),
rollback: () => Effect.die("Cursor replay rollback is not recorded"),
listMessages: Effect.try({
try: () => {
assertOutbound({
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/orchestration-v2/CommandPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) =
const layer = it.layer(CommandPolicy.layer);

layer("CommandPolicyV2", (it) => {
it.effect("allows Cursor conversation rollback", () =>
Effect.gen(function* () {
const policy = yield* CommandPolicy.CommandPolicyV2;
yield* policy.ensureRollback({
commandId,
threadId,
providerInstanceId: ProviderInstanceId.make("cursor"),
capabilities: CursorProviderCapabilitiesV2,
});
}),
);

it.effect("falls back to portable context when Cursor cannot fork natively", () =>
Effect.gen(function* () {
const policy = yield* CommandPolicy.CommandPolicyV2;
Expand Down
6 changes: 5 additions & 1 deletion docs/user/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,21 @@ 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;
- editing sent messages and reverting conversations through persisted SDK checkpoints;
- provider conversation snapshots through `Agent.messages.list()`;
- Cursor `task` subagents, projected as read-only child app threads with their tool activity and
final result.

The public SDK does not currently expose native agent fork, conversation rollback, active steering,
The public SDK does not currently expose native agent fork, active steering,
or interactive approval callbacks. Direct active steering is advertised as unsupported, while V2
steering uses the orchestrator's interrupt-and-restart path and preserves the app run identity across
provider turns. Same-provider Cursor forks use the orchestrator's portable full-thread context
handoff into a fresh Cursor agent. The SDK has in-process custom callback tools, but the V2 adapter
intentionally uses the authenticated, thread-scoped MCP server instead.

Editing a sent message rewinds the conversation to before that message. Rollback requires the
local Cursor checkpoint data to still be available on the environment running the thread.

Portable handoffs summarize eligible timeline items and can omit the end of long messages. Read
[Context in portable handoffs](./portable-handoffs.md) before using a fork for work whose exact
instructions must carry forward.
Expand Down
70 changes: 70 additions & 0 deletions packages/provider-cursor/src/server/CursorAgentSdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import { JsonlLocalAgentStore } from "@cursor/sdk";
import * as Layer from "effect/Layer";
import * as Exit from "effect/Exit";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -136,6 +138,74 @@ const layerTest = CursorAgentSdk.layer.pipe(
);

describe("CursorAgentSdkRunner", () => {
it.effect("restores persisted run boundaries and rejects unavailable or active targets", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "cursor-rollback-" });
const store = new JsonlLocalAgentStore(root);
const agentId = "agent-cursor-agent-sdk-test";
const checkpoint = { schemaVersion: 1 as const, rootBlobId: "retained" };
const document = {
agentId,
cwd: root,
status: "idle" as const,
activeRunId: null,
createdAt: 0,
updatedAt: 0,
latestCheckpoint: { ...checkpoint, rootBlobId: "discarded" },
sdkMetadata: { preserved: true },
};
yield* Effect.promise(async () => {
await store.agents.create({ agent: document });
await store.checkpoints.create({
agentId,
blobId: checkpoint.rootBlobId,
data: new Uint8Array([1]),
});
await store.runs.create({
run: {
agentId,
runId: "retained-run",
turnNumber: 1,
status: "finished",
createdAt: 0,
updatedAt: 0,
latestCheckpointRef: checkpoint,
},
});
});
cursorSdkMock.setAgentCloseFailure(undefined);
const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
const session = yield* runner.open({
operation: "resume",
agentId,
threadId: ThreadId.make("rollback"),
providerSessionId: ProviderSessionId.make("rollback"),
options: { local: { cwd: root, store } },
});
yield* session.rollback("retained-run");
const reopened = new JsonlLocalAgentStore(root);
const restored = yield* Effect.promise(() => reopened.agents.get({ agentId }));
assert.deepStrictEqual(restored?.latestCheckpoint, checkpoint);
assert.deepStrictEqual(restored?.sdkMetadata, document.sdkMetadata);
const missing = yield* session.rollback("missing").pipe(Effect.exit);
assert(Exit.isFailure(missing));
yield* Effect.promise(() =>
store.agents.update({ agent: { ...document, status: "running" } }),
);
assert(Exit.isFailure(yield* session.rollback(null).pipe(Effect.exit)));
yield* Effect.promise(() => store.agents.update({ agent: document }));
yield* Effect.promise(() =>
store.checkpoints.delete({
filter: { agentIds: [agentId], blobIds: [checkpoint.rootBlobId] },
}),
);
assert(Exit.isFailure(yield* session.rollback("retained-run").pipe(Effect.exit)));
yield* session.rollback(null);
assert.isNull((yield* Effect.promise(() => store.agents.get({ agentId })))?.latestCheckpoint);
}).pipe(Effect.provide(Layer.merge(layerTest, NodeServices.layer))),
);

it.effect("constructs without loading and reports first-use load failures as runner errors", () =>
Effect.gen(function* () {
const failure = new Error("missing SDK chunk");
Expand Down
51 changes: 46 additions & 5 deletions packages/provider-cursor/src/server/CursorAgentSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export interface CursorAgentSdkSession {
readonly send: <Error>(
input: CursorAgentSdkSendInput<Error>,
) => Effect.Effect<CursorAgentSdkRun, CursorAgentSdkRunnerError>;
readonly rollback: (runId: string | null) => Effect.Effect<void, CursorAgentSdkRunnerError>;
readonly listMessages: Effect.Effect<ReadonlyArray<AgentMessage>, CursorAgentSdkRunnerError>;
readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>;
}
Expand Down Expand Up @@ -588,6 +589,50 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu
),
} satisfies CursorAgentSdkRun;
}),
rollback: Effect.fn("CursorAgentSdkSession.rollback")(function* (runId) {
yield* Effect.tryPromise({
try: async () => {
const ownedStore =
input.options.local?.store === undefined
? await sdk
.loadSqlite()
.SqliteLocalAgentStore.open({ workspaceRef: cwd ?? process.cwd() })
: undefined;
const store = input.options.local?.store ?? ownedStore!;
try {
const document = await store.agents.get({ agentId: agent.agentId });
if (document === null) throw new Error("Cursor agent is unavailable for rollback.");
if (document.status === "running" || document.activeRunId != null)
throw new Error("Cannot roll back an active Cursor agent.");
const run =
runId === null ? null : await store.runs.get({ agentId: agent.agentId, runId });
if (
runId !== null &&
(run === null ||
run.agentId !== agent.agentId ||
run.status === "running" ||
run.status === "queued" ||
run.latestCheckpointRef == null)
)
throw new Error("Cursor turn checkpoint is unavailable for rollback.");
const checkpoint = run?.latestCheckpointRef ?? null;
if (
checkpoint !== null &&
(await store.checkpoints.get({
agentId: agent.agentId,
blobId: checkpoint.rootBlobId,
})) === null
)
throw new Error("Cursor checkpoint blob is unavailable for rollback.");
agent.close();
await store.agents.update({ agent: { ...document, latestCheckpoint: checkpoint } });
} finally {
await ownedStore?.dispose();
}
},
catch: (cause) => runnerError(cause, "agent.rollback"),
});
}),
listMessages: log({
direction: "outgoing",
stage: "decoded",
Expand All @@ -598,11 +643,7 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu
}).pipe(
Effect.andThen(
Effect.tryPromise({
try: () =>
Agent.messages.list(agent.agentId, {
runtime: "local",
...(cwd === undefined ? {} : { cwd }),
}),
try: () => Agent.messages.list(agent.agentId, runOptions),
catch: (cause) => runnerError(cause, "agent.messages.list"),
}),
),
Expand Down
Loading
Loading