From 66c2476d37bf05e1ca3cf601349cbf494d1f1408 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sat, 5 Sep 2026 11:38:35 +0530 Subject: [PATCH] fix(providers): reject unsupported rewind before restoring files --- .../src/provider/Layers/AmpAdapter.test.ts | 1 + apps/server/src/provider/Layers/AmpAdapter.ts | 2 +- .../provider/Layers/CopilotAdapter.test.ts | 6 +++++ .../src/provider/Layers/CopilotAdapter.ts | 2 +- .../src/provider/Layers/CursorAdapter.test.ts | 26 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 14 +++++----- .../src/provider/Layers/DroidAdapter.test.ts | 1 + .../src/provider/Layers/DroidAdapter.ts | 2 +- .../provider/Layers/GeminiCliAdapter.test.ts | 1 + .../src/provider/Layers/GeminiCliAdapter.ts | 2 +- .../src/provider/Layers/StandardAcpAdapter.ts | 2 +- .../Layers/StandardAcpProviders.test.ts | 1 + 12 files changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/AmpAdapter.test.ts b/apps/server/src/provider/Layers/AmpAdapter.test.ts index 74b9e59b96e0..70971ee7e9ee 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.test.ts @@ -123,6 +123,7 @@ it.effect("AmpAdapter delegates session startup to the manager", () => Effect.gen(function* () { const manager = new FakeAmpManager(); const adapter = yield* makeAmpAdapter(enabledAmpSettings, { manager }); + NodeAssert.equal(adapter.capabilities.supportsConversationRollback, false); const session = yield* adapter.startSession({ threadId: asThreadId("thread-1"), diff --git a/apps/server/src/provider/Layers/AmpAdapter.ts b/apps/server/src/provider/Layers/AmpAdapter.ts index bc1da3a6a192..1422e817ab33 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.ts @@ -68,7 +68,7 @@ export const makeAmpAdapter = Effect.fn("makeAmpAdapter")(function* ( const service: AmpAdapterShape = { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, startSession: (input) => Effect.gen(function* () { if (!ampSettings.enabled) { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 056a81d735ec..1ec73f700479 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -138,6 +138,12 @@ const modeLayer = it.layer( ); modeLayer("CopilotAdapterLive interaction mode", (it) => { + it.effect("declares conversation rollback unsupported", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + NodeAssert.equal(adapter.capabilities.supportsConversationRollback, false); + }), + ); // Skip: @github/copilot-sdk has broken ESM resolution (vscode-jsonrpc/node) in CI it.effect.skip("switches the Copilot session mode when interactionMode changes", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 3d921bed7002..1b9568207458 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1743,7 +1743,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index d2ff2c9b64f8..32ccd0ab8d38 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -196,6 +196,32 @@ async function withAdapter( } describe("CursorAdapter SDK", () => { + it("rejects rollback without changing the retained conversation", async () => { + const run = new FakeRun("run-rollback", "agent-rollback", [ + makeSdkMessage({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "done" }] }, + }), + ]); + const fakeClient = new FakeCursorSdkClient(new FakeAgent("agent-rollback", run)); + await withAdapter(fakeClient, (adapter) => + Effect.gen(function* () { + expect(adapter.capabilities.supportsConversationRollback).toBe(false); + const threadId = asThreadId("thread-rollback"); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const events = yield* collectThroughTurnCompleted(adapter); + yield* adapter.sendTurn({ threadId, input: "hello", attachments: [] }); + yield* Fiber.join(events); + const before = yield* adapter.readThread(threadId); + expect(before.turns).toHaveLength(1); + const rollback = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.exit); + expect(rollback._tag).toBe("Failure"); + expect(yield* adapter.readThread(threadId)).toEqual(before); + expect((yield* adapter.readThread(threadId)).turns).toHaveLength(1); + }), + ); + }); + it("creates local SDK agents, applies model params, and emits canonical runtime events", async () => { const run = new FakeRun("run-1", "agent-1", [ makeSdkMessage({ type: "status", status: "RUNNING" }), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 7223a6922cc1..4baf31663138 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1268,7 +1268,7 @@ export function makeCursorAdapter( const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => Effect.gen(function* () { - const context = yield* requireSession(threadId); + yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -1276,11 +1276,11 @@ export function makeCursorAdapter( issue: "numTurns must be an integer >= 1.", }); } - context.turns.splice(Math.max(0, context.turns.length - numTurns)); - return { - threadId, - turns: context.turns, - }; + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Cursor SDK does not support provider-side conversation rollback.", + }); }); const stopSession: CursorAdapterShape["stopSession"] = (threadId) => @@ -1317,7 +1317,7 @@ export function makeCursorAdapter( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index e8716a4f823b..63614468e7ee 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -1124,6 +1124,7 @@ it.effect("reads Droid thread snapshots and rejects unsupported rollback", () => const before = yield* adapter.readThread(threadId); NodeAssert.equal(before.turns.length, 2); + NodeAssert.equal(adapter.capabilities.supportsConversationRollback, false); const rollback = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.exit); NodeAssert.equal(rollback._tag, "Failure"); if (rollback._tag === "Failure") { diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index 754c04cceaab..1dbe5c73f899 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -429,7 +429,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter return { provider: DROID_PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, startSession, sendTurn, interruptTurn: (threadId) => diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts index e492e3f2070c..d0725b8f42cf 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts @@ -125,6 +125,7 @@ it.effect("delegates session startup to the manager", () => Effect.gen(function* () { const manager = new FakeGeminiCliManager(); const adapter = yield* makeGeminiCliAdapter(enabledConfig, { manager }); + NodeAssert.equal(adapter.capabilities.supportsConversationRollback, false); const session = yield* adapter.startSession({ threadId: asThreadId("thread-1"), diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.ts index 43603d07cdcc..b5234de6ddfb 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.ts @@ -96,7 +96,7 @@ export const makeGeminiCliAdapter = Effect.fn("makeGeminiCliAdapter")(function* const adapter: GeminiCliAdapterShape = { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, startSession: (input) => Effect.gen(function* () { if (!config.enabled) { diff --git a/apps/server/src/provider/Layers/StandardAcpAdapter.ts b/apps/server/src/provider/Layers/StandardAcpAdapter.ts index cd3d368ee0c7..d5f1d4d81710 100644 --- a/apps/server/src/provider/Layers/StandardAcpAdapter.ts +++ b/apps/server/src/provider/Layers/StandardAcpAdapter.ts @@ -2153,7 +2153,7 @@ export function makeStandardAcpAdapter { : provider === "ohMyPi" ? decodeOhMyPiSettings({ binaryPath }).pipe(Effect.flatMap(makeOhMyPiAdapter)) : decodePiSettings({ binaryPath }).pipe(Effect.flatMap(makePiAdapter)); + assert.strictEqual(adapter.capabilities.supportsConversationRollback, false); const threadId = ThreadId.make(`${provider}-mock-thread`); const providerKind = ProviderDriverKind.make(provider); const events: ProviderRuntimeEvent[] = [];