From 448bac486c150ac0ad97ce7a335d78da939c0262 Mon Sep 17 00:00:00 2001 From: David Vanderklay Date: Sun, 5 Jul 2026 22:32:16 -0500 Subject: [PATCH 1/3] fix(opencode): persist and reuse session cursor --- .../provider/Layers/OpenCodeAdapter.test.ts | 209 ++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 74 +++++-- 2 files changed, 268 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d0475e25284..eb9ed63fb79 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -54,6 +54,11 @@ const runtimeMock = { state: { startCalls: [] as string[], sessionCreateUrls: [] as string[], + sessionCreateDelay: null as Promise | null, + sessionGetCalls: [] as string[], + sessionGetError: null as Error | null, + sessionGetDataById: new Map(), + sessionGetDelay: null as Promise | null, authHeaders: [] as Array, abortCalls: [] as string[], closeCalls: [] as string[], @@ -67,6 +72,11 @@ const runtimeMock = { reset() { this.state.startCalls.length = 0; this.state.sessionCreateUrls.length = 0; + this.state.sessionCreateDelay = null; + this.state.sessionGetCalls.length = 0; + this.state.sessionGetError = null; + this.state.sessionGetDataById = new Map(); + this.state.sessionGetDelay = null; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; @@ -124,11 +134,26 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { session: { create: async () => { runtimeMock.state.sessionCreateUrls.push(baseUrl); + if (runtimeMock.state.sessionCreateDelay) { + await runtimeMock.state.sessionCreateDelay; + } runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); return { data: { id: `${baseUrl}/session` } }; }, + get: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionGetCalls.push(sessionID); + if (runtimeMock.state.sessionGetDelay) { + await runtimeMock.state.sessionGetDelay; + } + if (runtimeMock.state.sessionGetError) { + throw runtimeMock.state.sessionGetError; + } + return { + data: runtimeMock.state.sessionGetDataById.get(sessionID) ?? { id: sessionID }, + }; + }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, @@ -227,6 +252,11 @@ beforeEach(() => { const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); +const waitUntil = (predicate: () => boolean): Effect.Effect => + Effect.suspend(() => + predicate() ? Effect.void : Effect.sleep("1 millis").pipe(Effect.andThen(waitUntil(predicate))), + ); + it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { @@ -248,6 +278,185 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("returns a resume cursor for a fresh OpenCode session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-cursor"), + runtimeMode: "full-access", + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + yield* adapter.stopSession(asThreadId("thread-opencode-cursor")); + }), + ); + + it.effect("resumes an existing OpenCode session from a persisted cursor", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + runtimeMock.state.sessionGetDataById.set("ses_existing", { id: "ses_existing" }); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-resume"), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetCalls, ["ses_existing"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_existing", + }); + yield* adapter.stopSession(asThreadId("thread-opencode-resume")); + }), + ); + + it.effect("sends follow-up prompts to the resumed OpenCode session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume-follow-up"); + runtimeMock.state.sessionGetDataById.set("ses_existing", { id: "ses_existing" }); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "Continue", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "ses_existing", + model: { providerID: "openai", modelID: "gpt-5" }, + parts: [{ type: "text", text: "Continue" }], + }); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("fails a valid resume cursor instead of creating an empty replacement session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + runtimeMock.state.sessionGetError = new Error("resume failed"); + + const error = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-resume-failure"), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_missing" }, + }) + .pipe(Effect.flip); + + NodeAssert.equal(error._tag, "ProviderAdapterProcessError"); + NodeAssert.deepEqual(runtimeMock.state.sessionGetCalls, ["ses_missing"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + }), + ); + + it.effect("treats malformed OpenCode resume cursors as absent", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-malformed-cursor"), + runtimeMode: "full-access", + resumeCursor: { sessionId: "ses_existing" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetCalls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + yield* adapter.stopSession(asThreadId("thread-opencode-malformed-cursor")); + }), + ); + + it.effect( + "does not abort an adopted OpenCode session when a concurrent start wins the race", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume-race"); + let releaseResume!: () => void; + runtimeMock.state.sessionGetDelay = new Promise((resolve) => { + releaseResume = resolve; + }); + + const resumedFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* waitUntil(() => runtimeMock.state.sessionGetCalls.length === 1); + + const raceWinner = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + releaseResume(); + const resumedResult = yield* Fiber.join(resumedFiber); + + NodeAssert.equal(resumedResult, raceWinner); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, []); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("aborts a newly-created OpenCode session when a concurrent start wins the race", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-create-race"); + let releaseCreate!: () => void; + runtimeMock.state.sessionCreateDelay = new Promise((resolve) => { + releaseCreate = resolve; + }); + + const losingFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* waitUntil(() => runtimeMock.state.sessionCreateUrls.length === 1); + + runtimeMock.state.sessionCreateDelay = null; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + releaseCreate(); + yield* Fiber.join(losingFiber); + + NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1eb6e47bc19..2b18a595442 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -52,6 +52,12 @@ import { import * as Option from "effect/Option"; const PROVIDER = ProviderDriverKind.make("opencode"); +const OPENCODE_RESUME_CURSOR_SCHEMA_VERSION = 1; + +interface OpenCodeResumeCursor { + readonly schemaVersion: typeof OPENCODE_RESUME_CURSOR_SCHEMA_VERSION; + readonly sessionId: string; +} interface OpenCodeTurnSnapshot { readonly id: TurnId; @@ -107,6 +113,30 @@ export interface OpenCodeAdapterLiveOptions { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function parseOpenCodeResumeCursor(input: unknown): OpenCodeResumeCursor | undefined { + if (!input || typeof input !== "object") { + return undefined; + } + + const cursor = input as { + readonly schemaVersion?: unknown; + readonly sessionId?: unknown; + }; + + if ( + cursor.schemaVersion !== OPENCODE_RESUME_CURSOR_SCHEMA_VERSION || + typeof cursor.sessionId !== "string" || + cursor.sessionId.trim().length === 0 + ) { + return undefined; + } + + return { + schemaVersion: OPENCODE_RESUME_CURSOR_SCHEMA_VERSION, + sessionId: cursor.sessionId, + }; +} + /** * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call @@ -1070,16 +1100,23 @@ export function makeOpenCodeAdapter( }), ); } - const openCodeSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); + const resumeCursor = parseOpenCodeResumeCursor(input.resumeCursor); + const openCodeSession = resumeCursor + ? yield* runOpenCodeSdk("session.get", () => + client.session.get({ sessionID: resumeCursor.sessionId }), + ) + : yield* runOpenCodeSdk("session.create", () => + client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); if (!openCodeSession.data) { return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", + operation: resumeCursor ? "session.get" : "session.create", + detail: resumeCursor + ? "OpenCode session.get returned no session payload." + : "OpenCode session.create returned no session payload.", }); } return { @@ -1087,6 +1124,7 @@ export function makeOpenCodeAdapter( server, client, openCodeSession: openCodeSession.data, + createdRemoteSession: resumeCursor === undefined, }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); @@ -1101,13 +1139,15 @@ export function makeOpenCodeAdapter( // and already inserted a session while we were awaiting async work. const raceWinner = sessions.get(input.threadId); if (raceWinner) { - // Another call won the race – clean up the session we just created - // (including the remote SDK session) and return the existing one. - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, - }), - ).pipe(Effect.ignore); + // Another call won the race; clean up local resources and abort only + // remote sessions created by this losing start attempt. + if (started.createdRemoteSession) { + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSession.id, + }), + ).pipe(Effect.ignore); + } yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -1121,6 +1161,10 @@ export function makeOpenCodeAdapter( cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), threadId: input.threadId, + resumeCursor: { + schemaVersion: OPENCODE_RESUME_CURSOR_SCHEMA_VERSION, + sessionId: started.openCodeSession.id, + }, createdAt, updatedAt: createdAt, }; From 902ba627b9c7deb4f6a8602e1f13506fb18b80c1 Mon Sep 17 00:00:00 2001 From: David Vanderklay Date: Sun, 5 Jul 2026 22:44:12 -0500 Subject: [PATCH 2/3] fix(opencode): preserve session before resume --- .../provider/Layers/OpenCodeAdapter.test.ts | 33 +++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 41 ++++++++++++------- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eb9ed63fb79..9a3a2551429 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -319,6 +319,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("preserves an existing upstream session when restarting with its resume cursor", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-restart-with-cursor"); + + const first = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const cursor = first.resumeCursor; + NodeAssert.deepEqual(cursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + const second = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: cursor, + }); + + NodeAssert.deepEqual(runtimeMock.state.abortCalls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionGetCalls, ["http://127.0.0.1:9999/session"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(second.resumeCursor, cursor); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("sends follow-up prompts to the resumed OpenCode session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 2b18a595442..f7ca856dc55 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -432,20 +432,23 @@ function updateProviderSession( }); } -const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( +const closeOpenCodeContext = Effect.fn("closeOpenCodeContext")(function* ( context: OpenCodeSessionContext, + options?: { readonly abortRemote?: boolean }, ) { // Race-safe one-shot: first caller flips the flag, everyone else no-ops. if (yield* Ref.getAndSet(context.stopped, true)) { return false; } - // Best-effort remote abort. The scope close below tears down the local - // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), - // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + if (options?.abortRemote ?? true) { + // Best-effort remote abort. The scope close below tears down the local + // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), + // but we still want to tell OpenCode that this session is done. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); + } // Closing the session scope interrupts every fiber forked into it and // runs each finalizer we registered — the `AbortController.abort()` call, @@ -454,6 +457,12 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( return true; }); +const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( + context: OpenCodeSessionContext, +) { + return yield* closeOpenCodeContext(context, { abortRemote: true }); +}); + export function makeOpenCodeAdapter( openCodeSettings: OpenCodeSettings, options?: OpenCodeAdapterLiveOptions, @@ -1061,9 +1070,14 @@ export function makeOpenCodeAdapter( const serverUrl = openCodeSettings.serverUrl; const serverPassword = openCodeSettings.serverPassword; const directory = input.cwd ?? serverConfig.cwd; + const incomingResumeCursor = parseOpenCodeResumeCursor(input.resumeCursor); const existing = sessions.get(input.threadId); if (existing) { - yield* stopOpenCodeContext(existing); + const shouldPreserveRemoteSession = + incomingResumeCursor?.sessionId === existing.openCodeSessionId; + yield* closeOpenCodeContext(existing, { + abortRemote: !shouldPreserveRemoteSession, + }); sessions.delete(input.threadId); } @@ -1100,10 +1114,9 @@ export function makeOpenCodeAdapter( }), ); } - const resumeCursor = parseOpenCodeResumeCursor(input.resumeCursor); - const openCodeSession = resumeCursor + const openCodeSession = incomingResumeCursor ? yield* runOpenCodeSdk("session.get", () => - client.session.get({ sessionID: resumeCursor.sessionId }), + client.session.get({ sessionID: incomingResumeCursor.sessionId }), ) : yield* runOpenCodeSdk("session.create", () => client.session.create({ @@ -1113,8 +1126,8 @@ export function makeOpenCodeAdapter( ); if (!openCodeSession.data) { return yield* new OpenCodeRuntimeError({ - operation: resumeCursor ? "session.get" : "session.create", - detail: resumeCursor + operation: incomingResumeCursor ? "session.get" : "session.create", + detail: incomingResumeCursor ? "OpenCode session.get returned no session payload." : "OpenCode session.create returned no session payload.", }); @@ -1124,7 +1137,7 @@ export function makeOpenCodeAdapter( server, client, openCodeSession: openCodeSession.data, - createdRemoteSession: resumeCursor === undefined, + createdRemoteSession: incomingResumeCursor === undefined, }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); From 2df4ec5c4215737a7bd61da84ebe28723df7a2e0 Mon Sep 17 00:00:00 2001 From: David Vanderklay Date: Sun, 5 Jul 2026 23:37:04 -0500 Subject: [PATCH 3/3] fix(opencode): preserve resumed session memory --- .../provider/Layers/OpenCodeAdapter.test.ts | 54 ++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 61 +++++++++++++++---- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9a3a2551429..6c96d085ae7 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -15,6 +15,7 @@ import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings, + ApprovalRequestId, ProviderDriverKind, ProviderInstanceId, ThreadId, @@ -63,6 +64,7 @@ const runtimeMock = { abortCalls: [] as string[], closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, promptCalls: [] as Array, promptAsyncError: null as Error | null, closeError: null as Error | null, @@ -81,6 +83,7 @@ const runtimeMock = { this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; + this.state.permissionReplyCalls.length = 0; this.state.promptCalls.length = 0; this.state.promptAsyncError = null; this.state.closeError = null; @@ -183,6 +186,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.messages; }, }, + permission: { + reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { + runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + }, + }, event: { subscribe: async () => ({ stream: (async function* () { @@ -352,6 +360,52 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("preserves pending permissions when restarting with the same resume cursor", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-restart-with-permission"); + const sessionID = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [ + { + type: "permission.asked", + properties: { + id: "perm-restart", + sessionID, + permission: "bash", + patterns: ["npm test"], + metadata: {}, + always: [], + }, + }, + ]; + + const first = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* advanceTestClock(10); + + runtimeMock.state.subscribedEvents = []; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: first.resumeCursor, + }); + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("perm-restart"), "accept"); + + NodeAssert.deepEqual(runtimeMock.state.abortCalls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionGetCalls, [sessionID]); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "perm-restart", reply: "once" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("sends follow-up prompts to the resumed OpenCode session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index f7ca856dc55..50a3091c268 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -104,6 +104,20 @@ interface OpenCodeSessionContext { readonly sessionScope: Scope.Closeable; } +interface OpenCodeSessionMemory { + readonly session: ProviderSession; + readonly pendingPermissions: OpenCodeSessionContext["pendingPermissions"]; + readonly pendingQuestions: OpenCodeSessionContext["pendingQuestions"]; + readonly messageRoleById: OpenCodeSessionContext["messageRoleById"]; + readonly partById: OpenCodeSessionContext["partById"]; + readonly emittedTextByPartId: OpenCodeSessionContext["emittedTextByPartId"]; + readonly completedAssistantPartIds: OpenCodeSessionContext["completedAssistantPartIds"]; + readonly turns: OpenCodeSessionContext["turns"]; + readonly activeTurnId: OpenCodeSessionContext["activeTurnId"]; + readonly activeAgent: OpenCodeSessionContext["activeAgent"]; + readonly activeVariant: OpenCodeSessionContext["activeVariant"]; +} + export interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -137,6 +151,22 @@ function parseOpenCodeResumeCursor(input: unknown): OpenCodeResumeCursor | undef }; } +function captureOpenCodeSessionMemory(context: OpenCodeSessionContext): OpenCodeSessionMemory { + return { + session: context.session, + pendingPermissions: context.pendingPermissions, + pendingQuestions: context.pendingQuestions, + messageRoleById: context.messageRoleById, + partById: context.partById, + emittedTextByPartId: context.emittedTextByPartId, + completedAssistantPartIds: context.completedAssistantPartIds, + turns: context.turns, + activeTurnId: context.activeTurnId, + activeAgent: context.activeAgent, + activeVariant: context.activeVariant, + }; +} + /** * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call @@ -1071,10 +1101,14 @@ export function makeOpenCodeAdapter( const serverPassword = openCodeSettings.serverPassword; const directory = input.cwd ?? serverConfig.cwd; const incomingResumeCursor = parseOpenCodeResumeCursor(input.resumeCursor); + let preservedSessionMemory: OpenCodeSessionMemory | undefined; const existing = sessions.get(input.threadId); if (existing) { const shouldPreserveRemoteSession = incomingResumeCursor?.sessionId === existing.openCodeSessionId; + preservedSessionMemory = shouldPreserveRemoteSession + ? captureOpenCodeSessionMemory(existing) + : undefined; yield* closeOpenCodeContext(existing, { abortRemote: !shouldPreserveRemoteSession, }); @@ -1166,14 +1200,19 @@ export function makeOpenCodeAdapter( } const createdAt = yield* nowIso; + const preservedSession = preservedSessionMemory?.session; const session: ProviderSession = { provider: PROVIDER, providerInstanceId: boundInstanceId, - status: "ready", + status: preservedSession?.status ?? "ready", runtimeMode: input.runtimeMode, cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), threadId: input.threadId, + ...(preservedSession?.activeTurnId + ? { activeTurnId: preservedSession.activeTurnId } + : {}), + ...(preservedSession?.lastError ? { lastError: preservedSession.lastError } : {}), resumeCursor: { schemaVersion: OPENCODE_RESUME_CURSOR_SCHEMA_VERSION, sessionId: started.openCodeSession.id, @@ -1188,16 +1227,16 @@ export function makeOpenCodeAdapter( server: started.server, directory, openCodeSessionId: started.openCodeSession.id, - pendingPermissions: new Map(), - pendingQuestions: new Map(), - partById: new Map(), - emittedTextByPartId: new Map(), - messageRoleById: new Map(), - completedAssistantPartIds: new Set(), - turns: [], - activeTurnId: undefined, - activeAgent: undefined, - activeVariant: undefined, + pendingPermissions: preservedSessionMemory?.pendingPermissions ?? new Map(), + pendingQuestions: preservedSessionMemory?.pendingQuestions ?? new Map(), + partById: preservedSessionMemory?.partById ?? new Map(), + emittedTextByPartId: preservedSessionMemory?.emittedTextByPartId ?? new Map(), + messageRoleById: preservedSessionMemory?.messageRoleById ?? new Map(), + completedAssistantPartIds: preservedSessionMemory?.completedAssistantPartIds ?? new Set(), + turns: preservedSessionMemory?.turns ?? [], + activeTurnId: preservedSessionMemory?.activeTurnId, + activeAgent: preservedSessionMemory?.activeAgent, + activeVariant: preservedSessionMemory?.activeVariant, stopped: yield* Ref.make(false), sessionScope: started.sessionScope, };