authorgravatar for vdmkotai@gmail.comVadym Kotai <vdmkotai@gmail.com> 2026-07-20 14:20:28+04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 10:20:28+00:00
logf4da4f3b4037260bbb0d8914acbebafd2206607a
tree569b08410768edd6f152e6dda23f416bb6077102
parentb6d9ce325c4586ea3ab2d2ea5cfd5e8f8c167aeb
signature Signed by PGP key B5690EEEBB952194

fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one (#3617)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 601 insertions(+), 23 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+378-4
......@@ -5,8 +5,10 @@ import * as Context from "effect/Context";
55import * as Effect from "effect/Effect";
66import * as Exit from "effect/Exit";
77import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
89import * as Layer from "effect/Layer";
910import * as Option from "effect/Option";
11import * as Path from "effect/Path";
1012import * as Schema from "effect/Schema";
1113import * as Scope from "effect/Scope";
1214import * as Stream from "effect/Stream";
......@@ -31,6 +33,8 @@ import {
3133} from "../opencodeRuntime.ts";
3234import {
3335 appendOpenCodeAssistantTextDelta,
36 isOpenCodeNotFound,
37 isSameOpenCodeDirectory,
3438 makeOpenCodeAdapter,
3539 mergeOpenCodeAssistantText,
3640} from "./OpenCodeAdapter.ts";
......@@ -64,6 +68,12 @@ const runtimeMock = {
6468 closeError: null as Error | null,
6569 messages: [] as MessageEntry[],
6670 subscribedEvents: [] as unknown[],
71 sessionGetIds: [] as string[],
72 missingSessionIds: new Set<string>(),
73 transientErrorSessionIds: new Set<string>(),
74 sessionDirectoryById: new Map<string, string>(),
75 sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>,
76 forkCalls: [] as Array<{ sessionID: string; directory?: string }>,
6777 },
6878 reset() {
6979 this.state.startCalls.length = 0;
......@@ -78,6 +88,12 @@ const runtimeMock = {
7888 this.state.closeError = null;
7989 this.state.messages = [];
8090 this.state.subscribedEvents = [];
91 this.state.sessionGetIds.length = 0;
92 this.state.missingSessionIds.clear();
93 this.state.transientErrorSessionIds.clear();
94 this.state.sessionDirectoryById.clear();
95 this.state.sessionUpdateCalls.length = 0;
96 this.state.forkCalls.length = 0;
8197 },
8298};
8399
......@@ -102,10 +118,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
102118 connectToOpenCodeServer: ({ serverUrl }) =>
103119 Effect.gen(function* () {
104120 const url = serverUrl ?? "http://127.0.0.1:4301";
105 // Unconditionally register a scope finalizer for test observability —
106 // preserves the `closeCalls` / `closeError` probes that the existing
107 // suites rely on. Production code never attaches a finalizer to an
108 // external server (it simply returns `Effect.succeed(...)`).
121 // Always register a finalizer so the closeCalls/closeError probes fire;
122 // production attaches none for external servers.
109123 yield* Effect.addFinalizer(() =>
110124 Effect.sync(() => {
111125 runtimeMock.state.closeCalls.push(url);
......@@ -132,6 +146,34 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
132146 );
133147 return { data: { id: `${baseUrl}/session` } };
134148 },
149 get: async ({ sessionID }: { sessionID: string }) => {
150 runtimeMock.state.sessionGetIds.push(sessionID);
151 // The real client is `throwOnError: true`: non-2xx rejects rather
152 // than resolving, so missing → 404 throw, transient → 500 throw.
153 if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) {
154 throw new Error("opencode server error", { cause: { status: 500 } });
155 }
156 if (runtimeMock.state.missingSessionIds.has(sessionID)) {
157 throw new Error(`Session not found: ${sessionID}`, {
158 cause: { status: 404, body: { name: "NotFoundError" } },
159 });
160 }
161 const directory = runtimeMock.state.sessionDirectoryById.get(sessionID);
162 return { data: { id: sessionID, ...(directory ? { directory } : {}) } };
163 },
164 update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => {
165 runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission });
166 return { data: { id: sessionID } };
167 },
168 fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => {
169 // Fork clones history into a new session bound to the directory.
170 const forkedId = `${sessionID}_fork`;
171 runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) });
172 if (directory) {
173 runtimeMock.state.sessionDirectoryById.set(forkedId, directory);
174 }
175 return { data: { id: forkedId, ...(directory ? { directory } : {}) } };
176 },
135177 abort: async ({ sessionID }: { sessionID: string }) => {
136178 runtimeMock.state.abortCalls.push(sessionID);
137179 },
......@@ -251,6 +293,256 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
251293 }),
252294 );
253295
296 it.effect("returns a durable resume cursor for a freshly created session", () =>
297 Effect.gen(function* () {
298 const adapter = yield* OpenCodeAdapter;
299 const threadId = asThreadId("thread-opencode-cursor");
300
301 const session = yield* adapter.startSession({
302 provider: ProviderDriverKind.make("opencode"),
303 threadId,
304 runtimeMode: "full-access",
305 });
306
307 // Without a persisted cursor, a session is created and its id is
308 // surfaced as a resume cursor so the upper layer can persist it.
309 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []);
310 NodeAssert.deepEqual(session.resumeCursor, {
311 schemaVersion: 1,
312 sessionId: "http://127.0.0.1:9999/session",
313 });
314
315 yield* adapter.stopSession(threadId);
316 }),
317 );
318
319 it.effect("resumes the persisted OpenCode session instead of creating a new one", () =>
320 Effect.gen(function* () {
321 const adapter = yield* OpenCodeAdapter;
322 const threadId = asThreadId("thread-opencode-resume");
323
324 const session = yield* adapter.startSession({
325 provider: ProviderDriverKind.make("opencode"),
326 threadId,
327 runtimeMode: "full-access",
328 resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" },
329 });
330
331 // The adapter validates the persisted id with session.get and re-adopts
332 // it — no new session is minted (issue #3604).
333 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]);
334 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []);
335 NodeAssert.deepEqual(session.resumeCursor, {
336 schemaVersion: 1,
337 sessionId: "ses_persisted",
338 });
339 // Resume re-asserts the permission ruleset for the current runtimeMode.
340 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1);
341 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted");
342 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true);
343
344 yield* adapter.stopSession(threadId);
345 }),
346 );
347
348 it.effect("sends follow-up turns to the resumed session id", () =>
349 Effect.gen(function* () {
350 const adapter = yield* OpenCodeAdapter;
351 const threadId = asThreadId("thread-opencode-resume-turn");
352
353 yield* adapter.startSession({
354 provider: ProviderDriverKind.make("opencode"),
355 threadId,
356 runtimeMode: "full-access",
357 resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" },
358 });
359
360 const result = yield* adapter.sendTurn({
361 threadId,
362 input: "continue where we left off",
363 modelSelection: createModelSelection(
364 ProviderInstanceId.make("opencode"),
365 "anthropic/sonnet",
366 ),
367 });
368
369 // The prompt targets the resumed id, and the turn re-surfaces the cursor.
370 NodeAssert.deepEqual(
371 (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID,
372 "ses_persisted",
373 );
374 NodeAssert.deepEqual(result.resumeCursor, {
375 schemaVersion: 1,
376 sessionId: "ses_persisted",
377 });
378
379 yield* adapter.stopSession(threadId);
380 }),
381 );
382
383 it.effect("falls back to a fresh session when the persisted session is gone", () =>
384 Effect.gen(function* () {
385 const adapter = yield* OpenCodeAdapter;
386 const threadId = asThreadId("thread-opencode-stale");
387 runtimeMock.state.missingSessionIds.add("ses_stale");
388
389 const session = yield* adapter.startSession({
390 provider: ProviderDriverKind.make("opencode"),
391 threadId,
392 runtimeMode: "full-access",
393 resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" },
394 });
395
396 // get probed the stale id, found nothing, then created a new session and
397 // emitted a fresh cursor rather than wedging the thread.
398 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]);
399 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]);
400 NodeAssert.deepEqual(session.resumeCursor, {
401 schemaVersion: 1,
402 sessionId: "http://127.0.0.1:9999/session",
403 });
404
405 yield* adapter.stopSession(threadId);
406 }),
407 );
408
409 it.effect("ignores a malformed or wrong-version resume cursor", () =>
410 Effect.gen(function* () {
411 const adapter = yield* OpenCodeAdapter;
412 const threadId = asThreadId("thread-opencode-badcursor");
413
414 const session = yield* adapter.startSession({
415 provider: ProviderDriverKind.make("opencode"),
416 threadId,
417 runtimeMode: "full-access",
418 resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" },
419 });
420
421 // A foreign/stale-shaped cursor is treated as "no resume": never probed,
422 // a fresh session is created.
423 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []);
424 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]);
425 NodeAssert.deepEqual(session.resumeCursor, {
426 schemaVersion: 1,
427 sessionId: "http://127.0.0.1:9999/session",
428 });
429
430 yield* adapter.stopSession(threadId);
431 }),
432 );
433
434 it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () =>
435 Effect.gen(function* () {
436 const adapter = yield* OpenCodeAdapter;
437 const threadId = asThreadId("thread-opencode-transient");
438 // session.get returns a 500 (not a 404) for this id.
439 runtimeMock.state.transientErrorSessionIds.add("ses_transient");
440
441 const exit = yield* Effect.exit(
442 adapter.startSession({
443 provider: ProviderDriverKind.make("opencode"),
444 threadId,
445 runtimeMode: "full-access",
446 resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" },
447 }),
448 );
449
450 // A transient/transport/auth failure must propagate — NOT be masked as a
451 // brand-new empty session (the #3604 class of silent context loss).
452 NodeAssert.equal(Exit.isFailure(exit), true);
453 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]);
454 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []);
455 }),
456 );
457
458 it.effect("re-applies the current runtimeMode permissions when resuming", () =>
459 Effect.gen(function* () {
460 const adapter = yield* OpenCodeAdapter;
461 const threadId = asThreadId("thread-opencode-perms");
462
463 yield* adapter.startSession({
464 provider: ProviderDriverKind.make("opencode"),
465 // A different runtimeMode than the original create — resume must not
466 // leave the upstream session on stale permissions.
467 runtimeMode: "approval-required",
468 threadId,
469 resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" },
470 });
471
472 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]);
473 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []);
474 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1);
475 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms");
476 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true);
477
478 yield* adapter.stopSession(threadId);
479 }),
480 );
481
482 it.effect(
483 "forks the resumed session into the requested directory instead of losing context",
484 () =>
485 Effect.gen(function* () {
486 const adapter = yield* OpenCodeAdapter;
487 const threadId = asThreadId("thread-opencode-cwd");
488 // The persisted session still exists but was created in another working dir
489 // (e.g. the thread moved from the project root into a git worktree).
490 runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree");
491
492 const session = yield* adapter.startSession({
493 provider: ProviderDriverKind.make("opencode"),
494 threadId,
495 runtimeMode: "full-access",
496 resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" },
497 });
498
499 // A cwd change must not mint an empty session: the adapter forks the
500 // persisted session into the requested cwd, carrying history forward.
501 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]);
502 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []);
503 NodeAssert.equal(runtimeMock.state.forkCalls.length, 1);
504 NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir");
505 NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string");
506 // Permission ruleset re-asserted on the fork for the current runtimeMode.
507 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1);
508 NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork");
509 // Durable cursor now points at the history-complete fork in the new directory.
510 NodeAssert.deepEqual(session.resumeCursor, {
511 schemaVersion: 1,
512 sessionId: "ses_otherdir_fork",
513 });
514
515 yield* adapter.stopSession(threadId);
516 }),
517 );
518
519 it.effect("reuses the resumed session when the stored directory differs only lexically", () =>
520 Effect.gen(function* () {
521 const adapter = yield* OpenCodeAdapter;
522 const threadId = asThreadId("thread-opencode-samedir");
523 // Same working tree, different spelling (trailing slash) — must reuse,
524 // not fork.
525 runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`);
526
527 const session = yield* adapter.startSession({
528 provider: ProviderDriverKind.make("opencode"),
529 threadId,
530 runtimeMode: "full-access",
531 resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" },
532 });
533
534 NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]);
535 NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []);
536 NodeAssert.deepEqual(runtimeMock.state.forkCalls, []);
537 NodeAssert.deepEqual(session.resumeCursor, {
538 schemaVersion: 1,
539 sessionId: "ses_samedir",
540 });
541
542 yield* adapter.stopSession(threadId);
543 }),
544 );
545
254546 it.effect("fails sendTurn for missing sessions through the typed error channel", () =>
255547 Effect.gen(function* () {
256548 const adapter = yield* OpenCodeAdapter;
......@@ -673,6 +965,88 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
673965 }),
674966 );
675967
968 it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () =>
969 Effect.sync(() => {
970 // The real production shape: runOpenCodeSdk wraps the thrown Error
971 // (cause = { body, status }) under OpenCodeRuntimeError.
972 const wrappedError = new Error("Session not found: ses_x", {
973 cause: { body: { name: "NotFoundError" }, status: 404 },
974 });
975 NodeAssert.equal(
976 isOpenCodeNotFound({
977 _tag: "OpenCodeRuntimeError",
978 operation: "session.get",
979 detail: "Session not found: ses_x",
980 cause: wrappedError,
981 }),
982 true,
983 );
984
985 // 404 expressed only via response.status (the bot's flagged shape).
986 NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true);
987 // 404 via a bare numeric status / statusCode.
988 NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true);
989 NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true);
990 // OpenCode NotFoundError body name with no status.
991 NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true);
992
993 // NOT a miss: only structured signals count, never free text. A non-404
994 // error whose message/detail merely contains "not found" must propagate,
995 // not be misread as a missing session and silently start fresh.
996 NodeAssert.equal(
997 isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })),
998 false,
999 );
1000 NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false);
1001 // An explicit non-404 status seals its subtree: a 500 whose serialized
1002 // body echoes a NotFoundError name — or that is itself named
1003 // *NotFound* — is a real failure, never a miss.
1004 NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false);
1005 NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false);
1006 // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError`
1007 // is not a confirmed miss even without a sealing status.
1008 NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false);
1009 NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false);
1010 NodeAssert.equal(
1011 isOpenCodeNotFound(
1012 new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }),
1013 ),
1014 false,
1015 );
1016 // Other transient/auth/network failures must propagate too.
1017 NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false);
1018 NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false);
1019 NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false);
1020 NodeAssert.equal(isOpenCodeNotFound(undefined), false);
1021 }),
1022 );
1023
1024 it.effect("treats lexically or physically identical directories as the same", () =>
1025 Effect.gen(function* () {
1026 const fileSystem = yield* FileSystem.FileSystem;
1027 const path = yield* Path.Path;
1028 const sameDirectory = (left: string, right: string) =>
1029 isSameOpenCodeDirectory(fileSystem, path, left, right);
1030
1031 // Lexical-only differences (trailing slash, dot segments) short-circuit
1032 // without touching the filesystem — the paths need not exist.
1033 NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true);
1034 NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true);
1035 // Nonexistent paths degrade to the lexical comparison instead of failing.
1036 NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false);
1037
1038 // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to
1039 // the directory it points at, so the two spellings compare equal.
1040 const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" });
1041 const real = path.join(base, "real");
1042 const link = path.join(base, "link");
1043 yield* fileSystem.makeDirectory(real);
1044 yield* fileSystem.symlink(real, link);
1045 NodeAssert.equal(yield* sameDirectory(link, real), true);
1046 NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false);
1047 }).pipe(Effect.scoped),
1048 );
1049
6761050 it.effect("appends raw assistant text deltas and reconciles part update snapshots", () =>
6771051 Effect.sync(() => {
6781052 const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello");
apps/server/src/provider/Layers/OpenCodeAdapter.ts+223-19
......@@ -17,6 +17,8 @@ import * as Crypto from "effect/Crypto";
1717import * as DateTime from "effect/DateTime";
1818import * as Effect from "effect/Effect";
1919import * as Exit from "effect/Exit";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
2022import * as Queue from "effect/Queue";
2123import * as Ref from "effect/Ref";
2224import * as Scope from "effect/Scope";
......@@ -53,6 +55,114 @@ import * as Option from "effect/Option";
5355
5456const PROVIDER = ProviderDriverKind.make("opencode");
5557
58/**
59 * Version tag stamped into the OpenCode resume cursor. Bump if the cursor
60 * shape changes so stale-shaped cursors written by older builds are ignored
61 * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION).
62 */
63const OPENCODE_RESUME_VERSION = 1 as const;
64
65/**
66 * Decode a persisted resume cursor into the upstream `ses_…` id. Anything
67 * that isn't a current-version cursor with a non-empty id means "no resume"
68 * rather than an error. Re-adopting the session id IS the resume mechanism —
69 * OpenCode scopes a conversation's history by session id.
70 */
71function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined {
72 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
73 return undefined;
74 }
75 const record = raw as Record<string, unknown>;
76 if (record.schemaVersion !== OPENCODE_RESUME_VERSION) {
77 return undefined;
78 }
79 if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) {
80 return undefined;
81 }
82 return { sessionId: record.sessionId.trim() };
83}
84
85/**
86 * Whether an error definitively reports a missing session. Only a confirmed
87 * miss may silently start a fresh session; any other failure (the SDK client
88 * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must
89 * propagate, or a transient blip resets a live thread to an empty one — the
90 * #3604 silent context loss. Decides on structured signals only, never free
91 * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk
92 * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its
93 * subtree so a wrapped "NotFound" name can't reclassify a real failure.
94 * Exported for unit testing.
95 */
96export function isOpenCodeNotFound(cause: unknown): boolean {
97 const seen = new Set<unknown>();
98 const queue: Array<unknown> = [cause];
99 for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) {
100 const node = queue.shift();
101 if (node === null || typeof node !== "object" || seen.has(node)) {
102 continue;
103 }
104 seen.add(node);
105 const record = node as Record<string, unknown>;
106
107 const response = record.response;
108 const statuses = [
109 record.status,
110 record.statusCode,
111 response !== null && typeof response === "object"
112 ? (response as { readonly status?: unknown }).status
113 : undefined,
114 ].filter((status): status is number => typeof status === "number");
115 if (statuses.includes(404)) {
116 return true;
117 }
118 if (statuses.length > 0) {
119 continue;
120 }
121
122 const name = record.name;
123 if (typeof name === "string" && name.toLowerCase() === "notfounderror") {
124 return true;
125 }
126
127 for (const key of ["cause", "body", "error", "data"] as const) {
128 if (record[key] !== undefined) {
129 queue.push(record[key]);
130 }
131 }
132 }
133 return false;
134}
135
136/**
137 * Whether two directory spellings name the same location. Raw string
138 * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd
139 * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the
140 * session on every resume. Lexically equal paths short-circuit; otherwise
141 * both sides go through `realPath`, each falling back to its lexical form
142 * on failure (deleted directory, external-server path) — so the probe can
143 * only widen matches, never split them. Takes the services as arguments so
144 * adapter methods stay service-free. Exported for unit testing.
145 */
146export function isSameOpenCodeDirectory(
147 fileSystem: FileSystem.FileSystem,
148 path: Path.Path,
149 left: string,
150 right: string,
151): Effect.Effect<boolean> {
152 const lexicalLeft = path.resolve(left);
153 const lexicalRight = path.resolve(right);
154 if (lexicalLeft === lexicalRight) {
155 return Effect.succeed(true);
156 }
157 const canonicalize = (lexical: string) =>
158 fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical));
159 return Effect.zipWith(
160 canonicalize(lexicalLeft),
161 canonicalize(lexicalRight),
162 (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight,
163 );
164}
165
56166interface OpenCodeTurnSnapshot {
57167 readonly id: TurnId;
58168 readonly items: Array<unknown>;
......@@ -459,6 +569,10 @@ export function makeOpenCodeAdapter(
459569 const serverConfig = yield* ServerConfig;
460570 const openCodeRuntime = yield* OpenCodeRuntime;
461571 const crypto = yield* Crypto.Crypto;
572 const fileSystem = yield* FileSystem.FileSystem;
573 const path = yield* Path.Path;
574 const sameDirectory = (left: string, right: string) =>
575 isSameOpenCodeDirectory(fileSystem, path, left, right);
462576 const nativeEventLogger =
463577 options?.nativeEventLogger ??
464578 (options?.nativeEventLogPath !== undefined
......@@ -1076,6 +1190,7 @@ export function makeOpenCodeAdapter(
10761190 const serverUrl = openCodeSettings.serverUrl;
10771191 const serverPassword = openCodeSettings.serverPassword;
10781192 const directory = input.cwd ?? serverConfig.cwd;
1193 const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId;
10791194 const existing = sessions.get(input.threadId);
10801195 if (existing) {
10811196 yield* stopOpenCodeContext(existing);
......@@ -1115,22 +1230,96 @@ export function makeOpenCodeAdapter(
11151230 }),
11161231 );
11171232 }
1118 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
1119 client.session.create({
1120 permission: buildOpenCodePermissionRules(input.runtimeMode),
1121 }),
1122 );
1123 if (!openCodeSession.data) {
1124 return yield* new OpenCodeRuntimeError({
1125 operation: "session.create",
1126 detail: "OpenCode session.create returned no session payload.",
1127 });
1128 }
1233 // Resume: re-adopt the session named by the durable cursor —
1234 // OpenCode scopes history by session id. The probe recovers only
1235 // a confirmed not-found (start fresh); transport/auth/server
1236 // errors propagate instead of masking as a new empty session.
1237 const resolved = yield* Effect.gen(function* () {
1238 const adopted = resumeSessionId
1239 ? yield* runOpenCodeSdk("session.get", () =>
1240 client.session.get({ sessionID: resumeSessionId }),
1241 ).pipe(
1242 Effect.map((response) => response.data),
1243 Effect.catchIf(
1244 (cause) => isOpenCodeNotFound(cause),
1245 () => Effect.void,
1246 ),
1247 )
1248 : undefined;
1249
1250 // Reuse in place only when the session still matches the
1251 // requested cwd; on a cwd change it is forked below instead.
1252 const reusable =
1253 adopted &&
1254 (!adopted.directory || (yield* sameDirectory(adopted.directory, directory)))
1255 ? adopted
1256 : undefined;
1257
1258 if (reusable) {
1259 // Resume skips `session.create`, so re-assert the ruleset —
1260 // a runtime-mode change would otherwise leave the session on
1261 // its original permissions.
1262 yield* runOpenCodeSdk("session.update", () =>
1263 client.session.update({
1264 sessionID: reusable.id,
1265 permission: buildOpenCodePermissionRules(input.runtimeMode),
1266 }),
1267 );
1268 return { openCodeSession: reusable, created: false };
1269 }
1270
1271 // The session lives under a different cwd (e.g. the thread
1272 // moved into a git worktree). Fork it into the requested
1273 // directory instead of minting an empty one — the fork carries
1274 // the full history, so the follow-up keeps its context (#3604).
1275 if (adopted) {
1276 yield* Effect.logInfo(
1277 `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`,
1278 );
1279 const forkedSession = yield* runOpenCodeSdk("session.fork", () =>
1280 client.session.fork({ sessionID: adopted.id, directory }),
1281 );
1282 const forked = forkedSession.data;
1283 if (!forked) {
1284 return yield* new OpenCodeRuntimeError({
1285 operation: "session.fork",
1286 detail: "OpenCode session.fork returned no session payload.",
1287 });
1288 }
1289 yield* runOpenCodeSdk("session.update", () =>
1290 client.session.update({
1291 sessionID: forked.id,
1292 permission: buildOpenCodePermissionRules(input.runtimeMode),
1293 }),
1294 );
1295 return { openCodeSession: forked, created: true };
1296 }
1297
1298 if (resumeSessionId) {
1299 yield* Effect.logWarning(
1300 `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`,
1301 );
1302 }
1303 const createdSession = yield* runOpenCodeSdk("session.create", () =>
1304 client.session.create({
1305 permission: buildOpenCodePermissionRules(input.runtimeMode),
1306 }),
1307 );
1308 if (!createdSession.data) {
1309 return yield* new OpenCodeRuntimeError({
1310 operation: "session.create",
1311 detail: "OpenCode session.create returned no session payload.",
1312 });
1313 }
1314 return { openCodeSession: createdSession.data, created: true };
1315 });
1316
11291317 return {
11301318 sessionScope,
11311319 server,
11321320 client,
1133 openCodeSession: openCodeSession.data,
1321 openCodeSession: resolved.openCodeSession,
1322 created: resolved.created,
11341323 };
11351324 }).pipe(Effect.provideService(Scope.Scope, sessionScope)),
11361325 );
......@@ -1145,13 +1334,16 @@ export function makeOpenCodeAdapter(
11451334 // and already inserted a session while we were awaiting async work.
11461335 const raceWinner = sessions.get(input.threadId);
11471336 if (raceWinner) {
1148 // Another call won the race – clean up the session we just created
1149 // (including the remote SDK session) and return the existing one.
1150 yield* runOpenCodeSdk("session.abort", () =>
1151 started.client.session.abort({
1152 sessionID: started.openCodeSession.id,
1153 }),
1154 ).pipe(Effect.ignore);
1337 // Another call won the race — clean up. Only abort the remote
1338 // session if we created it here; a resumed one is shared upstream
1339 // state the winner is now using.
1340 if (started.created) {
1341 yield* runOpenCodeSdk("session.abort", () =>
1342 started.client.session.abort({
1343 sessionID: started.openCodeSession.id,
1344 }),
1345 ).pipe(Effect.ignore);
1346 }
11551347 yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore);
11561348 return raceWinner.session;
11571349 }
......@@ -1165,6 +1357,13 @@ export function makeOpenCodeAdapter(
11651357 cwd: directory,
11661358 ...(input.modelSelection ? { model: input.modelSelection.model } : {}),
11671359 threadId: input.threadId,
1360 // ProviderService persists this cursor and feeds it back into
1361 // `startSession` after the in-memory session is lost (reaper /
1362 // restart), so follow-ups continue the same conversation (#3604).
1363 resumeCursor: {
1364 schemaVersion: OPENCODE_RESUME_VERSION,
1365 sessionId: started.openCodeSession.id,
1366 },
11681367 createdAt,
11691368 updatedAt: createdAt,
11701369 };
......@@ -1330,6 +1529,11 @@ export function makeOpenCodeAdapter(
13301529 return {
13311530 threadId: input.threadId,
13321531 turnId,
1532 // Re-surface the durable cursor on every turn so the persisted binding
1533 // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex).
1534 ...(context.session.resumeCursor !== undefined
1535 ? { resumeCursor: context.session.resumeCursor }
1536 : {}),
13331537 };
13341538 });
13351539