Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
209 changes: 209 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ const runtimeMock = {
state: {
startCalls: [] as string[],
sessionCreateUrls: [] as string[],
sessionCreateDelay: null as Promise<void> | null,
sessionGetCalls: [] as string[],
sessionGetError: null as Error | null,
sessionGetDataById: new Map<string, { id: string }>(),
sessionGetDelay: null as Promise<void> | null,
authHeaders: [] as Array<string | null>,
abortCalls: [] as string[],
closeCalls: [] as string[],
Expand All @@ -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;
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -227,6 +252,11 @@ beforeEach(() => {
const advanceTestClock = (ms: number) =>
TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow));

const waitUntil = (predicate: () => boolean): Effect.Effect<void> =>
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* () {
Expand All @@ -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;
Expand Down
74 changes: 59 additions & 15 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1070,23 +1100,31 @@ 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium Layers/OpenCodeAdapter.ts:1103

When a session is stopped (via stopSession or the layer finalizer), stopOpenCodeContext calls session.abort, which destroys the upstream OpenCode session. But the persisted resumeCursor pointing to that now-dead session id is never cleared. The next startSession with that resumeCursor calls session.get on the aborted upstream session, which fails with a ProviderAdapterProcessError instead of restoring the conversation. Consider clearing or invalidating the persisted resumeCursor when stopOpenCodeContext destroys the upstream session, so the resume path does not try to recover from a session that no longer exists.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/OpenCodeAdapter.ts around line 1103:

When a session is stopped (via `stopSession` or the layer finalizer), `stopOpenCodeContext` calls `session.abort`, which destroys the upstream OpenCode session. But the persisted `resumeCursor` pointing to that now-dead session id is never cleared. The next `startSession` with that `resumeCursor` calls `session.get` on the aborted upstream session, which fails with a `ProviderAdapterProcessError` instead of restoring the conversation. Consider clearing or invalidating the persisted `resumeCursor` when `stopOpenCodeContext` destroys the upstream session, so the resume path does not try to recover from a session that no longer exists.

const openCodeSession = resumeCursor
? yield* runOpenCodeSdk("session.get", () =>
client.session.get({ sessionID: resumeCursor.sessionId }),
)
Comment thread
cursor[bot] marked this conversation as resolved.
: 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 {
sessionScope,
server,
client,
openCodeSession: openCodeSession.data,
createdRemoteSession: resumeCursor === undefined,
};
}).pipe(Effect.provideService(Scope.Scope, sessionScope)),
);
Expand All @@ -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;
}
Expand All @@ -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,
};
Expand Down
Loading