History

fix(opencode): handle child approvals, stops, and model catalogs (#8480)

Theo Browne committed 2 months agov0.0.36-nightly.20260828.1213

Commit cb49e5d1 parent 0009aac42 files · +7983 −675Browse files

Unified · Split

42 files
  1. apps/mobile/src/features/threads/ThreadComposer.tsx +1 −0
  2. apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +43 −1
  3. apps/mobile/src/features/threads/provider-catalog-refresh.test.ts +46 −0
  4. apps/mobile/src/features/threads/provider-catalog-refresh.ts +34 −0
  5. apps/server/src/provider/Drivers/OpenCodeDriver.ts +19 −2
  6. apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +3667 −87
  7. apps/server/src/provider/Layers/OpenCodeAdapter.ts +1665 −248
  8. apps/server/src/provider/Layers/OpenCodeProvider.test.ts +148 −23
  9. apps/server/src/provider/Layers/OpenCodeProvider.ts +52 −34
  10. apps/server/src/provider/Layers/ProviderRegistry.test.ts +231 −6
  11. apps/server/src/provider/Layers/ProviderRegistry.ts +14 −0
  12. apps/server/src/provider/Layers/ProviderService.test.ts +63 −0
  13. apps/server/src/provider/OpenCodeServerOwner.test.ts +285 −0
  14. apps/server/src/provider/OpenCodeServerOwner.ts +184 −0
  15. apps/server/src/provider/makeManagedServerProvider.test.ts +69 −0
  16. apps/server/src/provider/makeManagedServerProvider.ts +20 −1
  17. apps/server/src/provider/opencodeRuntime.environment.test.ts +130 −1
  18. apps/server/src/provider/opencodeRuntime.inventory.test.ts +28 −0
  19. apps/server/src/provider/opencodeRuntime.permissions.test.ts +1 −0
  20. apps/server/src/provider/opencodeRuntime.ts +136 −21
  21. apps/server/src/server.test.ts +46 −0
  22. apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +124 −8
  23. apps/server/src/textGeneration/OpenCodeTextGeneration.ts +30 −194
  24. apps/web/src/components/chat/ChatComposer.tsx +11 −3
  25. apps/web/src/components/chat/ModelListRow.tsx +7 −0
  26. apps/web/src/components/chat/ModelPickerContent.test.ts +67 −0
  27. apps/web/src/components/chat/ModelPickerContent.tsx +51 −13
  28. apps/web/src/components/chat/ModelPickerSidebar.tsx +7 −2
  29. apps/web/src/components/chat/ProviderModelPicker.test.tsx +102 −0
  30. apps/web/src/components/chat/ProviderModelPicker.tsx +12 −6
  31. apps/web/src/components/chat/TraitsPicker.test.ts +34 −1
  32. apps/web/src/components/chat/TraitsPicker.tsx +81 −5
  33. apps/web/src/components/chat/composerProviderState.test.tsx +88 −0
  34. apps/web/src/components/chat/composerProviderState.tsx +16 −0
  35. apps/web/src/components/chat/providerIconUtils.ts +1 −0
  36. apps/web/src/components/settings/ProjectSettingsPanel.tsx +6 −5
  37. apps/web/src/composerDraftStore.ts +6 −0
  38. apps/web/src/modelSelection.test.ts +293 −0
  39. apps/web/src/modelSelection.ts +66 −13
  40. docs/README.md +1 −1
  41. docs/internals/providers.md +45 −0
  42. docs/user/providers-opencode.md +53 −0
apps/mobile/src/features/threads/ThreadComposer.tsx+1−0
@@ -621,6 +621,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 620 unmodified lines
621621 const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>(
622622 () => ({
623623 ownerId: settingsOwnerId,
624+ environmentId: props.environmentId,
624625 providerGroups: threadProviderGroups,
625626 selectedModel: currentModelSelection,
626627 onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+43−1
@@ -1,4 +1,5 @@
11 import type {
2+ EnvironmentId,
23 ModelSelection,
34 ProviderOptionDescriptor,
45 ProviderOptionSelection,
@@ -27,7 +28,7 @@ import { · 22 unmodified lines
2728 useState,
2829 type ReactNode,
2930 } from "react";
30−import { Platform, Pressable, ScrollView, TextInput, View } from "react-native";
31+import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native";
3132 import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
3233 import { useSafeAreaInsets } from "react-native-safe-area-context";
3334
@@ -47,7 +48,13 @@ import { · 13 unmodified lines
4748 nativeHeaderScrollEdgeEffects,
4849 } from "../../native/StackHeader";
4950 import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
51+import { serverEnvironment } from "../../state/server";
52+import { useAtomCommand } from "../../state/use-atom-command";
5053 import { useNewTaskFlow } from "./new-task-flow-provider";
54+import {
55+ createProviderCatalogRefreshRunner,
56+ providerCatalogRefreshError,
57+} from "./provider-catalog-refresh";
5158 import {
5259 createNativeMailSearchToolbarItem,
5360 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
@@ -293,6 +300,7 @@ type ThreadSettingsSubmenuPage = · 239 unmodified lines
293300 | { readonly kind: "runtime" };
294301
295302 type ThreadSettingsSessionProps = {
303+ readonly environmentId: EnvironmentId | null;
296304 readonly providerGroups: ReadonlyArray<ProviderGroup>;
297305 readonly selectedModel: ModelSelection | null;
298306 readonly onSelectModel: (option: ModelOption) => void;
@@ -344,6 +352,7 @@ export function useExistingThreadSettingsRoutePresentation() { · 45 unmodified lines
344352 }
345353
346354 type ThreadSettingsSessionValue = {
355+ readonly environmentId: EnvironmentId | null;
347356 readonly providerGroups: ReadonlyArray<ProviderGroup>;
348357 readonly runtimeMode: RuntimeMode;
349358 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
@@ -462,6 +471,7 @@ function ThreadSettingsSessionProvider( · 112 unmodified lines
462471
463472 const value = useMemo<ThreadSettingsSessionValue>(
464473 () => ({
474+ environmentId: props.environmentId,
465475 providerGroups: props.providerGroups,
466476 runtimeMode: props.runtimeMode,
467477 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
@@ -490,6 +500,7 @@ function ThreadSettingsSessionProvider( · 22 unmodified lines
490500 hasLegacyModels,
491501 isApplied,
492502 isDisplayed,
503+ props.environmentId,
493504 pendingModel,
494505 pressModel,
495506 providerFilter,
@@ -958,6 +969,23 @@ function ThreadSettingsModelsScreen() { · 462 unmodified lines
958969 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
959970 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
960971 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
972+ const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
973+ reportFailure: false,
974+ });
975+ const refreshProviderCatalog = useMemo(
976+ () => createProviderCatalogRefreshRunner(refreshProvidersCommand),
977+ [refreshProvidersCommand],
978+ );
979+ const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
980+ const refreshProviders = useCallback(() => {
981+ if (!session.environmentId || isRefreshingProviders) return;
982+ setIsRefreshingProviders(true);
983+ void refreshProviderCatalog(session.environmentId).then((result) => {
984+ setIsRefreshingProviders(false);
985+ const error = providerCatalogRefreshError(result);
986+ if (error) Alert.alert("Could not refresh models", error);
987+ });
988+ }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
961989 const commitAndClose = useCallback(() => {
962990 session.commitPendingModel();
963991 presentation.onClose();
@@ -1005,6 +1033,12 @@ function ThreadSettingsModelsScreen() { · 41 unmodified lines
10051033 {Platform.OS === "android" ? (
10061034 <AndroidScreenHeader
10071035 actions={[
1036+ {
1037+ accessibilityLabel: "Refresh models",
1038+ disabled: isRefreshingProviders || session.environmentId === null,
1039+ icon: "arrow.clockwise",
1040+ onPress: refreshProviders,
1041+ },
10081042 {
10091043 accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done",
10101044 icon: "checkmark",
@@ -1070,6 +1104,13 @@ function ThreadSettingsModelsScreen() { · 59 unmodified lines
10701104 />
10711105 </NativeHeaderToolbar>
10721106 <NativeHeaderToolbar placement="right">
1107+ <NativeHeaderToolbar.Button
1108+ accessibilityLabel="Refresh models"
1109+ disabled={isRefreshingProviders || session.environmentId === null}
1110+ icon="arrow.clockwise"
1111+ onPress={refreshProviders}
1112+ separateBackground
1113+ />
10731114 <NativeHeaderToolbar.Button
10741115 accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"}
10751116 label={session.pendingModel ? "Save" : "Done"}
@@ -1230,6 +1271,7 @@ export function NewTaskThreadSettingsRouteScreen() { · 154 unmodified lines
12301271
12311272 return (
12321273 <ThreadSettingsSessionProvider
1274+ environmentId={flow.selectedEnvironmentId}
12331275 providerGroups={flow.providerGroups}
12341276 selectedModel={flow.selectedModel}
12351277 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
apps/mobile/src/features/threads/provider-catalog-refresh.test.ts+46−0
@@ -0,0 +1,46 @@
1+import { EnvironmentId } from "@t3tools/contracts";
2+import * as Cause from "effect/Cause";
3+import { AsyncResult } from "effect/unstable/reactivity";
4+import { describe, expect, it, vi } from "vite-plus/test";
5+
6+import {
7+ createProviderCatalogRefreshRunner,
8+ providerCatalogRefreshError,
9+} from "./provider-catalog-refresh";
10+
11+describe("mobile provider catalog refresh", () => {
12+ it("calls server discovery for the selected environment and deduplicates pending taps", async () => {
13+ let resolveRefresh: ((value: "refreshed") => void) | undefined;
14+ const refreshProviders = vi.fn(
15+ () =>
16+ new Promise<"refreshed">((resolve) => {
17+ resolveRefresh = resolve;
18+ }),
19+ );
20+ const refresh = createProviderCatalogRefreshRunner(refreshProviders);
21+ const environmentId = EnvironmentId.make("environment-mobile");
22+
23+ const first = refresh(environmentId);
24+ const second = refresh(environmentId);
25+
26+ expect(second).toBe(first);
27+ expect(refreshProviders).toHaveBeenCalledOnce();
28+ expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} });
29+
30+ resolveRefresh?.("refreshed");
31+ await expect(first).resolves.toBe("refreshed");
32+ });
33+
34+ it("reports a discovery error and allows retry after the failed command settles", async () => {
35+ const failure = AsyncResult.failure<never, Error>(Cause.fail(new Error("discovery failed")));
36+ const success = AsyncResult.success("refreshed");
37+ let callCount = 0;
38+ const refreshProviders = vi.fn(async () => (callCount++ === 0 ? failure : success));
39+ const refresh = createProviderCatalogRefreshRunner(refreshProviders);
40+ const environmentId = EnvironmentId.make("environment-mobile");
41+
42+ expect(providerCatalogRefreshError(await refresh(environmentId))).toBe("discovery failed");
43+ expect(providerCatalogRefreshError(await refresh(environmentId))).toBeNull();
44+ expect(refreshProviders).toHaveBeenCalledTimes(2);
45+ });
46+});
apps/mobile/src/features/threads/provider-catalog-refresh.ts+34−0
@@ -0,0 +1,34 @@
1+import type { EnvironmentId } from "@t3tools/contracts";
2+import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3+import {
4+ isAtomCommandInterrupted,
5+ squashAtomCommandFailure,
6+} from "@t3tools/client-runtime/state/runtime";
7+
8+type RefreshProvidersTarget = {
9+ readonly environmentId: EnvironmentId;
10+ readonly input: Record<never, never>;
11+};
12+
13+/** Deduplicates taps while the server refresh command is still running. */
14+export function createProviderCatalogRefreshRunner<Result>(
15+ refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,
16+) {
17+ let pending: Promise<Result> | null = null;
18+
19+ return (environmentId: EnvironmentId): Promise<Result> => {
20+ if (pending) return pending;
21+ pending = refreshProviders({ environmentId, input: {} }).finally(() => {
22+ pending = null;
23+ });
24+ return pending;
25+ };
26+}
27+
28+export function providerCatalogRefreshError(
29+ result: AtomCommandResult<unknown, unknown>,
30+): string | null {
31+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null;
32+ const error = squashAtomCommandFailure(result);
33+ return error instanceof Error ? error.message : "Provider discovery failed.";
34+}
apps/server/src/provider/Drivers/OpenCodeDriver.ts+19−2
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
3434 import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
3535 import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3636 import { OpenCodeRuntime } from "../opencodeRuntime.ts";
37+import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
3738 import {
3839 defaultProviderContinuationIdentity,
3940 type ProviderDriver,
@@ -141,13 +142,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 101 unmodified lines
141142 environment: processEnv,
142143 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
143144 });
144− const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv);
145+ const serverOwner = yield* OpenCodeServerOwner.make({
146+ binaryPath: effectiveConfig.binaryPath,
147+ directory: serverConfig.cwd,
148+ ...(effectiveConfig.serverPassword
149+ ? { serverPassword: effectiveConfig.serverPassword }
150+ : {}),
151+ environment: processEnv,
152+ });
153+ const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
154+ Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
155+ );
145156
146157 const checkProvider = checkOpenCodeProviderStatus(
147158 effectiveConfig,
148159 serverConfig.cwd,
149160 processEnv,
150− ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
161+ ).pipe(
162+ Effect.map(stampIdentity),
163+ Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
164+ Effect.provideService(OpenCodeRuntime, openCodeRuntime),
165+ );
151166
152167 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
153168 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
@@ -156,6 +171,8 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 2 unmodified lines
156171 getSettings: snapshotSettings.getSettings,
157172 streamSettings: snapshotSettings.streamSettings,
158173 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
174+ checkProviderOnSettingsChange: () => false,
175+ refreshOnInterval: false,
159176 initialSnapshot: (settings) =>
160177 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
161178 checkProvider,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+3667−87
@@ -1,6 +1,7 @@
11 import * as NodeAssert from "node:assert/strict";
22 import * as NodeServices from "@effect/platform-node/NodeServices";
33 import { it } from "@effect/vitest";
4+import * as Cause from "effect/Cause";
45 import * as Context from "effect/Context";
56 import * as Effect from "effect/Effect";
67 import * as Exit from "effect/Exit";
@@ -14,8 +15,10 @@ import * as Scope from "effect/Scope"; · 7 unmodified lines
1415 import * as Stream from "effect/Stream";
1516 import * as TestClock from "effect/testing/TestClock";
1617 import { beforeEach } from "vite-plus/test";
18+import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
1719
1820 import {
21+ ApprovalRequestId,
1922 OpenCodeSettings,
2023 ProviderDriverKind,
2124 ProviderInstanceId,
@@ -59,19 +62,48 @@ const runtimeMock = { · 37 unmodified lines
5962 startCalls: [] as string[],
6063 sessionCreateUrls: [] as string[],
6164 sessionCreateInputs: [] as Array<Record<string, unknown>>,
65+ createdSessionIds: [] as string[],
6266 authHeaders: [] as Array<string | null>,
6367 abortCalls: [] as string[],
68+ abortSignals: [] as AbortSignal[],
69+ abortImplementation: null as
70+ | ((sessionID: string, signal?: AbortSignal) => Promise<void>)
71+ | null,
6472 closeCalls: [] as string[],
6573 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
74+ messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
75+ messageFailures: 0,
6676 promptCalls: [] as Array<unknown>,
6777 promptAsyncError: null as Error | null,
78+ promptAsyncImplementation: null as (() => Promise<void>) | null,
79+ autoPromptEcho: true,
80+ autoConnect: true,
81+ promptEchoEvents: [] as Array<unknown>,
6882 closeError: null as Error | null,
6983 messages: [] as MessageEntry[],
70− subscribedEvents: [] as unknown[],
84+ subscribedEvents: [] as Array<unknown | Promise<unknown>>,
85+ eventSubscribeObserved: null as (() => void) | null,
86+ permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>,
87+ questionReplyCalls: [] as Array<{
88+ requestID: string;
89+ answers: ReadonlyArray<ReadonlyArray<string>>;
90+ }>,
91+ sessionStatus: "idle" as "idle" | "busy",
92+ sessionStatusFailures: 0,
93+ sessionStatusCalls: 0,
94+ sessionStatusImplementation: null as (() => Promise<unknown>) | null,
7195 sessionGetIds: [] as string[],
96+ sessionGetObserved: null as ((sessionID: string) => void) | null,
7297 missingSessionIds: new Set<string>(),
7398 transientErrorSessionIds: new Set<string>(),
7499 sessionDirectoryById: new Map<string, string>(),
100+ sessionParentById: new Map<string, string>(),
101+ pendingPermissions: [] as Array<PermissionRequest>,
102+ pendingQuestions: [] as Array<QuestionRequest>,
103+ permissionListCalls: 0,
104+ questionListCalls: 0,
105+ permissionListImplementation: null as (() => Promise<Array<PermissionRequest>>) | null,
106+ questionListImplementation: null as (() => Promise<Array<QuestionRequest>>) | null,
75107 sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>,
76108 forkCalls: [] as Array<{ sessionID: string; directory?: string }>,
77109 },
@@ -79,26 +111,50 @@ const runtimeMock = { · 1 unmodified line
79111 this.state.startCalls.length = 0;
80112 this.state.sessionCreateUrls.length = 0;
81113 this.state.sessionCreateInputs.length = 0;
114+ this.state.createdSessionIds.length = 0;
82115 this.state.authHeaders.length = 0;
83116 this.state.abortCalls.length = 0;
117+ this.state.abortSignals.length = 0;
118+ this.state.abortImplementation = null;
84119 this.state.closeCalls.length = 0;
85120 this.state.revertCalls.length = 0;
121+ this.state.messageCalls.length = 0;
122+ this.state.messageFailures = 0;
86123 this.state.promptCalls.length = 0;
87124 this.state.promptAsyncError = null;
125+ this.state.promptAsyncImplementation = null;
126+ this.state.autoPromptEcho = true;
127+ this.state.autoConnect = true;
128+ this.state.promptEchoEvents.length = 0;
88129 this.state.closeError = null;
89130 this.state.messages = [];
90131 this.state.subscribedEvents = [];
132+ this.state.eventSubscribeObserved = null;
133+ this.state.permissionReplyCalls.length = 0;
134+ this.state.questionReplyCalls.length = 0;
135+ this.state.sessionStatus = "idle";
136+ this.state.sessionStatusFailures = 0;
137+ this.state.sessionStatusCalls = 0;
138+ this.state.sessionStatusImplementation = null;
91139 this.state.sessionGetIds.length = 0;
140+ this.state.sessionGetObserved = null;
92141 this.state.missingSessionIds.clear();
93142 this.state.transientErrorSessionIds.clear();
94143 this.state.sessionDirectoryById.clear();
144+ this.state.sessionParentById.clear();
145+ this.state.pendingPermissions = [];
146+ this.state.pendingQuestions = [];
147+ this.state.permissionListCalls = 0;
148+ this.state.questionListCalls = 0;
149+ this.state.permissionListImplementation = null;
150+ this.state.questionListImplementation = null;
95151 this.state.sessionUpdateCalls.length = 0;
96152 this.state.forkCalls.length = 0;
97153 },
98154 };
99155
100156 const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
101− startOpenCodeServerProcess: ({ binaryPath }) =>
157+ startOpenCodeServerProcess: ({ binaryPath, serverPassword }) =>
102158 Effect.gen(function* () {
103159 runtimeMock.state.startCalls.push(binaryPath);
104160 const url = "http://127.0.0.1:4301";
@@ -112,10 +168,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 7 unmodified lines
112168 );
113169 return {
114170 url,
171+ version: "1.15.13",
172+ ...(serverPassword ? { serverPassword } : {}),
115173 exitCode: Effect.never,
174+ isRunning: Effect.succeed(true),
116175 };
117176 }),
118− connectToOpenCodeServer: ({ serverUrl }) =>
177+ connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
119178 Effect.gen(function* () {
120179 const url = serverUrl ?? "http://127.0.0.1:4301";
121180 // Always register a finalizer so the closeCalls/closeError probes fire;
@@ -130,6 +189,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 8 unmodified lines
130189 );
131190 return {
132191 url,
192+ version: "1.15.13",
193+ ...(serverPassword ? { serverPassword } : {}),
133194 exitCode: null,
134195 external: Boolean(serverUrl),
135196 };
@@ -144,10 +205,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 8 unmodified lines
144205 runtimeMock.state.authHeaders.push(
145206 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
146207 );
147− return { data: { id: `${baseUrl}/session` } };
208+ return {
209+ data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` },
210+ };
148211 },
149212 get: async ({ sessionID }: { sessionID: string }) => {
150213 runtimeMock.state.sessionGetIds.push(sessionID);
214+ runtimeMock.state.sessionGetObserved?.(sessionID);
151215 // The real client is `throwOnError: true`: non-2xx rejects rather
152216 // than resolving, so missing → 404 throw, transient → 500 throw.
153217 if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) {
@@ -159,7 +223,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 5 unmodified lines
159223 });
160224 }
161225 const directory = runtimeMock.state.sessionDirectoryById.get(sessionID);
162− return { data: { id: sessionID, ...(directory ? { directory } : {}) } };
226+ const parentID = runtimeMock.state.sessionParentById.get(sessionID);
227+ return {
228+ data: {
229+ id: sessionID,
230+ ...(directory ? { directory } : {}),
231+ ...(parentID ? { parentID } : {}),
232+ },
233+ };
163234 },
164235 update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => {
165236 runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission });
@@ -174,16 +245,73 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 8 unmodified lines
174245 }
175246 return { data: { id: forkedId, ...(directory ? { directory } : {}) } };
176247 },
177− abort: async ({ sessionID }: { sessionID: string }) => {
248+ abort: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
178249 runtimeMock.state.abortCalls.push(sessionID);
250+ if (options?.signal) {
251+ runtimeMock.state.abortSignals.push(options.signal);
252+ }
253+ await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);
254+ },
255+ status: async () => {
256+ runtimeMock.state.sessionStatusCalls += 1;
257+ if (runtimeMock.state.sessionStatusImplementation) {
258+ return await runtimeMock.state.sessionStatusImplementation();
259+ }
260+ if (runtimeMock.state.sessionStatusFailures > 0) {
261+ runtimeMock.state.sessionStatusFailures -= 1;
262+ throw new Error("status failed");
263+ }
264+ return {
265+ data:
266+ runtimeMock.state.sessionStatus === "idle"
267+ ? {}
268+ : { "http://127.0.0.1:9999/session": { type: "busy" as const } },
269+ };
179270 },
180271 promptAsync: async (input: unknown) => {
181272 runtimeMock.state.promptCalls.push(input);
273+ await runtimeMock.state.promptAsyncImplementation?.();
182274 if (runtimeMock.state.promptAsyncError) {
183275 throw runtimeMock.state.promptAsyncError;
184276 }
277+ if (
278+ runtimeMock.state.autoPromptEcho &&
279+ typeof input === "object" &&
280+ input !== null &&
281+ "sessionID" in input &&
282+ "messageID" in input &&
283+ typeof input.sessionID === "string" &&
284+ typeof input.messageID === "string"
285+ ) {
286+ runtimeMock.state.messages.push({
287+ info: { id: input.messageID, role: "user" },
288+ parts: [],
289+ });
290+ runtimeMock.state.promptEchoEvents.push({
291+ id: `evt-auto-user-${input.messageID}`,
292+ type: "message.updated",
293+ properties: {
294+ sessionID: input.sessionID,
295+ info: { id: input.messageID, role: "user" },
296+ },
297+ });
298+ }
185299 },
186300 messages: async () => ({ data: runtimeMock.state.messages }),
301+ message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
302+ runtimeMock.state.messageCalls.push({ sessionID, messageID });
303+ if (runtimeMock.state.messageFailures > 0) {
304+ runtimeMock.state.messageFailures -= 1;
305+ throw new Error("message lookup failed", { cause: { status: 500 } });
306+ }
307+ const message = runtimeMock.state.messages.find((entry) => entry.info.id === messageID);
308+ if (!message) {
309+ throw new Error(`Message not found: ${messageID}`, {
310+ cause: { status: 404, body: { name: "NotFoundError" } },
311+ });
312+ }
313+ return { data: message };
314+ },
187315 revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => {
188316 runtimeMock.state.revertCalls.push({
189317 sessionID,
@@ -204,13 +332,55 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 14 unmodified lines
204332 },
205333 },
206334 event: {
207− subscribe: async () => ({
208− stream: (async function* () {
209− for (const event of runtimeMock.state.subscribedEvents) {
210− yield event;
211− }
212− })(),
213− }),
335+ subscribe: async () => {
336+ runtimeMock.state.eventSubscribeObserved?.();
337+ return {
338+ stream: (async function* () {
339+ if (runtimeMock.state.autoConnect) {
340+ yield { id: "evt-auto-connected", type: "server.connected", properties: {} };
341+ }
342+ for (const event of runtimeMock.state.subscribedEvents) {
343+ const resolved = await event;
344+ while (runtimeMock.state.promptEchoEvents.length > 0) {
345+ yield runtimeMock.state.promptEchoEvents.shift();
346+ }
347+ yield resolved;
348+ }
349+ })(),
350+ };
351+ },
352+ },
353+ permission: {
354+ list: async () => {
355+ runtimeMock.state.permissionListCalls += 1;
356+ return {
357+ data: runtimeMock.state.permissionListImplementation
358+ ? await runtimeMock.state.permissionListImplementation()
359+ : runtimeMock.state.pendingPermissions,
360+ };
361+ },
362+ reply: async ({ requestID, reply }: { requestID: string; reply: string }) => {
363+ runtimeMock.state.permissionReplyCalls.push({ requestID, reply });
364+ },
365+ },
366+ question: {
367+ list: async () => {
368+ runtimeMock.state.questionListCalls += 1;
369+ return {
370+ data: runtimeMock.state.questionListImplementation
371+ ? await runtimeMock.state.questionListImplementation()
372+ : runtimeMock.state.pendingQuestions,
373+ };
374+ },
375+ reply: async ({
376+ requestID,
377+ answers,
378+ }: {
379+ requestID: string;
380+ answers: ReadonlyArray<ReadonlyArray<string>>;
381+ }) => {
382+ runtimeMock.state.questionReplyCalls.push({ requestID, answers });
383+ },
214384 },
215385 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
216386 loadOpenCodeInventory: () =>
@@ -280,6 +450,37 @@ beforeEach(() => { · 63 unmodified lines
280450 const advanceTestClock = (ms: number) =>
281451 TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow));
282452
453+function promiseWithResolvers<T>() {
454+ let resolve!: (value: T | PromiseLike<T>) => void;
455+ let reject!: (reason?: unknown) => void;
456+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
457+ resolve = resolvePromise;
458+ reject = rejectPromise;
459+ });
460+ return { promise, resolve, reject };
461+}
462+
463+const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({
464+ id,
465+ sessionID,
466+ permission: "bash",
467+ patterns: ["pwd"],
468+ metadata: {},
469+ always: [],
470+});
471+
472+const questionRequest = (id: string, sessionID: string): QuestionRequest => ({
473+ id,
474+ sessionID,
475+ questions: [
476+ {
477+ header: "Scope",
478+ question: "Which scope should OpenCode use?",
479+ options: [{ label: "Workspace", description: "Use this workspace." }],
480+ },
481+ ],
482+});
483+
283484 it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
284485 it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () =>
285486 Effect.gen(function* () {
@@ -301,6 +502,348 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 15 unmodified lines
301502 }),
302503 );
303504
505+ it.effect("fails startup when the OpenCode event stream does not connect", () =>
506+ Effect.gen(function* () {
507+ const adapter = yield* OpenCodeAdapter;
508+ const threadId = asThreadId("thread-opencode-connect-timeout");
509+ runtimeMock.state.autoConnect = false;
510+
511+ const startFiber = yield* adapter
512+ .startSession({
513+ provider: ProviderDriverKind.make("opencode"),
514+ threadId,
515+ runtimeMode: "full-access",
516+ })
517+ .pipe(Effect.result, Effect.forkChild);
518+ yield* Effect.yieldNow;
519+ yield* advanceTestClock(10_000);
520+
521+ const result = yield* Fiber.join(startFiber);
522+ NodeAssert.equal(result._tag, "Failure");
523+ NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError");
524+ NodeAssert.equal(result.failure.method, "event.subscribe");
525+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
526+ }),
527+ );
528+
529+ it.effect("closes a connecting session when startup is interrupted", () =>
530+ Effect.gen(function* () {
531+ const adapter = yield* OpenCodeAdapter;
532+ const threadId = asThreadId("thread-opencode-connect-interrupted");
533+ const eventSubscribeObserved = promiseWithResolvers<void>();
534+ runtimeMock.state.autoConnect = false;
535+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
536+
537+ const startFiber = yield* adapter
538+ .startSession({
539+ provider: ProviderDriverKind.make("opencode"),
540+ threadId,
541+ runtimeMode: "full-access",
542+ })
543+ .pipe(Effect.forkChild);
544+ yield* Effect.promise(() => eventSubscribeObserved.promise);
545+ yield* Effect.yieldNow;
546+ yield* Fiber.interrupt(startFiber);
547+
548+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
549+ NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]);
550+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
551+ }),
552+ );
553+
554+ it.effect("stops a connecting session and rejects its waiting send", () =>
555+ Effect.gen(function* () {
556+ const adapter = yield* OpenCodeAdapter;
557+ const threadId = asThreadId("thread-opencode-stop-connecting");
558+ const eventSubscribeObserved = promiseWithResolvers<void>();
559+ runtimeMock.state.autoConnect = false;
560+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
561+
562+ const startFiber = yield* adapter
563+ .startSession({
564+ provider: ProviderDriverKind.make("opencode"),
565+ threadId,
566+ runtimeMode: "full-access",
567+ })
568+ .pipe(Effect.result, Effect.forkChild);
569+ yield* Effect.promise(() => eventSubscribeObserved.promise);
570+ const connecting = (yield* adapter.listSessions()).find(
571+ (session) => session.threadId === threadId,
572+ );
573+ NodeAssert.equal(connecting?.status, "connecting");
574+
575+ const sendFiber = yield* adapter
576+ .sendTurn({
577+ threadId,
578+ input: "Must not be sent",
579+ modelSelection: createModelSelection(
580+ ProviderInstanceId.make("opencode"),
581+ "opencode/kimi-k3",
582+ ),
583+ })
584+ .pipe(Effect.exit, Effect.forkChild);
585+ yield* Effect.yieldNow;
586+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 0);
587+
588+ yield* adapter.stopSession(threadId);
589+ const startResult = yield* Fiber.join(startFiber);
590+ const sendResult = yield* Fiber.join(sendFiber);
591+ NodeAssert.equal(startResult._tag, "Failure");
592+ NodeAssert.equal(sendResult._tag, "Failure");
593+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 0);
594+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
595+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
596+ }),
597+ );
598+
599+ it.effect("aborts a held teardown request before closing the session scope", () =>
600+ Effect.gen(function* () {
601+ const adapter = yield* OpenCodeAdapter;
602+ const threadId = asThreadId("thread-opencode-teardown-timeout");
603+ const abortStarted = promiseWithResolvers<void>();
604+ runtimeMock.state.abortImplementation = async () => {
605+ abortStarted.resolve(undefined);
606+ await new Promise<void>(() => {});
607+ };
608+ yield* adapter.startSession({
609+ provider: ProviderDriverKind.make("opencode"),
610+ threadId,
611+ runtimeMode: "full-access",
612+ });
613+ const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
614+ yield* Effect.promise(() => abortStarted.promise);
615+
616+ yield* advanceTestClock(999);
617+ NodeAssert.equal(stopFiber.pollUnsafe(), undefined);
618+ NodeAssert.equal(runtimeMock.state.abortSignals.length, 1);
619+ NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, false);
620+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, []);
621+
622+ yield* advanceTestClock(1);
623+ yield* Fiber.join(stopFiber);
624+ NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true);
625+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
626+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
627+ }),
628+ );
629+
630+ it.effect("stopAll closes a connecting session and releases startup", () =>
631+ Effect.gen(function* () {
632+ const adapter = yield* OpenCodeAdapter;
633+ const threadId = asThreadId("thread-opencode-stop-all-connecting");
634+ const eventSubscribeObserved = promiseWithResolvers<void>();
635+ runtimeMock.state.autoConnect = false;
636+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
637+
638+ const startFiber = yield* adapter
639+ .startSession({
640+ provider: ProviderDriverKind.make("opencode"),
641+ threadId,
642+ runtimeMode: "full-access",
643+ })
644+ .pipe(Effect.result, Effect.forkChild);
645+ yield* Effect.promise(() => eventSubscribeObserved.promise);
646+ const sessionCount = (yield* adapter.listSessions()).length;
647+
648+ yield* adapter.stopAll();
649+ const startResult = yield* Fiber.join(startFiber);
650+ NodeAssert.equal(startResult._tag, "Failure");
651+ NodeAssert.equal(runtimeMock.state.closeCalls.length, sessionCount);
652+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
653+ }),
654+ );
655+
656+ it.effect("keeps one session when concurrent starts cross the connection barrier", () =>
657+ Effect.gen(function* () {
658+ const adapter = yield* OpenCodeAdapter;
659+ const threadId = asThreadId("thread-opencode-concurrent-start");
660+ const connectionEvent = promiseWithResolvers<unknown>();
661+ runtimeMock.state.autoConnect = false;
662+ runtimeMock.state.createdSessionIds.push("ses_race_a", "ses_race_b");
663+ runtimeMock.state.subscribedEvents = [connectionEvent.promise];
664+
665+ const firstStart = yield* adapter
666+ .startSession({
667+ provider: ProviderDriverKind.make("opencode"),
668+ threadId,
669+ runtimeMode: "full-access",
670+ })
671+ .pipe(Effect.forkChild);
672+ const secondStart = yield* adapter
673+ .startSession({
674+ provider: ProviderDriverKind.make("opencode"),
675+ threadId,
676+ runtimeMode: "full-access",
677+ })
678+ .pipe(Effect.forkChild);
679+ yield* Effect.yieldNow;
680+ connectionEvent.resolve({
681+ id: "evt-concurrent-start-connected",
682+ type: "server.connected",
683+ properties: {},
684+ });
685+
686+ const [firstSession, secondSession] = yield* Effect.all([
687+ Fiber.join(firstStart),
688+ Fiber.join(secondStart),
689+ ]);
690+ const sessions = yield* adapter.listSessions();
691+ const threadSessions = sessions.filter((session) => session.threadId === threadId);
692+ NodeAssert.equal(threadSessions.length, 1);
693+ NodeAssert.deepEqual(firstSession.resumeCursor, secondSession.resumeCursor);
694+ NodeAssert.equal(firstSession.status, "ready");
695+ NodeAssert.equal(secondSession.status, "ready");
696+ const winnerId = (threadSessions[0]?.resumeCursor as { sessionId?: string } | undefined)
697+ ?.sessionId;
698+ NodeAssert.ok(winnerId === "ses_race_a" || winnerId === "ses_race_b");
699+ NodeAssert.deepEqual(runtimeMock.state.abortCalls, [
700+ winnerId === "ses_race_a" ? "ses_race_b" : "ses_race_a",
701+ ]);
702+ yield* adapter.stopSession(threadId);
703+ }),
704+ );
705+
706+ it.effect("reuses a published connecting session after it becomes ready", () =>
707+ Effect.gen(function* () {
708+ const adapter = yield* OpenCodeAdapter;
709+ const threadId = asThreadId("thread-opencode-reuse-connecting");
710+ const connectionEvent = promiseWithResolvers<unknown>();
711+ const eventSubscribeObserved = promiseWithResolvers<void>();
712+ runtimeMock.state.autoConnect = false;
713+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
714+ runtimeMock.state.subscribedEvents = [connectionEvent.promise];
715+
716+ const owningStart = yield* adapter
717+ .startSession({
718+ provider: ProviderDriverKind.make("opencode"),
719+ threadId,
720+ runtimeMode: "full-access",
721+ })
722+ .pipe(Effect.forkChild);
723+ yield* Effect.promise(() => eventSubscribeObserved.promise);
724+ const reusedStart = yield* adapter
725+ .startSession({
726+ provider: ProviderDriverKind.make("opencode"),
727+ threadId,
728+ runtimeMode: "full-access",
729+ })
730+ .pipe(Effect.forkChild);
731+ yield* Effect.yieldNow;
732+ NodeAssert.equal(runtimeMock.state.sessionCreateUrls.length, 1);
733+
734+ connectionEvent.resolve({
735+ id: "evt-reused-start-connected",
736+ type: "server.connected",
737+ properties: {},
738+ });
739+ const [ownedSession, reusedSession] = yield* Effect.all([
740+ Fiber.join(owningStart),
741+ Fiber.join(reusedStart),
742+ ]);
743+ NodeAssert.equal(ownedSession.status, "ready");
744+ NodeAssert.equal(reusedSession.status, "ready");
745+ NodeAssert.deepEqual(ownedSession.resumeCursor, reusedSession.resumeCursor);
746+
747+ yield* adapter.stopSession(threadId);
748+ }),
749+ );
750+
751+ it.effect("does not let an old held stop delete its replacement", () =>
752+ Effect.gen(function* () {
753+ const adapter = yield* OpenCodeAdapter;
754+ const threadId = asThreadId("thread-opencode-old-stop-replacement");
755+ const abortStarted = promiseWithResolvers<void>();
756+ const abortRelease = promiseWithResolvers<void>();
757+ runtimeMock.state.createdSessionIds.push("ses_old", "ses_replacement");
758+
759+ const oldSession = yield* adapter.startSession({
760+ provider: ProviderDriverKind.make("opencode"),
761+ threadId,
762+ runtimeMode: "full-access",
763+ });
764+ runtimeMock.state.abortImplementation = async () => {
765+ abortStarted.resolve(undefined);
766+ await abortRelease.promise;
767+ };
768+ const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
769+ yield* Effect.promise(() => abortStarted.promise);
770+
771+ const replacement = yield* adapter.startSession({
772+ provider: ProviderDriverKind.make("opencode"),
773+ threadId,
774+ runtimeMode: "full-access",
775+ });
776+ NodeAssert.deepEqual(oldSession.resumeCursor, { schemaVersion: 1, sessionId: "ses_old" });
777+ NodeAssert.deepEqual(replacement.resumeCursor, {
778+ schemaVersion: 1,
779+ sessionId: "ses_replacement",
780+ });
781+
782+ abortRelease.resolve(undefined);
783+ yield* Fiber.join(oldStop);
784+ const current = (yield* adapter.listSessions()).find(
785+ (session) => session.threadId === threadId,
786+ );
787+ NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor);
788+
789+ runtimeMock.state.abortImplementation = null;
790+ yield* adapter.stopSession(threadId);
791+ }),
792+ );
793+
794+ it.effect("replaces a stopped connecting session while its teardown is held", () =>
795+ Effect.gen(function* () {
796+ const adapter = yield* OpenCodeAdapter;
797+ const threadId = asThreadId("thread-opencode-stopped-connecting-retry");
798+ const eventSubscribeObserved = promiseWithResolvers<void>();
799+ const abortStarted = promiseWithResolvers<void>();
800+ const abortRelease = promiseWithResolvers<void>();
801+ runtimeMock.state.autoConnect = false;
802+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
803+ runtimeMock.state.createdSessionIds.push("ses_connecting_old", "ses_connecting_replacement");
804+
805+ const oldStart = yield* adapter
806+ .startSession({
807+ provider: ProviderDriverKind.make("opencode"),
808+ threadId,
809+ runtimeMode: "full-access",
810+ })
811+ .pipe(Effect.result, Effect.forkChild);
812+ yield* Effect.promise(() => eventSubscribeObserved.promise);
813+
814+ runtimeMock.state.abortImplementation = async () => {
815+ abortStarted.resolve(undefined);
816+ await abortRelease.promise;
817+ };
818+ const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
819+ yield* Effect.promise(() => abortStarted.promise);
820+
821+ runtimeMock.state.autoConnect = true;
822+ runtimeMock.state.abortImplementation = null;
823+ const replacement = yield* adapter.startSession({
824+ provider: ProviderDriverKind.make("opencode"),
825+ threadId,
826+ runtimeMode: "full-access",
827+ });
828+ NodeAssert.equal(replacement.status, "ready");
829+ NodeAssert.deepEqual(replacement.resumeCursor, {
830+ schemaVersion: 1,
831+ sessionId: "ses_connecting_replacement",
832+ });
833+
834+ abortRelease.resolve(undefined);
835+ const oldStartResult = yield* Fiber.join(oldStart);
836+ yield* Fiber.join(oldStop);
837+ NodeAssert.equal(oldStartResult._tag, "Failure");
838+ const current = (yield* adapter.listSessions()).find(
839+ (session) => session.threadId === threadId,
840+ );
841+ NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor);
842+
843+ yield* adapter.stopSession(threadId);
844+ }),
845+ );
846+
304847 it.effect("returns a durable resume cursor for a freshly created session", () =>
305848 Effect.gen(function* () {
306849 const adapter = yield* OpenCodeAdapter;
@@ -809,95 +1352,3099 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 502 unmodified lines
8091352 }),
8101353 );
8111354
812− it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
813− const instanceId = ProviderInstanceId.make("opencode_zen");
814− const adapterLayer = Layer.effect(
815− OpenCodeAdapter,
816− makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
817− ).pipe(
818− Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
819− Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
820− Layer.provideMerge(ServerSettingsService.layerTest()),
821− Layer.provideMerge(providerSessionDirectoryTestLayer),
822− Layer.provideMerge(NodeServices.layer),
823− );
824−
825− return Effect.gen(function* () {
1355+ it.effect("does not let an old idle status complete a successful steer", () =>
1356+ Effect.gen(function* () {
8261357 const adapter = yield* OpenCodeAdapter;
1358+ const threadId = asThreadId("thread-steer-idle-admission");
1359+ const busyBeforeSteer = promiseWithResolvers<unknown>();
1360+ const idleBeforeSteer = promiseWithResolvers<unknown>();
1361+ const idleAfterSteer = promiseWithResolvers<unknown>();
1362+ const statusStarted = promiseWithResolvers<void>();
1363+ const statusRelease = promiseWithResolvers<void>();
1364+ const steerStarted = promiseWithResolvers<void>();
1365+ const steerRelease = promiseWithResolvers<void>();
1366+ runtimeMock.state.subscribedEvents = [
1367+ busyBeforeSteer.promise,
1368+ idleBeforeSteer.promise,
1369+ idleAfterSteer.promise,
1370+ ];
1371+ runtimeMock.state.sessionStatusImplementation = async () => {
1372+ statusStarted.resolve(undefined);
1373+ await statusRelease.promise;
1374+ return { data: {} };
1375+ };
1376+ runtimeMock.state.promptAsyncImplementation = async () => {
1377+ if (runtimeMock.state.promptCalls.length === 3) {
1378+ steerStarted.resolve(undefined);
1379+ await steerRelease.promise;
1380+ }
1381+ };
1382+
1383+ const completedFiber = yield* adapter.streamEvents.pipe(
1384+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
1385+ Stream.runHead,
1386+ Effect.forkChild,
1387+ );
8271388 yield* adapter.startSession({
8281389 provider: ProviderDriverKind.make("opencode"),
829− threadId: asThreadId("thread-custom-instance"),
1390+ threadId,
8301391 runtimeMode: "full-access",
8311392 });
832−
833− yield* adapter.sendTurn({
834− threadId: asThreadId("thread-custom-instance"),
835− input: "Fix it",
1393+ const stoppedTurn = yield* adapter.sendTurn({
1394+ threadId,
1395+ input: "Stop this turn",
8361396 modelSelection: createModelSelection(
837− ProviderInstanceId.make("opencode_zen"),
838− "anthropic/claude-sonnet-4-5",
839− [
840− { id: "agent", value: "github-copilot" },
841− { id: "variant", value: "high" },
842− ],
1397+ ProviderInstanceId.make("opencode"),
1398+ "opencode/kimi-k3",
8431399 ),
8441400 });
845−
846− NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), {
847− sessionID: "http://127.0.0.1:9999/session",
848− model: {
849− providerID: "anthropic",
850− modelID: "claude-sonnet-4-5",
851− },
852− agent: "github-copilot",
853− variant: "high",
854− parts: [{ type: "text", text: "Fix it" }],
855− });
856− }).pipe(Effect.provide(adapterLayer));
857− });
858−
859− it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => {
860− const instanceId = ProviderInstanceId.make("opencode_zen");
861− const adapterLayer = Layer.effect(
862− OpenCodeAdapter,
863− makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
864− ).pipe(
865− Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
866− Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
867− Layer.provideMerge(ServerSettingsService.layerTest()),
868− Layer.provideMerge(providerSessionDirectoryTestLayer),
869− Layer.provideMerge(NodeServices.layer),
870− );
871−
872− return Effect.gen(function* () {
873− const adapter = yield* OpenCodeAdapter;
874− const threadId = asThreadId("thread-custom-instance-fallback-model");
875− yield* adapter.startSession({
876− provider: ProviderDriverKind.make("opencode"),
1401+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
1402+ const activeTurn = yield* adapter.sendTurn({
8771403 threadId,
878− runtimeMode: "full-access",
1404+ input: "Start the next turn",
8791405 modelSelection: createModelSelection(
880− ProviderInstanceId.make("opencode_zen"),
881− "anthropic/claude-sonnet-4-5",
1406+ ProviderInstanceId.make("opencode"),
1407+ "opencode/kimi-k3",
8821408 ),
8831409 });
884−
885− yield* adapter.sendTurn({
886− threadId,
887− input: "Fix it",
1410+ busyBeforeSteer.resolve({
1411+ id: "evt-busy-before-steer",
1412+ type: "session.status",
1413+ properties: {
1414+ sessionID: "http://127.0.0.1:9999/session",
1415+ status: { type: "busy" },
1416+ },
8881417 });
889−
890− NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), {
891− sessionID: "http://127.0.0.1:9999/session",
892− model: {
893− providerID: "anthropic",
894− modelID: "claude-sonnet-4-5",
1418+ idleBeforeSteer.resolve({
1419+ id: "evt-idle-before-steer",
1420+ type: "session.status",
1421+ properties: {
1422+ sessionID: "http://127.0.0.1:9999/session",
1423+ status: { type: "idle" },
8951424 },
896− parts: [{ type: "text", text: "Fix it" }],
8971425 });
898− }).pipe(Effect.provide(adapterLayer));
899− });
900−
1426+ yield* Effect.promise(() => statusStarted.promise);
1427+ const steerFiber = yield* adapter
1428+ .sendTurn({
1429+ threadId,
1430+ input: "Add one more task",
1431+ modelSelection: createModelSelection(
1432+ ProviderInstanceId.make("opencode"),
1433+ "opencode/kimi-k3",
1434+ ),
1435+ })
1436+ .pipe(Effect.forkChild);
1437+ yield* Effect.promise(() => steerStarted.promise);
1438+ statusRelease.resolve(undefined);
1439+ steerRelease.resolve(undefined);
1440+ yield* Fiber.join(steerFiber);
1441+
1442+ const sessions = yield* adapter.listSessions();
1443+ const session = sessions.find((candidate) => candidate.threadId === threadId);
1444+ NodeAssert.equal(session?.status, "running");
1445+ NodeAssert.equal(session?.activeTurnId, activeTurn.turnId);
1446+
1447+ idleAfterSteer.resolve({
1448+ id: "evt-idle-after-steer",
1449+ type: "session.status",
1450+ properties: {
1451+ sessionID: "http://127.0.0.1:9999/session",
1452+ status: { type: "idle" },
1453+ },
1454+ });
1455+ const completed = Option.getOrUndefined(
1456+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
1457+ );
1458+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
1459+ }),
1460+ );
1461+
1462+ it.effect("waits for steer admission before accepting the only idle event", () =>
1463+ Effect.gen(function* () {
1464+ const adapter = yield* OpenCodeAdapter;
1465+ const threadId = asThreadId("thread-steer-admission-only-idle");
1466+ runtimeMock.state.autoPromptEcho = false;
1467+ const firstUserMessageEvent = promiseWithResolvers<unknown>();
1468+ const staleIdleEvent = promiseWithResolvers<unknown>();
1469+ const userMessageEvent = promiseWithResolvers<unknown>();
1470+ const idleEvent = promiseWithResolvers<unknown>();
1471+ const steerStarted = promiseWithResolvers<void>();
1472+ const steerRelease = promiseWithResolvers<void>();
1473+ runtimeMock.state.subscribedEvents = [
1474+ firstUserMessageEvent.promise,
1475+ staleIdleEvent.promise,
1476+ userMessageEvent.promise,
1477+ idleEvent.promise,
1478+ ];
1479+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
1480+ runtimeMock.state.promptAsyncImplementation = async () => {
1481+ if (runtimeMock.state.promptCalls.length === 2) {
1482+ steerStarted.resolve(undefined);
1483+ await steerRelease.promise;
1484+ }
1485+ };
1486+
1487+ const completedFiber = yield* adapter.streamEvents.pipe(
1488+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
1489+ Stream.runHead,
1490+ Effect.forkChild,
1491+ );
1492+ yield* adapter.startSession({
1493+ provider: ProviderDriverKind.make("opencode"),
1494+ threadId,
1495+ runtimeMode: "full-access",
1496+ });
1497+ const activeTurn = yield* adapter.sendTurn({
1498+ threadId,
1499+ input: "Start work",
1500+ modelSelection: createModelSelection(
1501+ ProviderInstanceId.make("opencode"),
1502+ "opencode/kimi-k3",
1503+ ),
1504+ });
1505+ const steerFiber = yield* adapter
1506+ .sendTurn({
1507+ threadId,
1508+ input: "Add another task",
1509+ modelSelection: createModelSelection(
1510+ ProviderInstanceId.make("opencode"),
1511+ "opencode/kimi-k3",
1512+ ),
1513+ })
1514+ .pipe(Effect.forkChild);
1515+ yield* Effect.promise(() => steerStarted.promise);
1516+ const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID;
1517+ const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID;
1518+ NodeAssert.match(firstMessageId ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
1519+ NodeAssert.match(steerMessageId ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
1520+ firstUserMessageEvent.resolve({
1521+ id: "evt-delayed-first-user-message",
1522+ type: "message.updated",
1523+ properties: {
1524+ sessionID: "http://127.0.0.1:9999/session",
1525+ info: { id: firstMessageId, role: "user" },
1526+ },
1527+ });
1528+ staleIdleEvent.resolve({
1529+ id: "evt-stale-idle-during-steer",
1530+ type: "session.status",
1531+ properties: {
1532+ sessionID: "http://127.0.0.1:9999/session",
1533+ status: { type: "idle" },
1534+ },
1535+ });
1536+ userMessageEvent.resolve({
1537+ id: "evt-steer-user-message",
1538+ type: "message.updated",
1539+ properties: {
1540+ sessionID: "http://127.0.0.1:9999/session",
1541+ info: { id: steerMessageId, role: "user" },
1542+ },
1543+ });
1544+ idleEvent.resolve({
1545+ id: "evt-only-idle-during-steer",
1546+ type: "session.status",
1547+ properties: {
1548+ sessionID: "http://127.0.0.1:9999/session",
1549+ status: { type: "idle" },
1550+ },
1551+ });
1552+ yield* Effect.yieldNow;
1553+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 0);
1554+ steerRelease.resolve(undefined);
1555+ yield* Fiber.join(steerFiber);
1556+
1557+ const completed = Option.getOrUndefined(
1558+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
1559+ );
1560+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
1561+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true);
1562+ }),
1563+ );
1564+
1565+ it.effect("keeps steer admission until its user message arrives after prompt acceptance", () =>
1566+ Effect.gen(function* () {
1567+ const adapter = yield* OpenCodeAdapter;
1568+ const threadId = asThreadId("thread-steer-message-after-acceptance");
1569+ runtimeMock.state.autoPromptEcho = false;
1570+ const firstUserMessageEvent = promiseWithResolvers<unknown>();
1571+ const staleIdleEvent = promiseWithResolvers<unknown>();
1572+ const steerUserMessageEvent = promiseWithResolvers<unknown>();
1573+ const validIdleEvent = promiseWithResolvers<unknown>();
1574+ runtimeMock.state.subscribedEvents = [
1575+ firstUserMessageEvent.promise,
1576+ staleIdleEvent.promise,
1577+ steerUserMessageEvent.promise,
1578+ validIdleEvent.promise,
1579+ ];
1580+
1581+ const completedFiber = yield* adapter.streamEvents.pipe(
1582+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
1583+ Stream.runHead,
1584+ Effect.forkChild,
1585+ );
1586+ yield* adapter.startSession({
1587+ provider: ProviderDriverKind.make("opencode"),
1588+ threadId,
1589+ runtimeMode: "full-access",
1590+ });
1591+ const activeTurn = yield* adapter.sendTurn({
1592+ threadId,
1593+ input: "Start work",
1594+ modelSelection: createModelSelection(
1595+ ProviderInstanceId.make("opencode"),
1596+ "opencode/kimi-k3",
1597+ ),
1598+ });
1599+ const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID;
1600+ firstUserMessageEvent.resolve({
1601+ id: "evt-first-user-message-before-steer",
1602+ type: "message.updated",
1603+ properties: {
1604+ sessionID: "http://127.0.0.1:9999/session",
1605+ info: { id: firstMessageId, role: "user" },
1606+ },
1607+ });
1608+ yield* Effect.yieldNow;
1609+
1610+ yield* adapter.sendTurn({
1611+ threadId,
1612+ input: "Add another task",
1613+ modelSelection: createModelSelection(
1614+ ProviderInstanceId.make("opencode"),
1615+ "opencode/kimi-k3",
1616+ ),
1617+ });
1618+ const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID;
1619+
1620+ staleIdleEvent.resolve({
1621+ id: "evt-stale-idle-after-steer-acceptance",
1622+ type: "session.status",
1623+ properties: {
1624+ sessionID: "http://127.0.0.1:9999/session",
1625+ status: { type: "idle" },
1626+ },
1627+ });
1628+ yield* Effect.yieldNow;
1629+ const sessionsAfterStaleIdle = yield* adapter.listSessions();
1630+ const sessionAfterStaleIdle = sessionsAfterStaleIdle.find(
1631+ (candidate) => candidate.threadId === threadId,
1632+ );
1633+ NodeAssert.equal(sessionAfterStaleIdle?.status, "running");
1634+ NodeAssert.equal(sessionAfterStaleIdle?.activeTurnId, activeTurn.turnId);
1635+
1636+ steerUserMessageEvent.resolve({
1637+ id: "evt-steer-user-message-after-acceptance",
1638+ type: "message.updated",
1639+ properties: {
1640+ sessionID: "http://127.0.0.1:9999/session",
1641+ info: { id: steerMessageId, role: "user" },
1642+ },
1643+ });
1644+ validIdleEvent.resolve({
1645+ id: "evt-valid-idle-after-steer-message",
1646+ type: "session.status",
1647+ properties: {
1648+ sessionID: "http://127.0.0.1:9999/session",
1649+ status: { type: "idle" },
1650+ },
1651+ });
1652+
1653+ const completed = Option.getOrUndefined(
1654+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
1655+ );
1656+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
1657+ }),
1658+ );
1659+
1660+ it.effect("recovers steer admission when reconnect happens before prompt acceptance", () =>
1661+ Effect.gen(function* () {
1662+ const adapter = yield* OpenCodeAdapter;
1663+ const threadId = asThreadId("thread-steer-reconnect-before-acceptance");
1664+ const firstUserMessageEvent = promiseWithResolvers<unknown>();
1665+ const reconnectEvent = promiseWithResolvers<unknown>();
1666+ const steerStarted = promiseWithResolvers<void>();
1667+ const steerRelease = promiseWithResolvers<void>();
1668+ runtimeMock.state.autoPromptEcho = false;
1669+ runtimeMock.state.subscribedEvents = [firstUserMessageEvent.promise, reconnectEvent.promise];
1670+ runtimeMock.state.promptAsyncImplementation = async () => {
1671+ if (runtimeMock.state.promptCalls.length === 2) {
1672+ steerStarted.resolve(undefined);
1673+ await steerRelease.promise;
1674+ }
1675+ };
1676+
1677+ const completedFiber = yield* adapter.streamEvents.pipe(
1678+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
1679+ Stream.runHead,
1680+ Effect.forkChild,
1681+ );
1682+ yield* adapter.startSession({
1683+ provider: ProviderDriverKind.make("opencode"),
1684+ threadId,
1685+ runtimeMode: "full-access",
1686+ });
1687+ const activeTurn = yield* adapter.sendTurn({
1688+ threadId,
1689+ input: "Start work",
1690+ modelSelection: createModelSelection(
1691+ ProviderInstanceId.make("opencode"),
1692+ "opencode/kimi-k3",
1693+ ),
1694+ });
1695+ const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID;
1696+ firstUserMessageEvent.resolve({
1697+ id: "evt-first-user-before-reconnect-steer",
1698+ type: "message.updated",
1699+ properties: {
1700+ sessionID: "http://127.0.0.1:9999/session",
1701+ info: { id: firstMessageId, role: "user" },
1702+ },
1703+ });
1704+ yield* Effect.yieldNow;
1705+
1706+ const steerFiber = yield* adapter
1707+ .sendTurn({
1708+ threadId,
1709+ input: "Add another task",
1710+ modelSelection: createModelSelection(
1711+ ProviderInstanceId.make("opencode"),
1712+ "opencode/kimi-k3",
1713+ ),
1714+ })
1715+ .pipe(Effect.forkChild);
1716+ yield* Effect.promise(() => steerStarted.promise);
1717+ const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID;
1718+ NodeAssert.ok(steerMessageId);
1719+ runtimeMock.state.messages.push({
1720+ info: { id: steerMessageId, role: "user" },
1721+ parts: [],
1722+ });
1723+ runtimeMock.state.messageFailures = 1;
1724+ reconnectEvent.resolve({
1725+ id: "evt-reconnected-during-steer",
1726+ type: "server.connected",
1727+ properties: {},
1728+ });
1729+ yield* Effect.yieldNow;
1730+
1731+ steerRelease.resolve(undefined);
1732+ yield* Fiber.join(steerFiber);
1733+ yield* advanceTestClock(250);
1734+
1735+ const completed = Option.getOrUndefined(
1736+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
1737+ );
1738+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
1739+ NodeAssert.equal(
1740+ runtimeMock.state.messageCalls.filter((call) => call.messageID === steerMessageId).length,
1741+ 2,
1742+ );
1743+ const abortCallsAfterCompletion = runtimeMock.state.abortCalls.length;
1744+ yield* adapter.interruptTurn(threadId, activeTurn.turnId);
1745+ NodeAssert.equal(runtimeMock.state.abortCalls.length, abortCallsAfterCompletion);
1746+ }),
1747+ );
1748+
1749+ it.effect("resolves admission without a prompt echo when busy and idle still arrive", () =>
1750+ Effect.gen(function* () {
1751+ const adapter = yield* OpenCodeAdapter;
1752+ const threadId = asThreadId("thread-admission-without-echo");
1753+ const busyEvent = promiseWithResolvers<unknown>();
1754+ const idleEvent = promiseWithResolvers<unknown>();
1755+ runtimeMock.state.autoPromptEcho = false;
1756+ runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise];
1757+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
1758+ runtimeMock.state.promptAsyncImplementation = async () => {
1759+ const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined;
1760+ if (prompt?.messageID) {
1761+ runtimeMock.state.messages.push({
1762+ info: { id: prompt.messageID, role: "user" },
1763+ parts: [],
1764+ });
1765+ }
1766+ };
1767+
1768+ yield* adapter.startSession({
1769+ provider: ProviderDriverKind.make("opencode"),
1770+ threadId,
1771+ runtimeMode: "full-access",
1772+ });
1773+ const turn = yield* adapter.sendTurn({
1774+ threadId,
1775+ input: "Run without an echo event",
1776+ modelSelection: createModelSelection(
1777+ ProviderInstanceId.make("opencode"),
1778+ "opencode/kimi-k3",
1779+ ),
1780+ });
1781+ busyEvent.resolve({
1782+ id: "evt-busy-without-echo",
1783+ type: "session.status",
1784+ properties: {
1785+ sessionID: "http://127.0.0.1:9999/session",
1786+ status: { type: "busy" },
1787+ },
1788+ });
1789+ idleEvent.resolve({
1790+ id: "evt-idle-without-echo",
1791+ type: "session.status",
1792+ properties: {
1793+ sessionID: "http://127.0.0.1:9999/session",
1794+ status: { type: "idle" },
1795+ },
1796+ });
1797+ yield* advanceTestClock(1_000);
1798+
1799+ NodeAssert.equal(
1800+ runtimeMock.state.messageCalls.some(
1801+ (call) => call.messageID === runtimeMock.state.messages[0]?.info.id,
1802+ ),
1803+ true,
1804+ );
1805+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true);
1806+ const sessions = yield* adapter.listSessions();
1807+ const session = sessions.find((candidate) => candidate.threadId === threadId);
1808+ NodeAssert.equal(session?.status, "ready");
1809+ NodeAssert.equal(session?.activeTurnId, undefined);
1810+ NodeAssert.equal(turn.turnId !== undefined, true);
1811+
1812+ yield* adapter.stopSession(threadId);
1813+ }),
1814+ );
1815+
1816+ it.effect("uses polled busy status to admit output after a stopped turn", () =>
1817+ Effect.gen(function* () {
1818+ const adapter = yield* OpenCodeAdapter;
1819+ const threadId = asThreadId("thread-polled-busy-after-stop");
1820+ const firstUserMessageEvent = promiseWithResolvers<unknown>();
1821+ const assistantMessageEvent = promiseWithResolvers<unknown>();
1822+ const assistantPartEvent = promiseWithResolvers<unknown>();
1823+ const idleEvent = promiseWithResolvers<unknown>();
1824+ const busyStatusPolled = promiseWithResolvers<void>();
1825+ runtimeMock.state.autoPromptEcho = false;
1826+ runtimeMock.state.subscribedEvents = [
1827+ firstUserMessageEvent.promise,
1828+ assistantMessageEvent.promise,
1829+ assistantPartEvent.promise,
1830+ idleEvent.promise,
1831+ ];
1832+
1833+ const eventsFiber = yield* adapter.streamEvents.pipe(
1834+ Stream.filter(
1835+ (event) =>
1836+ event.threadId === threadId &&
1837+ (event.type === "content.delta" || event.type === "turn.completed"),
1838+ ),
1839+ Stream.take(2),
1840+ Stream.runCollect,
1841+ Effect.forkChild,
1842+ );
1843+ yield* adapter.startSession({
1844+ provider: ProviderDriverKind.make("opencode"),
1845+ threadId,
1846+ runtimeMode: "full-access",
1847+ });
1848+ const stoppedTurn = yield* adapter.sendTurn({
1849+ threadId,
1850+ input: "Stop this turn",
1851+ modelSelection: createModelSelection(
1852+ ProviderInstanceId.make("opencode"),
1853+ "opencode/kimi-k3",
1854+ ),
1855+ });
1856+ const stoppedMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string })
1857+ .messageID;
1858+ firstUserMessageEvent.resolve({
1859+ id: "evt-first-user-before-polled-busy-turn",
1860+ type: "message.updated",
1861+ properties: {
1862+ sessionID: "http://127.0.0.1:9999/session",
1863+ info: { id: stoppedMessageId, role: "user" },
1864+ },
1865+ });
1866+ yield* Effect.yieldNow;
1867+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
1868+
1869+ runtimeMock.state.sessionStatusCalls = 0;
1870+ runtimeMock.state.sessionStatusImplementation = async () => {
1871+ if (runtimeMock.state.sessionStatusCalls === 1) {
1872+ busyStatusPolled.resolve(undefined);
1873+ return {
1874+ data: { "http://127.0.0.1:9999/session": { type: "busy" as const } },
1875+ };
1876+ }
1877+ return { data: {} };
1878+ };
1879+ const activeTurn = yield* adapter.sendTurn({
1880+ threadId,
1881+ input: "Run without echo or busy events",
1882+ modelSelection: createModelSelection(
1883+ ProviderInstanceId.make("opencode"),
1884+ "opencode/kimi-k3",
1885+ ),
1886+ });
1887+ yield* Effect.promise(() => busyStatusPolled.promise);
1888+ yield* Effect.yieldNow;
1889+
1890+ assistantMessageEvent.resolve({
1891+ id: "evt-assistant-after-polled-busy",
1892+ type: "message.updated",
1893+ properties: {
1894+ sessionID: "http://127.0.0.1:9999/session",
1895+ info: { id: "msg-assistant-after-polled-busy", role: "assistant" },
1896+ },
1897+ });
1898+ assistantPartEvent.resolve({
1899+ id: "evt-part-after-polled-busy",
1900+ type: "message.part.updated",
1901+ properties: {
1902+ sessionID: "http://127.0.0.1:9999/session",
1903+ part: {
1904+ id: "part-after-polled-busy",
1905+ sessionID: "http://127.0.0.1:9999/session",
1906+ messageID: "msg-assistant-after-polled-busy",
1907+ type: "text",
1908+ text: "Visible output",
1909+ time: { start: 1 },
1910+ },
1911+ time: 1,
1912+ },
1913+ });
1914+ idleEvent.resolve({
1915+ id: "evt-idle-after-polled-busy",
1916+ type: "session.status",
1917+ properties: {
1918+ sessionID: "http://127.0.0.1:9999/session",
1919+ status: { type: "idle" },
1920+ },
1921+ });
1922+
1923+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
1924+ NodeAssert.deepEqual(
1925+ events.map((event) => event.type),
1926+ ["content.delta", "turn.completed"],
1927+ );
1928+ const delta = events[0];
1929+ if (delta?.type === "content.delta") {
1930+ NodeAssert.equal(delta.payload.delta, "Visible output");
1931+ }
1932+ NodeAssert.equal(events[1]?.turnId, activeTurn.turnId);
1933+ const sessions = yield* adapter.listSessions();
1934+ const session = sessions.find((candidate) => candidate.threadId === threadId);
1935+ NodeAssert.equal(session?.status, "ready");
1936+ NodeAssert.equal(session?.activeTurnId, undefined);
1937+
1938+ yield* adapter.stopSession(threadId);
1939+ }),
1940+ );
1941+
1942+ it.effect("ignores a stale admission status response after the next turn starts", () =>
1943+ Effect.gen(function* () {
1944+ const adapter = yield* OpenCodeAdapter;
1945+ const threadId = asThreadId("thread-stale-admission-status-after-stop");
1946+ const idleEvent = promiseWithResolvers<unknown>();
1947+ const userMessageEvent = promiseWithResolvers<unknown>();
1948+ const staleStatusStarted = promiseWithResolvers<void>();
1949+ const staleStatusRelease = promiseWithResolvers<void>();
1950+ const staleStatusReturned = promiseWithResolvers<void>();
1951+ const activePromptStarted = promiseWithResolvers<void>();
1952+ const activePromptRelease = promiseWithResolvers<void>();
1953+ runtimeMock.state.autoPromptEcho = false;
1954+ runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise];
1955+ runtimeMock.state.sessionStatusImplementation = async () => {
1956+ if (runtimeMock.state.sessionStatusCalls === 1) {
1957+ staleStatusStarted.resolve(undefined);
1958+ await staleStatusRelease.promise;
1959+ staleStatusReturned.resolve(undefined);
1960+ return {
1961+ data: { "http://127.0.0.1:9999/session": { type: "busy" as const } },
1962+ };
1963+ }
1964+ return { data: {} };
1965+ };
1966+ runtimeMock.state.promptAsyncImplementation = async () => {
1967+ if (runtimeMock.state.promptCalls.length === 2) {
1968+ activePromptStarted.resolve(undefined);
1969+ await activePromptRelease.promise;
1970+ }
1971+ };
1972+
1973+ const completedFiber = yield* adapter.streamEvents.pipe(
1974+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
1975+ Stream.runHead,
1976+ Effect.forkChild,
1977+ );
1978+ yield* adapter.startSession({
1979+ provider: ProviderDriverKind.make("opencode"),
1980+ threadId,
1981+ runtimeMode: "full-access",
1982+ });
1983+ const stoppedTurn = yield* adapter.sendTurn({
1984+ threadId,
1985+ input: "Stop while status is pending",
1986+ modelSelection: createModelSelection(
1987+ ProviderInstanceId.make("opencode"),
1988+ "opencode/kimi-k3",
1989+ ),
1990+ });
1991+ yield* Effect.promise(() => staleStatusStarted.promise);
1992+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
1993+
1994+ const activeTurnFiber = yield* adapter
1995+ .sendTurn({
1996+ threadId,
1997+ input: "Start while the old status is pending",
1998+ modelSelection: createModelSelection(
1999+ ProviderInstanceId.make("opencode"),
2000+ "opencode/kimi-k3",
2001+ ),
2002+ })
2003+ .pipe(Effect.forkChild);
2004+ yield* Effect.promise(() => activePromptStarted.promise);
2005+ const activeMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string })
2006+ .messageID;
2007+
2008+ staleStatusRelease.resolve(undefined);
2009+ yield* Effect.promise(() => staleStatusReturned.promise);
2010+ for (let index = 0; index < 2; index += 1) {
2011+ yield* Effect.yieldNow;
2012+ }
2013+ idleEvent.resolve({
2014+ id: "evt-idle-after-stale-admission-status",
2015+ type: "session.status",
2016+ properties: {
2017+ sessionID: "http://127.0.0.1:9999/session",
2018+ status: { type: "idle" },
2019+ },
2020+ });
2021+ for (let index = 0; index < 4; index += 1) {
2022+ yield* Effect.yieldNow;
2023+ }
2024+ NodeAssert.equal(activeTurnFiber.pollUnsafe(), undefined);
2025+ NodeAssert.equal(completedFiber.pollUnsafe(), undefined);
2026+ const sessionsBeforeAcceptance = yield* adapter.listSessions();
2027+ const sessionBeforeAcceptance = sessionsBeforeAcceptance.find(
2028+ (candidate) => candidate.threadId === threadId,
2029+ );
2030+ NodeAssert.equal(sessionBeforeAcceptance?.status, "running");
2031+ NodeAssert.notEqual(sessionBeforeAcceptance?.activeTurnId, stoppedTurn.turnId);
2032+
2033+ userMessageEvent.resolve({
2034+ id: "evt-user-after-stale-admission-status",
2035+ type: "message.updated",
2036+ properties: {
2037+ sessionID: "http://127.0.0.1:9999/session",
2038+ info: { id: activeMessageId, role: "user" },
2039+ },
2040+ });
2041+ yield* Effect.yieldNow;
2042+ activePromptRelease.resolve(undefined);
2043+ const activeTurn = yield* Fiber.join(activeTurnFiber);
2044+ yield* advanceTestClock(250);
2045+
2046+ const completed = Option.getOrUndefined(
2047+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2048+ );
2049+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
2050+ const sessions = yield* adapter.listSessions();
2051+ const session = sessions.find((candidate) => candidate.threadId === threadId);
2052+ NodeAssert.equal(session?.status, "ready");
2053+ NodeAssert.equal(session?.activeTurnId, undefined);
2054+
2055+ yield* adapter.stopSession(threadId);
2056+ }),
2057+ );
2058+
2059+ it.effect("reconciles a sole idle when the matching prompt echo arrives later", () =>
2060+ Effect.gen(function* () {
2061+ const adapter = yield* OpenCodeAdapter;
2062+ const threadId = asThreadId("thread-idle-before-delayed-echo");
2063+ const idleEvent = promiseWithResolvers<unknown>();
2064+ const userMessageEvent = promiseWithResolvers<unknown>();
2065+ runtimeMock.state.autoPromptEcho = false;
2066+ runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise];
2067+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
2068+
2069+ const completedFiber = yield* adapter.streamEvents.pipe(
2070+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2071+ Stream.runHead,
2072+ Effect.forkChild,
2073+ );
2074+ yield* adapter.startSession({
2075+ provider: ProviderDriverKind.make("opencode"),
2076+ threadId,
2077+ runtimeMode: "full-access",
2078+ });
2079+ const turn = yield* adapter.sendTurn({
2080+ threadId,
2081+ input: "Finish before the echo arrives",
2082+ modelSelection: createModelSelection(
2083+ ProviderInstanceId.make("opencode"),
2084+ "opencode/kimi-k3",
2085+ ),
2086+ });
2087+ const messageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID;
2088+ idleEvent.resolve({
2089+ id: "evt-idle-before-delayed-echo",
2090+ type: "session.status",
2091+ properties: {
2092+ sessionID: "http://127.0.0.1:9999/session",
2093+ status: { type: "idle" },
2094+ },
2095+ });
2096+ userMessageEvent.resolve({
2097+ id: "evt-delayed-matching-echo",
2098+ type: "message.updated",
2099+ properties: {
2100+ sessionID: "http://127.0.0.1:9999/session",
2101+ info: { id: messageId, role: "user" },
2102+ },
2103+ });
2104+ yield* advanceTestClock(250);
2105+
2106+ const completed = Option.getOrUndefined(
2107+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2108+ );
2109+ NodeAssert.equal(completed?.turnId, turn.turnId);
2110+ const sessions = yield* adapter.listSessions();
2111+ const session = sessions.find((candidate) => candidate.threadId === threadId);
2112+ NodeAssert.equal(session?.status, "ready");
2113+ NodeAssert.equal(session?.activeTurnId, undefined);
2114+
2115+ yield* adapter.stopSession(threadId);
2116+ }),
2117+ );
2118+
2119+ it.effect("reconciles the only idle after a stopped turn when the prompt echo is missing", () =>
2120+ Effect.gen(function* () {
2121+ const adapter = yield* OpenCodeAdapter;
2122+ const threadId = asThreadId("thread-idle-only-without-echo-after-stop");
2123+ const idleEvent = promiseWithResolvers<unknown>();
2124+ runtimeMock.state.autoPromptEcho = false;
2125+ runtimeMock.state.subscribedEvents = [idleEvent.promise];
2126+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
2127+ runtimeMock.state.promptAsyncImplementation = async () => {
2128+ const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined;
2129+ if (prompt?.messageID) {
2130+ runtimeMock.state.messages.push({
2131+ info: { id: prompt.messageID, role: "user" },
2132+ parts: [],
2133+ });
2134+ }
2135+ };
2136+
2137+ yield* adapter.startSession({
2138+ provider: ProviderDriverKind.make("opencode"),
2139+ threadId,
2140+ runtimeMode: "full-access",
2141+ });
2142+ const stoppedTurn = yield* adapter.sendTurn({
2143+ threadId,
2144+ input: "Stop this turn",
2145+ modelSelection: createModelSelection(
2146+ ProviderInstanceId.make("opencode"),
2147+ "opencode/kimi-k3",
2148+ ),
2149+ });
2150+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
2151+ const activeTurn = yield* adapter.sendTurn({
2152+ threadId,
2153+ input: "Run after the stop",
2154+ modelSelection: createModelSelection(
2155+ ProviderInstanceId.make("opencode"),
2156+ "opencode/kimi-k3",
2157+ ),
2158+ });
2159+ const activeMessageId = (
2160+ runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined
2161+ )?.messageID;
2162+ idleEvent.resolve({
2163+ id: "evt-only-idle-without-echo-after-stop",
2164+ type: "session.status",
2165+ properties: {
2166+ sessionID: "http://127.0.0.1:9999/session",
2167+ status: { type: "idle" },
2168+ },
2169+ });
2170+ yield* advanceTestClock(1_000);
2171+
2172+ NodeAssert.equal(
2173+ runtimeMock.state.messageCalls.some((call) => call.messageID === activeMessageId),
2174+ true,
2175+ );
2176+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true);
2177+ const sessions = yield* adapter.listSessions();
2178+ const session = sessions.find((candidate) => candidate.threadId === threadId);
2179+ NodeAssert.equal(session?.status, "ready");
2180+ NodeAssert.equal(session?.activeTurnId, undefined);
2181+ NodeAssert.notEqual(activeTurn.turnId, stoppedTurn.turnId);
2182+
2183+ yield* adapter.stopSession(threadId);
2184+ }),
2185+ );
2186+
2187+ it.effect("reconciles a sole idle after a stop when the exact prompt echo arrives", () =>
2188+ Effect.gen(function* () {
2189+ const adapter = yield* OpenCodeAdapter;
2190+ const threadId = asThreadId("thread-idle-before-exact-echo-after-stop");
2191+ const idleEvent = promiseWithResolvers<unknown>();
2192+ const userMessageEvent = promiseWithResolvers<unknown>();
2193+ runtimeMock.state.autoPromptEcho = false;
2194+ runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise];
2195+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
2196+
2197+ const completedFiber = yield* adapter.streamEvents.pipe(
2198+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2199+ Stream.runHead,
2200+ Effect.forkChild,
2201+ );
2202+ yield* adapter.startSession({
2203+ provider: ProviderDriverKind.make("opencode"),
2204+ threadId,
2205+ runtimeMode: "full-access",
2206+ });
2207+ const stoppedTurn = yield* adapter.sendTurn({
2208+ threadId,
2209+ input: "Stop this turn",
2210+ modelSelection: createModelSelection(
2211+ ProviderInstanceId.make("opencode"),
2212+ "opencode/kimi-k3",
2213+ ),
2214+ });
2215+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
2216+ const activeTurn = yield* adapter.sendTurn({
2217+ threadId,
2218+ input: "Run after the stop",
2219+ modelSelection: createModelSelection(
2220+ ProviderInstanceId.make("opencode"),
2221+ "opencode/kimi-k3",
2222+ ),
2223+ });
2224+ const activeMessageId = (
2225+ runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined
2226+ )?.messageID;
2227+ idleEvent.resolve({
2228+ id: "evt-only-idle-before-exact-echo-after-stop",
2229+ type: "session.status",
2230+ properties: {
2231+ sessionID: "http://127.0.0.1:9999/session",
2232+ status: { type: "idle" },
2233+ },
2234+ });
2235+ yield* Effect.yieldNow;
2236+ const sessionsBeforeEcho = yield* adapter.listSessions();
2237+ const sessionBeforeEcho = sessionsBeforeEcho.find(
2238+ (candidate) => candidate.threadId === threadId,
2239+ );
2240+ NodeAssert.equal(sessionBeforeEcho?.status, "running");
2241+ NodeAssert.equal(sessionBeforeEcho?.activeTurnId, activeTurn.turnId);
2242+
2243+ userMessageEvent.resolve({
2244+ id: "evt-exact-prompt-echo-after-stop",
2245+ type: "message.updated",
2246+ properties: {
2247+ sessionID: "http://127.0.0.1:9999/session",
2248+ info: { id: activeMessageId, role: "user" },
2249+ },
2250+ });
2251+ yield* advanceTestClock(250);
2252+
2253+ const completed = Option.getOrUndefined(
2254+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2255+ );
2256+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
2257+ const sessions = yield* adapter.listSessions();
2258+ const session = sessions.find((candidate) => candidate.threadId === threadId);
2259+ NodeAssert.equal(session?.status, "ready");
2260+ NodeAssert.equal(session?.activeTurnId, undefined);
2261+
2262+ yield* adapter.stopSession(threadId);
2263+ }),
2264+ );
2265+
2266+ it.effect("recovers an idle before the exact prompt echo while acceptance is held", () =>
2267+ Effect.gen(function* () {
2268+ const adapter = yield* OpenCodeAdapter;
2269+ const threadId = asThreadId("thread-idle-and-echo-before-acceptance-after-stop");
2270+ const idleEvent = promiseWithResolvers<unknown>();
2271+ const userMessageEvent = promiseWithResolvers<unknown>();
2272+ const activePromptStarted = promiseWithResolvers<void>();
2273+ const activePromptRelease = promiseWithResolvers<void>();
2274+ runtimeMock.state.autoPromptEcho = false;
2275+ runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise];
2276+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
2277+ runtimeMock.state.promptAsyncImplementation = async () => {
2278+ if (runtimeMock.state.promptCalls.length === 2) {
2279+ activePromptStarted.resolve(undefined);
2280+ await activePromptRelease.promise;
2281+ }
2282+ };
2283+
2284+ const completedFiber = yield* adapter.streamEvents.pipe(
2285+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2286+ Stream.runHead,
2287+ Effect.forkChild,
2288+ );
2289+ yield* adapter.startSession({
2290+ provider: ProviderDriverKind.make("opencode"),
2291+ threadId,
2292+ runtimeMode: "full-access",
2293+ });
2294+ const stoppedTurn = yield* adapter.sendTurn({
2295+ threadId,
2296+ input: "Stop this turn",
2297+ modelSelection: createModelSelection(
2298+ ProviderInstanceId.make("opencode"),
2299+ "opencode/kimi-k3",
2300+ ),
2301+ });
2302+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
2303+
2304+ const activeTurnFiber = yield* adapter
2305+ .sendTurn({
2306+ threadId,
2307+ input: "Run after the stop",
2308+ modelSelection: createModelSelection(
2309+ ProviderInstanceId.make("opencode"),
2310+ "opencode/kimi-k3",
2311+ ),
2312+ })
2313+ .pipe(Effect.forkChild);
2314+ yield* Effect.promise(() => activePromptStarted.promise);
2315+ const activeMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string })
2316+ .messageID;
2317+ idleEvent.resolve({
2318+ id: "evt-idle-before-held-prompt-acceptance",
2319+ type: "session.status",
2320+ properties: {
2321+ sessionID: "http://127.0.0.1:9999/session",
2322+ status: { type: "idle" },
2323+ },
2324+ });
2325+ yield* Effect.yieldNow;
2326+ userMessageEvent.resolve({
2327+ id: "evt-exact-echo-before-held-prompt-acceptance",
2328+ type: "message.updated",
2329+ properties: {
2330+ sessionID: "http://127.0.0.1:9999/session",
2331+ info: { id: activeMessageId, role: "user" },
2332+ },
2333+ });
2334+ yield* Effect.yieldNow;
2335+ NodeAssert.equal(activeTurnFiber.pollUnsafe(), undefined);
2336+
2337+ activePromptRelease.resolve(undefined);
2338+ const activeTurn = yield* Fiber.join(activeTurnFiber);
2339+ yield* advanceTestClock(250);
2340+
2341+ const completed = Option.getOrUndefined(
2342+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2343+ );
2344+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
2345+ const sessions = yield* adapter.listSessions();
2346+ const session = sessions.find((candidate) => candidate.threadId === threadId);
2347+ NodeAssert.equal(session?.status, "ready");
2348+ NodeAssert.equal(session?.activeTurnId, undefined);
2349+
2350+ yield* adapter.stopSession(threadId);
2351+ }),
2352+ );
2353+
2354+ it.effect("restores idle reconciliation after a steer prompt fails", () =>
2355+ Effect.gen(function* () {
2356+ const adapter = yield* OpenCodeAdapter;
2357+ const threadId = asThreadId("thread-failed-steer-idle");
2358+ const busyEvent = promiseWithResolvers<unknown>();
2359+ const idleEvent = promiseWithResolvers<unknown>();
2360+ const firstStatusStarted = promiseWithResolvers<void>();
2361+ const firstStatusRelease = promiseWithResolvers<void>();
2362+ const steerStarted = promiseWithResolvers<void>();
2363+ const steerRelease = promiseWithResolvers<void>();
2364+ runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise];
2365+ runtimeMock.state.sessionStatusImplementation = async () => {
2366+ if (runtimeMock.state.sessionStatusCalls === 1) {
2367+ firstStatusStarted.resolve(undefined);
2368+ await firstStatusRelease.promise;
2369+ }
2370+ return { data: {} };
2371+ };
2372+ runtimeMock.state.promptAsyncImplementation = async () => {
2373+ if (runtimeMock.state.promptCalls.length === 3) {
2374+ steerStarted.resolve(undefined);
2375+ await steerRelease.promise;
2376+ throw new Error("steer failed");
2377+ }
2378+ };
2379+
2380+ const completedFiber = yield* adapter.streamEvents.pipe(
2381+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2382+ Stream.runHead,
2383+ Effect.forkChild,
2384+ );
2385+ yield* adapter.startSession({
2386+ provider: ProviderDriverKind.make("opencode"),
2387+ threadId,
2388+ runtimeMode: "full-access",
2389+ });
2390+ const stoppedTurn = yield* adapter.sendTurn({
2391+ threadId,
2392+ input: "Stop this turn",
2393+ modelSelection: createModelSelection(
2394+ ProviderInstanceId.make("opencode"),
2395+ "opencode/kimi-k3",
2396+ ),
2397+ });
2398+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
2399+ const activeTurn = yield* adapter.sendTurn({
2400+ threadId,
2401+ input: "Start the next turn",
2402+ modelSelection: createModelSelection(
2403+ ProviderInstanceId.make("opencode"),
2404+ "opencode/kimi-k3",
2405+ ),
2406+ });
2407+ busyEvent.resolve({
2408+ id: "evt-failed-steer-busy",
2409+ type: "session.status",
2410+ properties: {
2411+ sessionID: "http://127.0.0.1:9999/session",
2412+ status: { type: "busy" },
2413+ },
2414+ });
2415+ idleEvent.resolve({
2416+ id: "evt-failed-steer-idle",
2417+ type: "session.status",
2418+ properties: {
2419+ sessionID: "http://127.0.0.1:9999/session",
2420+ status: { type: "idle" },
2421+ },
2422+ });
2423+ yield* Effect.promise(() => firstStatusStarted.promise);
2424+ const steerFiber = yield* Effect.exit(
2425+ adapter.sendTurn({
2426+ threadId,
2427+ input: "This steer fails",
2428+ modelSelection: createModelSelection(
2429+ ProviderInstanceId.make("opencode"),
2430+ "opencode/kimi-k3",
2431+ ),
2432+ }),
2433+ ).pipe(Effect.forkChild);
2434+ yield* Effect.promise(() => steerStarted.promise);
2435+ firstStatusRelease.resolve(undefined);
2436+ steerRelease.resolve(undefined);
2437+ const steerExit = yield* Fiber.join(steerFiber);
2438+ NodeAssert.equal(Exit.isFailure(steerExit), true);
2439+
2440+ const completed = Option.getOrUndefined(
2441+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2442+ );
2443+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
2444+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 2);
2445+ }),
2446+ );
2447+
2448+ it.effect("accepts the only idle event after a steer fails before creating its message", () =>
2449+ Effect.gen(function* () {
2450+ const adapter = yield* OpenCodeAdapter;
2451+ const threadId = asThreadId("thread-failed-steer-admission-idle");
2452+ const idleEvent = promiseWithResolvers<unknown>();
2453+ const steerStarted = promiseWithResolvers<void>();
2454+ const steerRelease = promiseWithResolvers<void>();
2455+ runtimeMock.state.subscribedEvents = [idleEvent.promise];
2456+ runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} });
2457+ runtimeMock.state.promptAsyncImplementation = async () => {
2458+ if (runtimeMock.state.promptCalls.length === 2) {
2459+ steerStarted.resolve(undefined);
2460+ await steerRelease.promise;
2461+ throw new Error("steer failed before message creation");
2462+ }
2463+ };
2464+
2465+ const completedFiber = yield* adapter.streamEvents.pipe(
2466+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2467+ Stream.runHead,
2468+ Effect.forkChild,
2469+ );
2470+ yield* adapter.startSession({
2471+ provider: ProviderDriverKind.make("opencode"),
2472+ threadId,
2473+ runtimeMode: "full-access",
2474+ });
2475+ const activeTurn = yield* adapter.sendTurn({
2476+ threadId,
2477+ input: "Start work",
2478+ modelSelection: createModelSelection(
2479+ ProviderInstanceId.make("opencode"),
2480+ "opencode/kimi-k3",
2481+ ),
2482+ });
2483+ const steerFiber = yield* Effect.exit(
2484+ adapter.sendTurn({
2485+ threadId,
2486+ input: "This steer fails",
2487+ modelSelection: createModelSelection(
2488+ ProviderInstanceId.make("opencode"),
2489+ "opencode/kimi-k3",
2490+ ),
2491+ }),
2492+ ).pipe(Effect.forkChild);
2493+ yield* Effect.promise(() => steerStarted.promise);
2494+ idleEvent.resolve({
2495+ id: "evt-idle-during-failed-admission",
2496+ type: "session.status",
2497+ properties: {
2498+ sessionID: "http://127.0.0.1:9999/session",
2499+ status: { type: "idle" },
2500+ },
2501+ });
2502+ steerRelease.resolve(undefined);
2503+ NodeAssert.equal(Exit.isFailure(yield* Fiber.join(steerFiber)), true);
2504+
2505+ const completed = Option.getOrUndefined(
2506+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2507+ );
2508+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
2509+ }),
2510+ );
2511+
2512+ it.effect("routes child-session approval requests and replies through the parent thread", () =>
2513+ Effect.gen(function* () {
2514+ const adapter = yield* OpenCodeAdapter;
2515+ const threadId = asThreadId("thread-child-approval");
2516+ const permissionReply = promiseWithResolvers<unknown>();
2517+ runtimeMock.state.subscribedEvents = [
2518+ {
2519+ id: "evt-child-created",
2520+ type: "session.created",
2521+ properties: {
2522+ sessionID: "ses_child",
2523+ info: {
2524+ id: "ses_child",
2525+ parentID: "http://127.0.0.1:9999/session",
2526+ title: "Child session",
2527+ },
2528+ },
2529+ },
2530+ {
2531+ id: "evt-child-permission",
2532+ type: "permission.asked",
2533+ properties: {
2534+ id: "per_child",
2535+ sessionID: "ses_child",
2536+ permission: "external_directory",
2537+ patterns: ["/tmp/external/*"],
2538+ metadata: { source: "child" },
2539+ always: ["/tmp/external/*"],
2540+ },
2541+ },
2542+ permissionReply.promise,
2543+ ];
2544+
2545+ const openedEventsFiber = yield* adapter.streamEvents.pipe(
2546+ Stream.filter((event) => event.threadId === threadId),
2547+ Stream.take(3),
2548+ Stream.runCollect,
2549+ Effect.forkChild,
2550+ );
2551+ yield* adapter.startSession({
2552+ provider: ProviderDriverKind.make("opencode"),
2553+ threadId,
2554+ runtimeMode: "approval-required",
2555+ });
2556+
2557+ const openedEvents = Array.from(
2558+ yield* Fiber.join(openedEventsFiber).pipe(Effect.timeout("1 second")),
2559+ );
2560+ const opened = openedEvents.find((event) => event.type === "request.opened");
2561+ NodeAssert.ok(opened);
2562+ NodeAssert.equal(opened.requestId, "per_child");
2563+ NodeAssert.equal(
2564+ opened.raw?.source === "opencode.sdk.event" &&
2565+ typeof opened.raw.payload === "object" &&
2566+ opened.raw.payload !== null &&
2567+ "properties" in opened.raw.payload
2568+ ? (opened.raw.payload.properties as { sessionID?: string }).sessionID
2569+ : undefined,
2570+ "ses_child",
2571+ );
2572+
2573+ yield* adapter.respondToRequest(
2574+ threadId,
2575+ ApprovalRequestId.make("per_child"),
2576+ "acceptForSession",
2577+ );
2578+ NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2579+ { requestID: "per_child", reply: "always" },
2580+ ]);
2581+
2582+ const resolvedEventFiber = yield* adapter.streamEvents.pipe(
2583+ Stream.filter((event) => event.threadId === threadId),
2584+ Stream.take(1),
2585+ Stream.runHead,
2586+ Effect.forkChild,
2587+ );
2588+ permissionReply.resolve({
2589+ id: "evt-child-permission-replied",
2590+ type: "permission.replied",
2591+ properties: {
2592+ sessionID: "ses_child",
2593+ requestID: "per_child",
2594+ reply: "always",
2595+ },
2596+ });
2597+ const resolved = yield* Fiber.join(resolvedEventFiber).pipe(Effect.timeout("1 second"));
2598+ NodeAssert.equal(Option.getOrUndefined(resolved)?.type, "request.resolved");
2599+
2600+ yield* adapter.stopSession(threadId);
2601+ }),
2602+ );
2603+
2604+ it.effect("routes child-session questions and replies through the parent thread", () =>
2605+ Effect.gen(function* () {
2606+ const adapter = yield* OpenCodeAdapter;
2607+ const threadId = asThreadId("thread-child-question");
2608+ const questionReply = promiseWithResolvers<unknown>();
2609+ runtimeMock.state.subscribedEvents = [
2610+ {
2611+ id: "evt-child-created",
2612+ type: "session.created",
2613+ properties: {
2614+ sessionID: "ses_child_question",
2615+ info: {
2616+ id: "ses_child_question",
2617+ parentID: "http://127.0.0.1:9999/session",
2618+ title: "Child session",
2619+ },
2620+ },
2621+ },
2622+ {
2623+ id: "evt-child-question",
2624+ type: "question.asked",
2625+ properties: {
2626+ id: "que_child",
2627+ sessionID: "ses_child_question",
2628+ questions: [
2629+ {
2630+ header: "Scope",
2631+ question: "Which scope should OpenCode use?",
2632+ options: [{ label: "Workspace", description: "Use this workspace." }],
2633+ },
2634+ ],
2635+ },
2636+ },
2637+ questionReply.promise,
2638+ ];
2639+
2640+ const requestedEventsFiber = yield* adapter.streamEvents.pipe(
2641+ Stream.filter((event) => event.threadId === threadId),
2642+ Stream.take(3),
2643+ Stream.runCollect,
2644+ Effect.forkChild,
2645+ );
2646+ yield* adapter.startSession({
2647+ provider: ProviderDriverKind.make("opencode"),
2648+ threadId,
2649+ runtimeMode: "approval-required",
2650+ });
2651+
2652+ const requestedEvents = Array.from(
2653+ yield* Fiber.join(requestedEventsFiber).pipe(Effect.timeout("1 second")),
2654+ );
2655+ const requested = requestedEvents.find((event) => event.type === "user-input.requested");
2656+ NodeAssert.ok(requested);
2657+ NodeAssert.equal(requested.requestId, "que_child");
2658+
2659+ yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("que_child"), {
2660+ Scope: "Workspace",
2661+ });
2662+ NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, [
2663+ { requestID: "que_child", answers: [["Workspace"]] },
2664+ ]);
2665+
2666+ const resolvedEventFiber = yield* adapter.streamEvents.pipe(
2667+ Stream.filter((event) => event.threadId === threadId),
2668+ Stream.take(1),
2669+ Stream.runHead,
2670+ Effect.forkChild,
2671+ );
2672+ questionReply.resolve({
2673+ id: "evt-child-question-replied",
2674+ type: "question.replied",
2675+ properties: {
2676+ sessionID: "ses_child_question",
2677+ requestID: "que_child",
2678+ answers: [["Workspace"]],
2679+ },
2680+ });
2681+ const resolved = yield* Fiber.join(resolvedEventFiber).pipe(Effect.timeout("1 second"));
2682+ NodeAssert.equal(Option.getOrUndefined(resolved)?.type, "user-input.resolved");
2683+
2684+ yield* adapter.stopSession(threadId);
2685+ }),
2686+ );
2687+
2688+ it.effect("recovers pending requests from existing nested child sessions on resume", () =>
2689+ Effect.gen(function* () {
2690+ const adapter = yield* OpenCodeAdapter;
2691+ const threadId = asThreadId("thread-resume-child-requests");
2692+ runtimeMock.state.sessionParentById.set("ses_child", "ses_parent");
2693+ runtimeMock.state.sessionParentById.set("ses_nested", "ses_child");
2694+ runtimeMock.state.pendingPermissions = [permissionRequest("per_existing", "ses_nested")];
2695+ runtimeMock.state.pendingQuestions = [questionRequest("que_existing", "ses_child")];
2696+
2697+ const requestsFiber = yield* adapter.streamEvents.pipe(
2698+ Stream.filter(
2699+ (event) =>
2700+ event.threadId === threadId &&
2701+ (event.type === "request.opened" || event.type === "user-input.requested"),
2702+ ),
2703+ Stream.take(2),
2704+ Stream.runCollect,
2705+ Effect.forkChild,
2706+ );
2707+ yield* adapter.startSession({
2708+ provider: ProviderDriverKind.make("opencode"),
2709+ threadId,
2710+ runtimeMode: "approval-required",
2711+ resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" },
2712+ });
2713+
2714+ const requests = Array.from(
2715+ yield* Fiber.join(requestsFiber).pipe(Effect.timeout("1 second")),
2716+ );
2717+ NodeAssert.deepEqual(requests.map((event) => [event.type, event.requestId]).sort(), [
2718+ ["request.opened", "per_existing"],
2719+ ["user-input.requested", "que_existing"],
2720+ ]);
2721+ yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_existing"), "accept");
2722+ yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("que_existing"), {
2723+ Scope: "Workspace",
2724+ });
2725+ NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2726+ { requestID: "per_existing", reply: "once" },
2727+ ]);
2728+ NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, [
2729+ { requestID: "que_existing", answers: [["Workspace"]] },
2730+ ]);
2731+ }),
2732+ );
2733+
2734+ it.effect("retries ancestry for one live child request after a transient failure", () =>
2735+ Effect.gen(function* () {
2736+ const adapter = yield* OpenCodeAdapter;
2737+ const threadId = asThreadId("thread-child-request-ancestry-retry");
2738+ const parentId = "http://127.0.0.1:9999/session";
2739+ const ancestryAttempted = promiseWithResolvers<void>();
2740+ runtimeMock.state.sessionParentById.set("ses_existing_child", parentId);
2741+ runtimeMock.state.transientErrorSessionIds.add("ses_existing_child");
2742+ runtimeMock.state.sessionGetObserved = (sessionID) => {
2743+ if (sessionID === "ses_existing_child") {
2744+ ancestryAttempted.resolve(undefined);
2745+ }
2746+ };
2747+ runtimeMock.state.subscribedEvents = [
2748+ {
2749+ id: "evt-existing-child-permission",
2750+ type: "permission.asked",
2751+ properties: permissionRequest("per_retry", "ses_existing_child"),
2752+ },
2753+ ];
2754+
2755+ const eventsFiber = yield* adapter.streamEvents.pipe(
2756+ Stream.filter(
2757+ (event) =>
2758+ event.threadId === threadId &&
2759+ (event.type === "runtime.warning" || event.type === "request.opened"),
2760+ ),
2761+ Stream.take(2),
2762+ Stream.runCollect,
2763+ Effect.forkChild,
2764+ );
2765+ yield* adapter.startSession({
2766+ provider: ProviderDriverKind.make("opencode"),
2767+ threadId,
2768+ runtimeMode: "approval-required",
2769+ });
2770+ yield* Effect.promise(() => ancestryAttempted.promise);
2771+ runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child");
2772+ yield* advanceTestClock(250);
2773+
2774+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
2775+ NodeAssert.deepEqual(
2776+ events.map((event) => event.type),
2777+ ["runtime.warning", "request.opened"],
2778+ );
2779+ yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept");
2780+ }),
2781+ );
2782+
2783+ it.effect("does not resurrect a recovered child request after its live reply", () =>
2784+ Effect.gen(function* () {
2785+ const adapter = yield* OpenCodeAdapter;
2786+ const threadId = asThreadId("thread-stale-child-request-recovery");
2787+ const listStarted = promiseWithResolvers<void>();
2788+ const listRelease = promiseWithResolvers<void>();
2789+ const stale = permissionRequest("per_stale", "ses_existing_child");
2790+ runtimeMock.state.sessionParentById.set("ses_existing_child", "ses_parent");
2791+ runtimeMock.state.permissionListImplementation = async () => {
2792+ listStarted.resolve(undefined);
2793+ await listRelease.promise;
2794+ return [stale];
2795+ };
2796+ runtimeMock.state.subscribedEvents = [
2797+ {
2798+ id: "evt-stale-child-replied",
2799+ type: "permission.replied",
2800+ properties: {
2801+ sessionID: "ses_existing_child",
2802+ requestID: stale.id,
2803+ reply: "once",
2804+ },
2805+ },
2806+ ];
2807+
2808+ const resolvedFiber = yield* adapter.streamEvents.pipe(
2809+ Stream.filter(
2810+ (event) =>
2811+ event.threadId === threadId &&
2812+ (event.type === "request.opened" || event.type === "request.resolved"),
2813+ ),
2814+ Stream.runHead,
2815+ Effect.forkChild,
2816+ );
2817+
2818+ yield* adapter.startSession({
2819+ provider: ProviderDriverKind.make("opencode"),
2820+ threadId,
2821+ runtimeMode: "approval-required",
2822+ resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" },
2823+ });
2824+ yield* Effect.promise(() => listStarted.promise);
2825+ const resolved = Option.getOrUndefined(
2826+ yield* Fiber.join(resolvedFiber).pipe(Effect.timeout("1 second")),
2827+ );
2828+ NodeAssert.equal(resolved?.type, "request.resolved");
2829+ listRelease.resolve(undefined);
2830+ yield* Effect.yieldNow;
2831+
2832+ const response = yield* Effect.exit(
2833+ adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"),
2834+ );
2835+ NodeAssert.equal(Exit.isFailure(response), true);
2836+ }),
2837+ );
2838+
2839+ it.effect("lets a child reply supersede an ask while ancestry lookup is retrying", () =>
2840+ Effect.gen(function* () {
2841+ const adapter = yield* OpenCodeAdapter;
2842+ const threadId = asThreadId("thread-child-terminal-during-ancestry");
2843+ const ancestryAttempted = promiseWithResolvers<void>();
2844+ const childId = "ses_terminal_child";
2845+ const request = permissionRequest("per_terminal", childId);
2846+ runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session");
2847+ runtimeMock.state.transientErrorSessionIds.add(childId);
2848+ runtimeMock.state.sessionGetObserved = (sessionID) => {
2849+ if (sessionID === childId) {
2850+ ancestryAttempted.resolve(undefined);
2851+ }
2852+ };
2853+ runtimeMock.state.subscribedEvents = [
2854+ { id: "evt-terminal-ask", type: "permission.asked", properties: request },
2855+ {
2856+ id: "evt-terminal-reply",
2857+ type: "permission.replied",
2858+ properties: { sessionID: childId, requestID: request.id, reply: "once" },
2859+ },
2860+ ];
2861+
2862+ const terminalFiber = yield* adapter.streamEvents.pipe(
2863+ Stream.filter(
2864+ (event) =>
2865+ event.threadId === threadId &&
2866+ (event.type === "request.opened" || event.type === "request.resolved"),
2867+ ),
2868+ Stream.runHead,
2869+ Effect.forkChild,
2870+ );
2871+ yield* adapter.startSession({
2872+ provider: ProviderDriverKind.make("opencode"),
2873+ threadId,
2874+ runtimeMode: "approval-required",
2875+ });
2876+ yield* Effect.promise(() => ancestryAttempted.promise);
2877+ runtimeMock.state.transientErrorSessionIds.delete(childId);
2878+ yield* advanceTestClock(250);
2879+
2880+ const terminal = Option.getOrUndefined(
2881+ yield* Fiber.join(terminalFiber).pipe(Effect.timeout("1 second")),
2882+ );
2883+ NodeAssert.equal(terminal?.type, "request.resolved");
2884+ const response = yield* Effect.exit(
2885+ adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"),
2886+ );
2887+ NodeAssert.equal(Exit.isFailure(response), true);
2888+ }),
2889+ );
2890+
2891+ it.effect("caps terminal ancestry retries after a request finishes", () =>
2892+ Effect.gen(function* () {
2893+ const adapter = yield* OpenCodeAdapter;
2894+ const threadId = asThreadId("thread-terminal-ancestry-retry-cap");
2895+ const childId = "ses_terminal_retry_cap_child";
2896+ const request = permissionRequest("per_terminal_retry_cap", childId);
2897+ const terminalEvent = promiseWithResolvers<unknown>();
2898+ const askedAttempted = promiseWithResolvers<void>();
2899+ const terminalAttempted = promiseWithResolvers<void>();
2900+ let terminalReleased = false;
2901+ runtimeMock.state.transientErrorSessionIds.add(childId);
2902+ runtimeMock.state.sessionGetObserved = (sessionID) => {
2903+ if (sessionID !== childId) {
2904+ return;
2905+ }
2906+ if (terminalReleased) {
2907+ terminalAttempted.resolve(undefined);
2908+ } else {
2909+ askedAttempted.resolve(undefined);
2910+ }
2911+ };
2912+ runtimeMock.state.subscribedEvents = [
2913+ { id: "evt-terminal-cap-ask", type: "permission.asked", properties: request },
2914+ terminalEvent.promise,
2915+ ];
2916+
2917+ const unexpectedRequestFiber = yield* adapter.streamEvents.pipe(
2918+ Stream.filter(
2919+ (event) =>
2920+ event.threadId === threadId &&
2921+ (event.type === "request.opened" || event.type === "request.resolved"),
2922+ ),
2923+ Stream.runHead,
2924+ Effect.forkChild,
2925+ );
2926+ yield* adapter.startSession({
2927+ provider: ProviderDriverKind.make("opencode"),
2928+ threadId,
2929+ runtimeMode: "approval-required",
2930+ });
2931+ yield* Effect.promise(() => askedAttempted.promise);
2932+ const askedAttempts = runtimeMock.state.sessionGetIds.filter(
2933+ (sessionID) => sessionID === childId,
2934+ ).length;
2935+
2936+ terminalReleased = true;
2937+ terminalEvent.resolve({
2938+ id: "evt-terminal-cap-reply",
2939+ type: "permission.replied",
2940+ properties: { sessionID: childId, requestID: request.id, reply: "once" },
2941+ });
2942+ yield* Effect.promise(() => terminalAttempted.promise);
2943+ yield* advanceTestClock(10_000);
2944+ const callsAfterCap = runtimeMock.state.sessionGetIds.filter(
2945+ (sessionID) => sessionID === childId,
2946+ ).length;
2947+ NodeAssert.equal(callsAfterCap - askedAttempts, 5);
2948+
2949+ yield* advanceTestClock(30_000);
2950+ NodeAssert.equal(
2951+ runtimeMock.state.sessionGetIds.filter((sessionID) => sessionID === childId).length,
2952+ callsAfterCap,
2953+ );
2954+ NodeAssert.equal(unexpectedRequestFiber.pollUnsafe(), undefined);
2955+ yield* Fiber.interrupt(unexpectedRequestFiber);
2956+ yield* adapter.stopSession(threadId);
2957+ }),
2958+ );
2959+
2960+ it.effect("reruns recovery when the event stream connects during the startup snapshot", () =>
2961+ Effect.gen(function* () {
2962+ const adapter = yield* OpenCodeAdapter;
2963+ const threadId = asThreadId("thread-connected-recovery-rerun");
2964+ const firstListStarted = promiseWithResolvers<void>();
2965+ const firstListRelease = promiseWithResolvers<void>();
2966+ const pending = permissionRequest("per_connected", "ses_existing_child");
2967+ runtimeMock.state.sessionParentById.set("ses_existing_child", "ses_parent");
2968+ runtimeMock.state.permissionListImplementation = async () => {
2969+ if (runtimeMock.state.permissionListCalls === 1) {
2970+ firstListStarted.resolve(undefined);
2971+ await firstListRelease.promise;
2972+ return [];
2973+ }
2974+ return [pending];
2975+ };
2976+ runtimeMock.state.subscribedEvents = [
2977+ { id: "evt-connected", type: "server.connected", properties: {} },
2978+ ];
2979+
2980+ const openedFiber = yield* adapter.streamEvents.pipe(
2981+ Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
2982+ Stream.runHead,
2983+ Effect.forkChild,
2984+ );
2985+ yield* adapter.startSession({
2986+ provider: ProviderDriverKind.make("opencode"),
2987+ threadId,
2988+ runtimeMode: "approval-required",
2989+ resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" },
2990+ });
2991+ yield* Effect.promise(() => firstListStarted.promise);
2992+ firstListRelease.resolve(undefined);
2993+
2994+ const opened = Option.getOrUndefined(
2995+ yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")),
2996+ );
2997+ NodeAssert.equal(opened?.requestId, pending.id);
2998+ NodeAssert.equal(runtimeMock.state.permissionListCalls, 2);
2999+ }),
3000+ );
3001+
3002+ it.effect("keeps an idle event from completing a turn while its abort request is pending", () =>
3003+ Effect.gen(function* () {
3004+ const adapter = yield* OpenCodeAdapter;
3005+ const threadId = asThreadId("thread-interrupt-idle-race");
3006+ const idleEvent = promiseWithResolvers<unknown>();
3007+ const abortStarted = promiseWithResolvers<void>();
3008+ const abortRelease = promiseWithResolvers<void>();
3009+ runtimeMock.state.subscribedEvents = [idleEvent.promise];
3010+ runtimeMock.state.abortImplementation = async () => {
3011+ abortStarted.resolve(undefined);
3012+ await abortRelease.promise;
3013+ };
3014+
3015+ const eventsFiber = yield* adapter.streamEvents.pipe(
3016+ Stream.filter((event) => event.threadId === threadId),
3017+ Stream.take(4),
3018+ Stream.runCollect,
3019+ Effect.forkChild,
3020+ );
3021+ yield* adapter.startSession({
3022+ provider: ProviderDriverKind.make("opencode"),
3023+ threadId,
3024+ runtimeMode: "full-access",
3025+ });
3026+ const turn = yield* adapter.sendTurn({
3027+ threadId,
3028+ input: "Keep working",
3029+ modelSelection: createModelSelection(
3030+ ProviderInstanceId.make("opencode"),
3031+ "opencode/kimi-k3",
3032+ ),
3033+ });
3034+
3035+ const interruptFiber = yield* adapter
3036+ .interruptTurn(threadId, turn.turnId)
3037+ .pipe(Effect.forkChild);
3038+ yield* Effect.promise(() => abortStarted.promise);
3039+ idleEvent.resolve({
3040+ id: "evt-idle-after-stop",
3041+ type: "session.status",
3042+ properties: {
3043+ sessionID: "http://127.0.0.1:9999/session",
3044+ status: { type: "idle" },
3045+ },
3046+ });
3047+ yield* Effect.yieldNow;
3048+ abortRelease.resolve(undefined);
3049+ yield* Fiber.join(interruptFiber);
3050+
3051+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3052+ NodeAssert.deepEqual(
3053+ events
3054+ .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted")
3055+ .map((event) => event.type),
3056+ ["turn.aborted"],
3057+ );
3058+ const sessions = yield* adapter.listSessions();
3059+ const session = sessions.find((candidate) => candidate.threadId === threadId);
3060+ NodeAssert.equal(session?.status, "ready");
3061+ NodeAssert.equal(session?.activeTurnId, undefined);
3062+
3063+ yield* adapter.stopSession(threadId);
3064+ }),
3065+ );
3066+
3067+ it.effect("ignores late busy and idle status after an interrupted turn", () =>
3068+ Effect.gen(function* () {
3069+ const adapter = yield* OpenCodeAdapter;
3070+ const threadId = asThreadId("thread-late-status-after-interrupt");
3071+ const lateBusy = promiseWithResolvers<unknown>();
3072+ const lateIdle = promiseWithResolvers<unknown>();
3073+ runtimeMock.state.subscribedEvents = [lateBusy.promise, lateIdle.promise];
3074+
3075+ const eventsFiber = yield* adapter.streamEvents.pipe(
3076+ Stream.filter((event) => event.threadId === threadId),
3077+ Stream.take(5),
3078+ Stream.runCollect,
3079+ Effect.forkChild,
3080+ );
3081+ yield* adapter.startSession({
3082+ provider: ProviderDriverKind.make("opencode"),
3083+ threadId,
3084+ runtimeMode: "full-access",
3085+ });
3086+ const turn = yield* adapter.sendTurn({
3087+ threadId,
3088+ input: "Stop this turn",
3089+ modelSelection: createModelSelection(
3090+ ProviderInstanceId.make("opencode"),
3091+ "opencode/kimi-k3",
3092+ ),
3093+ });
3094+ yield* adapter.interruptTurn(threadId, turn.turnId);
3095+
3096+ lateBusy.resolve({
3097+ id: "evt-late-busy-after-interrupt",
3098+ type: "session.status",
3099+ properties: {
3100+ sessionID: "http://127.0.0.1:9999/session",
3101+ status: { type: "busy" },
3102+ },
3103+ });
3104+ lateIdle.resolve({
3105+ id: "evt-late-idle-after-interrupt",
3106+ type: "session.status",
3107+ properties: {
3108+ sessionID: "http://127.0.0.1:9999/session",
3109+ status: { type: "idle" },
3110+ },
3111+ });
3112+ yield* Effect.yieldNow;
3113+
3114+ const sessions = yield* adapter.listSessions();
3115+ const session = sessions.find((candidate) => candidate.threadId === threadId);
3116+ NodeAssert.equal(session?.status, "ready");
3117+ NodeAssert.equal(session?.activeTurnId, undefined);
3118+
3119+ yield* adapter.stopSession(threadId);
3120+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3121+ NodeAssert.deepEqual(
3122+ events
3123+ .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted")
3124+ .map((event) => event.type),
3125+ ["turn.aborted"],
3126+ );
3127+ }),
3128+ );
3129+
3130+ it.effect("rejects a prompt accepted after its turn was interrupted", () =>
3131+ Effect.gen(function* () {
3132+ const adapter = yield* OpenCodeAdapter;
3133+ const threadId = asThreadId("thread-interrupt-during-prompt-admission");
3134+ const promptStarted = promiseWithResolvers<void>();
3135+ const promptRelease = promiseWithResolvers<void>();
3136+ const lateBusy = promiseWithResolvers<unknown>();
3137+ const lateMessage = promiseWithResolvers<unknown>();
3138+ const latePart = promiseWithResolvers<unknown>();
3139+ const lateIdle = promiseWithResolvers<unknown>();
3140+ const marker = promiseWithResolvers<unknown>();
3141+ runtimeMock.state.autoPromptEcho = false;
3142+ runtimeMock.state.subscribedEvents = [
3143+ lateBusy.promise,
3144+ lateMessage.promise,
3145+ latePart.promise,
3146+ lateIdle.promise,
3147+ marker.promise,
3148+ ];
3149+ runtimeMock.state.promptAsyncImplementation = async () => {
3150+ if (runtimeMock.state.promptCalls.length === 1) {
3151+ promptStarted.resolve(undefined);
3152+ await promptRelease.promise;
3153+ }
3154+ };
3155+
3156+ const firstLateOutput = yield* adapter.streamEvents.pipe(
3157+ Stream.filter(
3158+ (event) =>
3159+ event.threadId === threadId &&
3160+ (event.type === "content.delta" || event.type === "thread.metadata.updated"),
3161+ ),
3162+ Stream.runHead,
3163+ Effect.forkChild,
3164+ );
3165+ yield* adapter.startSession({
3166+ provider: ProviderDriverKind.make("opencode"),
3167+ threadId,
3168+ runtimeMode: "full-access",
3169+ });
3170+ const sendFiber = yield* adapter
3171+ .sendTurn({
3172+ threadId,
3173+ input: "This request is still pending",
3174+ modelSelection: createModelSelection(
3175+ ProviderInstanceId.make("opencode"),
3176+ "opencode/kimi-k3",
3177+ ),
3178+ })
3179+ .pipe(Effect.exit, Effect.forkChild);
3180+ yield* Effect.promise(() => promptStarted.promise);
3181+
3182+ yield* adapter.interruptTurn(threadId);
3183+ NodeAssert.equal(runtimeMock.state.abortCalls.length, 1);
3184+ const sessionsAfterStop = yield* adapter.listSessions();
3185+ const sessionAfterStop = sessionsAfterStop.find(
3186+ (candidate) => candidate.threadId === threadId,
3187+ );
3188+ NodeAssert.equal(sessionAfterStop?.status, "ready");
3189+ NodeAssert.equal(sessionAfterStop?.activeTurnId, undefined);
3190+
3191+ promptRelease.resolve(undefined);
3192+ const sendResult = yield* Fiber.join(sendFiber);
3193+ lateBusy.resolve({
3194+ id: "evt-busy-after-late-prompt-acceptance",
3195+ type: "session.status",
3196+ properties: {
3197+ sessionID: "http://127.0.0.1:9999/session",
3198+ status: { type: "busy" },
3199+ },
3200+ });
3201+ lateMessage.resolve({
3202+ id: "evt-assistant-after-late-prompt-acceptance",
3203+ type: "message.updated",
3204+ properties: {
3205+ sessionID: "http://127.0.0.1:9999/session",
3206+ info: { id: "msg-late-assistant", role: "assistant" },
3207+ },
3208+ });
3209+ latePart.resolve({
3210+ id: "evt-part-after-late-prompt-acceptance",
3211+ type: "message.part.updated",
3212+ properties: {
3213+ sessionID: "http://127.0.0.1:9999/session",
3214+ part: {
3215+ id: "part-late-assistant",
3216+ sessionID: "http://127.0.0.1:9999/session",
3217+ messageID: "msg-late-assistant",
3218+ type: "text",
3219+ text: "Late output",
3220+ time: { start: 1 },
3221+ },
3222+ time: 1,
3223+ },
3224+ });
3225+ lateIdle.resolve({
3226+ id: "evt-idle-after-late-prompt-acceptance",
3227+ type: "session.status",
3228+ properties: {
3229+ sessionID: "http://127.0.0.1:9999/session",
3230+ status: { type: "idle" },
3231+ },
3232+ });
3233+ marker.resolve({
3234+ id: "evt-marker-after-late-prompt-acceptance",
3235+ type: "session.updated",
3236+ properties: {
3237+ info: {
3238+ id: "http://127.0.0.1:9999/session",
3239+ title: "Late prompt cleaned up",
3240+ },
3241+ },
3242+ });
3243+
3244+ const firstOutput = Option.getOrUndefined(
3245+ yield* Fiber.join(firstLateOutput).pipe(Effect.timeout("1 second")),
3246+ );
3247+ NodeAssert.equal(firstOutput?.type, "thread.metadata.updated");
3248+ NodeAssert.equal(Exit.isFailure(sendResult), true);
3249+ if (Exit.isFailure(sendResult)) {
3250+ NodeAssert.equal(Cause.hasInterruptsOnly(sendResult.cause), true);
3251+ }
3252+
3253+ yield* adapter.sendTurn({
3254+ threadId,
3255+ input: "Start after late cleanup",
3256+ modelSelection: createModelSelection(
3257+ ProviderInstanceId.make("opencode"),
3258+ "opencode/kimi-k3",
3259+ ),
3260+ });
3261+ yield* Effect.yieldNow;
3262+ NodeAssert.equal(runtimeMock.state.abortCalls.length, 1);
3263+ const sessionsAfterNextTurn = yield* adapter.listSessions();
3264+ const sessionAfterNextTurn = sessionsAfterNextTurn.find(
3265+ (candidate) => candidate.threadId === threadId,
3266+ );
3267+ NodeAssert.equal(sessionAfterNextTurn?.status, "running");
3268+
3269+ yield* adapter.stopSession(threadId);
3270+ }),
3271+ );
3272+
3273+ it.effect("treats MessageAbortedError as the acknowledgment for a pending user stop", () =>
3274+ Effect.gen(function* () {
3275+ const adapter = yield* OpenCodeAdapter;
3276+ const threadId = asThreadId("thread-interrupt-error-race");
3277+ const abortedEvent = promiseWithResolvers<unknown>();
3278+ const abortStarted = promiseWithResolvers<void>();
3279+ const abortRelease = promiseWithResolvers<void>();
3280+ runtimeMock.state.subscribedEvents = [abortedEvent.promise];
3281+ runtimeMock.state.abortImplementation = async () => {
3282+ abortStarted.resolve(undefined);
3283+ await abortRelease.promise;
3284+ };
3285+
3286+ const eventsFiber = yield* adapter.streamEvents.pipe(
3287+ Stream.filter((event) => event.threadId === threadId),
3288+ Stream.take(4),
3289+ Stream.runCollect,
3290+ Effect.forkChild,
3291+ );
3292+ yield* adapter.startSession({
3293+ provider: ProviderDriverKind.make("opencode"),
3294+ threadId,
3295+ runtimeMode: "full-access",
3296+ });
3297+ const turn = yield* adapter.sendTurn({
3298+ threadId,
3299+ input: "Keep working",
3300+ modelSelection: createModelSelection(
3301+ ProviderInstanceId.make("opencode"),
3302+ "opencode/kimi-k3",
3303+ ),
3304+ });
3305+
3306+ const interruptFiber = yield* adapter
3307+ .interruptTurn(threadId, turn.turnId)
3308+ .pipe(Effect.forkChild);
3309+ yield* Effect.promise(() => abortStarted.promise);
3310+ abortedEvent.resolve({
3311+ id: "evt-aborted-after-stop",
3312+ type: "session.error",
3313+ properties: {
3314+ sessionID: "http://127.0.0.1:9999/session",
3315+ error: { name: "MessageAbortedError", data: { message: "Aborted" } },
3316+ },
3317+ });
3318+ yield* Effect.yieldNow;
3319+ abortRelease.resolve(undefined);
3320+ yield* Fiber.join(interruptFiber);
3321+
3322+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3323+ NodeAssert.deepEqual(
3324+ events
3325+ .filter(
3326+ (event) =>
3327+ event.type === "turn.completed" ||
3328+ event.type === "turn.aborted" ||
3329+ event.type === "runtime.error",
3330+ )
3331+ .map((event) => event.type),
3332+ ["turn.aborted"],
3333+ );
3334+
3335+ yield* adapter.stopSession(threadId);
3336+ }),
3337+ );
3338+
3339+ it.effect("does not claim a turn stopped when the abort request fails", () =>
3340+ Effect.gen(function* () {
3341+ const adapter = yield* OpenCodeAdapter;
3342+ const threadId = asThreadId("thread-interrupt-request-failure");
3343+ runtimeMock.state.abortImplementation = async () => {
3344+ throw new Error("abort failed");
3345+ };
3346+
3347+ yield* adapter.startSession({
3348+ provider: ProviderDriverKind.make("opencode"),
3349+ threadId,
3350+ runtimeMode: "full-access",
3351+ });
3352+ const turn = yield* adapter.sendTurn({
3353+ threadId,
3354+ input: "Keep working",
3355+ modelSelection: createModelSelection(
3356+ ProviderInstanceId.make("opencode"),
3357+ "opencode/kimi-k3",
3358+ ),
3359+ });
3360+
3361+ const exit = yield* Effect.exit(adapter.interruptTurn(threadId, turn.turnId));
3362+ NodeAssert.equal(Exit.isFailure(exit), true);
3363+ const sessions = yield* adapter.listSessions();
3364+ const session = sessions.find((candidate) => candidate.threadId === threadId);
3365+ NodeAssert.equal(session?.status, "running");
3366+ NodeAssert.equal(session?.activeTurnId, turn.turnId);
3367+ }),
3368+ );
3369+
3370+ it.effect("releases stop and send waiters when a native abort times out", () =>
3371+ Effect.gen(function* () {
3372+ const adapter = yield* OpenCodeAdapter;
3373+ const threadId = asThreadId("thread-interrupt-timeout");
3374+ const abortStarted = promiseWithResolvers<void>();
3375+ runtimeMock.state.abortImplementation = async () => {
3376+ abortStarted.resolve(undefined);
3377+ await new Promise<void>(() => {});
3378+ };
3379+ runtimeMock.state.sessionStatus = "busy";
3380+
3381+ yield* adapter.startSession({
3382+ provider: ProviderDriverKind.make("opencode"),
3383+ threadId,
3384+ runtimeMode: "full-access",
3385+ });
3386+ const turn = yield* adapter.sendTurn({
3387+ threadId,
3388+ input: "Keep working",
3389+ modelSelection: createModelSelection(
3390+ ProviderInstanceId.make("opencode"),
3391+ "opencode/kimi-k3",
3392+ ),
3393+ });
3394+ const unexpectedEventFiber = yield* adapter.streamEvents.pipe(
3395+ Stream.filter(
3396+ (event) =>
3397+ event.threadId === threadId &&
3398+ (event.type === "turn.completed" || event.type === "turn.aborted"),
3399+ ),
3400+ Stream.runHead,
3401+ Effect.forkChild,
3402+ );
3403+ const firstInterrupt = yield* adapter
3404+ .interruptTurn(threadId, turn.turnId)
3405+ .pipe(Effect.result, Effect.forkChild);
3406+ yield* Effect.promise(() => abortStarted.promise);
3407+ NodeAssert.equal(runtimeMock.state.abortCalls.length, 1);
3408+ NodeAssert.equal(runtimeMock.state.abortSignals.length, 1);
3409+ const abortSignal = runtimeMock.state.abortSignals[0];
3410+ const secondInterrupt = yield* adapter
3411+ .interruptTurn(threadId, turn.turnId)
3412+ .pipe(Effect.result, Effect.forkChild);
3413+ const sendFiber = yield* adapter
3414+ .sendTurn({
3415+ threadId,
3416+ input: "Wait for the stop request",
3417+ modelSelection: createModelSelection(
3418+ ProviderInstanceId.make("opencode"),
3419+ "opencode/kimi-k3",
3420+ ),
3421+ })
3422+ .pipe(Effect.result, Effect.forkChild);
3423+ yield* Effect.yieldNow;
3424+ NodeAssert.equal(runtimeMock.state.abortCalls.length, 1);
3425+
3426+ yield* advanceTestClock(9_999);
3427+ NodeAssert.equal(firstInterrupt.pollUnsafe(), undefined);
3428+ NodeAssert.equal(secondInterrupt.pollUnsafe(), undefined);
3429+ NodeAssert.equal(sendFiber.pollUnsafe(), undefined);
3430+ yield* advanceTestClock(1);
3431+
3432+ const firstResult = yield* Fiber.join(firstInterrupt);
3433+ const secondResult = yield* Fiber.join(secondInterrupt);
3434+ const sendResult = yield* Fiber.join(sendFiber);
3435+ NodeAssert.equal(firstResult._tag, "Failure");
3436+ NodeAssert.equal(secondResult._tag, "Failure");
3437+ NodeAssert.equal(sendResult._tag, "Failure");
3438+ if (firstResult._tag === "Failure") {
3439+ NodeAssert.equal(firstResult.failure._tag, "ProviderAdapterRequestError");
3440+ NodeAssert.equal(
3441+ firstResult.failure.detail,
3442+ "OpenCode session abort did not complete within 10 seconds.",
3443+ );
3444+ }
3445+ NodeAssert.equal(abortSignal?.aborted, true);
3446+ NodeAssert.equal(unexpectedEventFiber.pollUnsafe(), undefined);
3447+
3448+ runtimeMock.state.abortImplementation = null;
3449+ yield* adapter.sendTurn({
3450+ threadId,
3451+ input: "Continue after the failed stop request",
3452+ modelSelection: createModelSelection(
3453+ ProviderInstanceId.make("opencode"),
3454+ "opencode/kimi-k3",
3455+ ),
3456+ });
3457+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 2);
3458+
3459+ yield* Fiber.interrupt(unexpectedEventFiber);
3460+ yield* adapter.stopSession(threadId);
3461+ }),
3462+ );
3463+
3464+ it.effect("shares one abort request across concurrent stops", () =>
3465+ Effect.gen(function* () {
3466+ const adapter = yield* OpenCodeAdapter;
3467+ const threadId = asThreadId("thread-concurrent-interrupt");
3468+ const abortStarted = promiseWithResolvers<void>();
3469+ const abortRelease = promiseWithResolvers<void>();
3470+ runtimeMock.state.abortImplementation = async () => {
3471+ abortStarted.resolve(undefined);
3472+ await abortRelease.promise;
3473+ };
3474+
3475+ const eventsFiber = yield* adapter.streamEvents.pipe(
3476+ Stream.filter((event) => event.threadId === threadId),
3477+ Stream.take(4),
3478+ Stream.runCollect,
3479+ Effect.forkChild,
3480+ );
3481+ yield* adapter.startSession({
3482+ provider: ProviderDriverKind.make("opencode"),
3483+ threadId,
3484+ runtimeMode: "full-access",
3485+ });
3486+ const turn = yield* adapter.sendTurn({
3487+ threadId,
3488+ input: "Keep working",
3489+ modelSelection: createModelSelection(
3490+ ProviderInstanceId.make("opencode"),
3491+ "opencode/kimi-k3",
3492+ ),
3493+ });
3494+
3495+ const firstInterrupt = yield* adapter
3496+ .interruptTurn(threadId, turn.turnId)
3497+ .pipe(Effect.forkChild);
3498+ const secondInterrupt = yield* adapter
3499+ .interruptTurn(threadId, turn.turnId)
3500+ .pipe(Effect.forkChild);
3501+ yield* Effect.promise(() => abortStarted.promise);
3502+ yield* Effect.yieldNow;
3503+ NodeAssert.equal(runtimeMock.state.abortCalls.length, 1);
3504+
3505+ abortRelease.resolve(undefined);
3506+ yield* Fiber.join(firstInterrupt);
3507+ yield* Fiber.join(secondInterrupt);
3508+
3509+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
3510+ NodeAssert.deepEqual(
3511+ events
3512+ .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted")
3513+ .map((event) => event.type),
3514+ ["turn.aborted"],
3515+ );
3516+
3517+ yield* adapter.stopSession(threadId);
3518+ }),
3519+ );
3520+
3521+ it.effect("accepts a native turnless abort before its request times out", () =>
3522+ Effect.gen(function* () {
3523+ const adapter = yield* OpenCodeAdapter;
3524+ const threadId = asThreadId("thread-turnless-interrupt");
3525+ const abortEvent = promiseWithResolvers<unknown>();
3526+ const markerEvent = promiseWithResolvers<unknown>();
3527+ const abortStarted = promiseWithResolvers<void>();
3528+ runtimeMock.state.subscribedEvents = [abortEvent.promise, markerEvent.promise];
3529+ runtimeMock.state.abortImplementation = async () => {
3530+ abortStarted.resolve(undefined);
3531+ await new Promise<void>(() => {});
3532+ };
3533+
3534+ yield* adapter.startSession({
3535+ provider: ProviderDriverKind.make("opencode"),
3536+ threadId,
3537+ runtimeMode: "full-access",
3538+ resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" },
3539+ });
3540+ const acknowledgmentFiber = yield* adapter.streamEvents.pipe(
3541+ Stream.filter(
3542+ (event) =>
3543+ event.threadId === threadId &&
3544+ (event.type === "turn.completed" ||
3545+ event.type === "turn.aborted" ||
3546+ event.type === "runtime.error" ||
3547+ event.type === "thread.metadata.updated"),
3548+ ),
3549+ Stream.runHead,
3550+ Effect.forkChild,
3551+ );
3552+ const firstInterrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild);
3553+ yield* Effect.promise(() => abortStarted.promise);
3554+ const secondInterrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild);
3555+ runtimeMock.state.sessionStatusImplementation = async () => ({
3556+ data: { ses_existing: { type: "busy" as const } },
3557+ });
3558+ const sendFiber = yield* adapter
3559+ .sendTurn({
3560+ threadId,
3561+ input: "Start after the session abort",
3562+ modelSelection: createModelSelection(
3563+ ProviderInstanceId.make("opencode"),
3564+ "opencode/kimi-k3",
3565+ ),
3566+ })
3567+ .pipe(Effect.forkChild);
3568+ yield* Effect.yieldNow;
3569+
3570+ NodeAssert.equal(runtimeMock.state.abortCalls.length, 1);
3571+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 0);
3572+
3573+ abortEvent.resolve({
3574+ id: "evt-turnless-abort",
3575+ type: "session.error",
3576+ properties: {
3577+ sessionID: "ses_existing",
3578+ error: { name: "MessageAbortedError", data: { message: "Aborted" } },
3579+ },
3580+ });
3581+ markerEvent.resolve({
3582+ id: "evt-after-turnless-abort",
3583+ type: "session.updated",
3584+ properties: {
3585+ info: { id: "ses_existing", title: "Turnless abort acknowledged" },
3586+ },
3587+ });
3588+ const acknowledgment = Option.getOrUndefined(yield* Fiber.join(acknowledgmentFiber));
3589+ NodeAssert.equal(acknowledgment?.type, "thread.metadata.updated");
3590+ const unexpectedEventFiber = yield* adapter.streamEvents.pipe(
3591+ Stream.filter(
3592+ (event) =>
3593+ event.threadId === threadId &&
3594+ (event.type === "turn.completed" ||
3595+ event.type === "turn.aborted" ||
3596+ event.type === "runtime.error"),
3597+ ),
3598+ Stream.runHead,
3599+ Effect.forkChild,
3600+ );
3601+ yield* advanceTestClock(10_000);
3602+ yield* Fiber.join(firstInterrupt);
3603+ yield* Fiber.join(secondInterrupt);
3604+ yield* Fiber.join(sendFiber);
3605+
3606+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3607+ NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true);
3608+ NodeAssert.equal(unexpectedEventFiber.pollUnsafe(), undefined);
3609+ yield* Fiber.interrupt(unexpectedEventFiber);
3610+ runtimeMock.state.abortImplementation = null;
3611+ yield* adapter.stopSession(threadId);
3612+ }),
3613+ );
3614+
3615+ it.effect("ignores a native turnless abort after its request succeeds", () =>
3616+ Effect.gen(function* () {
3617+ const adapter = yield* OpenCodeAdapter;
3618+ const threadId = asThreadId("thread-late-turnless-abort");
3619+ const abortEvent = promiseWithResolvers<unknown>();
3620+ const markerEvent = promiseWithResolvers<unknown>();
3621+ runtimeMock.state.subscribedEvents = [abortEvent.promise, markerEvent.promise];
3622+
3623+ yield* adapter.startSession({
3624+ provider: ProviderDriverKind.make("opencode"),
3625+ threadId,
3626+ runtimeMode: "full-access",
3627+ resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" },
3628+ });
3629+ const acknowledgmentFiber = yield* adapter.streamEvents.pipe(
3630+ Stream.filter(
3631+ (event) =>
3632+ event.threadId === threadId &&
3633+ (event.type === "turn.completed" ||
3634+ event.type === "turn.aborted" ||
3635+ event.type === "runtime.error" ||
3636+ event.type === "thread.metadata.updated"),
3637+ ),
3638+ Stream.runHead,
3639+ Effect.forkChild,
3640+ );
3641+
3642+ yield* adapter.interruptTurn(threadId);
3643+ abortEvent.resolve({
3644+ id: "evt-late-turnless-abort",
3645+ type: "session.error",
3646+ properties: {
3647+ sessionID: "ses_existing",
3648+ error: { name: "MessageAbortedError", data: { message: "Aborted" } },
3649+ },
3650+ });
3651+ markerEvent.resolve({
3652+ id: "evt-after-late-turnless-abort",
3653+ type: "session.updated",
3654+ properties: {
3655+ info: { id: "ses_existing", title: "Late turnless abort ignored" },
3656+ },
3657+ });
3658+ const acknowledgment = Option.getOrUndefined(yield* Fiber.join(acknowledgmentFiber));
3659+
3660+ NodeAssert.equal(acknowledgment?.type, "thread.metadata.updated");
3661+ const sessions = yield* adapter.listSessions();
3662+ const session = sessions.find((candidate) => candidate.threadId === threadId);
3663+ NodeAssert.equal(session?.status, "ready");
3664+ NodeAssert.equal(session?.activeTurnId, undefined);
3665+
3666+ yield* adapter.stopSession(threadId);
3667+ }),
3668+ );
3669+
3670+ it.effect("clears a failed turnless interrupt before the next turn", () =>
3671+ Effect.gen(function* () {
3672+ const adapter = yield* OpenCodeAdapter;
3673+ const threadId = asThreadId("thread-turnless-interrupt-failure");
3674+ runtimeMock.state.abortImplementation = async () => {
3675+ throw new Error("abort failed");
3676+ };
3677+
3678+ yield* adapter.startSession({
3679+ provider: ProviderDriverKind.make("opencode"),
3680+ threadId,
3681+ runtimeMode: "full-access",
3682+ resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" },
3683+ });
3684+ const interruptExit = yield* Effect.exit(adapter.interruptTurn(threadId));
3685+ NodeAssert.equal(Exit.isFailure(interruptExit), true);
3686+
3687+ runtimeMock.state.abortImplementation = null;
3688+ yield* adapter.sendTurn({
3689+ threadId,
3690+ input: "Start after the failed session abort",
3691+ modelSelection: createModelSelection(
3692+ ProviderInstanceId.make("opencode"),
3693+ "opencode/kimi-k3",
3694+ ),
3695+ });
3696+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3697+
3698+ yield* adapter.stopSession(threadId);
3699+ }),
3700+ );
3701+
3702+ it.effect("waits for a pending stop before starting the next turn", () =>
3703+ Effect.gen(function* () {
3704+ const adapter = yield* OpenCodeAdapter;
3705+ const threadId = asThreadId("thread-send-during-stop");
3706+ const abortStarted = promiseWithResolvers<void>();
3707+ const abortRelease = promiseWithResolvers<void>();
3708+ runtimeMock.state.abortImplementation = async () => {
3709+ abortStarted.resolve(undefined);
3710+ await abortRelease.promise;
3711+ };
3712+ yield* adapter.startSession({
3713+ provider: ProviderDriverKind.make("opencode"),
3714+ threadId,
3715+ runtimeMode: "full-access",
3716+ });
3717+ const stoppedTurn = yield* adapter.sendTurn({
3718+ threadId,
3719+ input: "First turn",
3720+ modelSelection: createModelSelection(
3721+ ProviderInstanceId.make("opencode"),
3722+ "opencode/kimi-k3",
3723+ ),
3724+ });
3725+ const stopFiber = yield* adapter
3726+ .interruptTurn(threadId, stoppedTurn.turnId)
3727+ .pipe(Effect.forkChild);
3728+ yield* Effect.promise(() => abortStarted.promise);
3729+ const sendFiber = yield* adapter
3730+ .sendTurn({
3731+ threadId,
3732+ input: "Second turn",
3733+ modelSelection: createModelSelection(
3734+ ProviderInstanceId.make("opencode"),
3735+ "opencode/kimi-k3",
3736+ ),
3737+ })
3738+ .pipe(Effect.forkChild);
3739+ yield* Effect.yieldNow;
3740+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3741+ abortRelease.resolve(undefined);
3742+ yield* Fiber.join(stopFiber);
3743+ const nextTurn = yield* Fiber.join(sendFiber);
3744+
3745+ NodeAssert.notEqual(nextTurn.turnId, stoppedTurn.turnId);
3746+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 2);
3747+ }),
3748+ );
3749+
3750+ it.effect("interrupts a turn waiting on cancellation when the session stops", () =>
3751+ Effect.gen(function* () {
3752+ const adapter = yield* OpenCodeAdapter;
3753+ const threadId = asThreadId("thread-stop-during-cancellation");
3754+ const firstAbortStarted = promiseWithResolvers<void>();
3755+ const teardownAbortStarted = promiseWithResolvers<void>();
3756+ const abortRelease = promiseWithResolvers<void>();
3757+ runtimeMock.state.abortImplementation = async () => {
3758+ if (runtimeMock.state.abortCalls.length === 1) {
3759+ firstAbortStarted.resolve(undefined);
3760+ } else {
3761+ teardownAbortStarted.resolve(undefined);
3762+ }
3763+ await abortRelease.promise;
3764+ };
3765+
3766+ yield* adapter.startSession({
3767+ provider: ProviderDriverKind.make("opencode"),
3768+ threadId,
3769+ runtimeMode: "full-access",
3770+ });
3771+ const activeTurn = yield* adapter.sendTurn({
3772+ threadId,
3773+ input: "First turn",
3774+ modelSelection: createModelSelection(
3775+ ProviderInstanceId.make("opencode"),
3776+ "opencode/kimi-k3",
3777+ ),
3778+ });
3779+ const interruptFiber = yield* adapter
3780+ .interruptTurn(threadId, activeTurn.turnId)
3781+ .pipe(Effect.forkChild);
3782+ yield* Effect.promise(() => firstAbortStarted.promise);
3783+
3784+ const sendFiber = yield* adapter
3785+ .sendTurn({
3786+ threadId,
3787+ input: "Must not be sent",
3788+ modelSelection: createModelSelection(
3789+ ProviderInstanceId.make("opencode"),
3790+ "opencode/kimi-k3",
3791+ ),
3792+ })
3793+ .pipe(Effect.exit, Effect.forkChild);
3794+ yield* Effect.yieldNow;
3795+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3796+
3797+ const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
3798+ const sendResult = yield* Fiber.join(sendFiber);
3799+ NodeAssert.equal(Exit.isFailure(sendResult), true);
3800+ if (Exit.isFailure(sendResult)) {
3801+ NodeAssert.equal(Cause.hasInterruptsOnly(sendResult.cause), true);
3802+ }
3803+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3804+
3805+ yield* Effect.promise(() => teardownAbortStarted.promise);
3806+ yield* advanceTestClock(1_000);
3807+ yield* Fiber.join(stopFiber);
3808+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
3809+
3810+ abortRelease.resolve(undefined);
3811+ yield* Fiber.join(interruptFiber);
3812+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3813+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
3814+ }),
3815+ );
3816+
3817+ it.effect("rechecks a newer idle after an older status call returns busy", () =>
3818+ Effect.gen(function* () {
3819+ const adapter = yield* OpenCodeAdapter;
3820+ const threadId = asThreadId("thread-newer-idle-during-status");
3821+ const busyEvent = promiseWithResolvers<unknown>();
3822+ const staleIdle = promiseWithResolvers<unknown>();
3823+ const realIdle = promiseWithResolvers<unknown>();
3824+ const statusStarted = promiseWithResolvers<void>();
3825+ const statusRelease = promiseWithResolvers<void>();
3826+ runtimeMock.state.subscribedEvents = [busyEvent.promise, staleIdle.promise, realIdle.promise];
3827+ runtimeMock.state.sessionStatusImplementation = async () => {
3828+ if (runtimeMock.state.sessionStatusCalls === 1) {
3829+ statusStarted.resolve(undefined);
3830+ await statusRelease.promise;
3831+ return {
3832+ data: { "http://127.0.0.1:9999/session": { type: "busy" as const } },
3833+ };
3834+ }
3835+ return { data: {} };
3836+ };
3837+
3838+ const completedFiber = yield* adapter.streamEvents.pipe(
3839+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
3840+ Stream.runHead,
3841+ Effect.forkChild,
3842+ );
3843+ yield* adapter.startSession({
3844+ provider: ProviderDriverKind.make("opencode"),
3845+ threadId,
3846+ runtimeMode: "full-access",
3847+ });
3848+ const firstTurn = yield* adapter.sendTurn({
3849+ threadId,
3850+ input: "First turn",
3851+ modelSelection: createModelSelection(
3852+ ProviderInstanceId.make("opencode"),
3853+ "opencode/kimi-k3",
3854+ ),
3855+ });
3856+ yield* adapter.interruptTurn(threadId, firstTurn.turnId);
3857+ const secondTurn = yield* adapter.sendTurn({
3858+ threadId,
3859+ input: "Second turn",
3860+ modelSelection: createModelSelection(
3861+ ProviderInstanceId.make("opencode"),
3862+ "opencode/kimi-k3",
3863+ ),
3864+ });
3865+ busyEvent.resolve({
3866+ id: "evt-new-turn-busy",
3867+ type: "session.status",
3868+ properties: {
3869+ sessionID: "http://127.0.0.1:9999/session",
3870+ status: { type: "busy" },
3871+ },
3872+ });
3873+ staleIdle.resolve({
3874+ id: "evt-old-idle",
3875+ type: "session.status",
3876+ properties: {
3877+ sessionID: "http://127.0.0.1:9999/session",
3878+ status: { type: "idle" },
3879+ },
3880+ });
3881+ yield* Effect.promise(() => statusStarted.promise);
3882+ realIdle.resolve({
3883+ id: "evt-new-idle",
3884+ type: "session.status",
3885+ properties: {
3886+ sessionID: "http://127.0.0.1:9999/session",
3887+ status: { type: "idle" },
3888+ },
3889+ });
3890+ statusRelease.resolve(undefined);
3891+
3892+ const completed = Option.getOrUndefined(
3893+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
3894+ );
3895+ NodeAssert.equal(completed?.turnId, secondTurn.turnId);
3896+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 2);
3897+ }),
3898+ );
3899+
3900+ it.effect("completes after transient status failures without another idle event", () =>
3901+ Effect.gen(function* () {
3902+ const adapter = yield* OpenCodeAdapter;
3903+ const threadId = asThreadId("thread-idle-status-retry");
3904+ const busyEvent = promiseWithResolvers<unknown>();
3905+ const idleEvent = promiseWithResolvers<unknown>();
3906+ const failuresObserved = promiseWithResolvers<void>();
3907+ runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise];
3908+ runtimeMock.state.sessionStatusImplementation = async () => {
3909+ if (runtimeMock.state.sessionStatusCalls <= 2) {
3910+ if (runtimeMock.state.sessionStatusCalls === 2) {
3911+ failuresObserved.resolve(undefined);
3912+ }
3913+ throw new Error("status failed");
3914+ }
3915+ return { data: {} };
3916+ };
3917+
3918+ const completedFiber = yield* adapter.streamEvents.pipe(
3919+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
3920+ Stream.runHead,
3921+ Effect.forkChild,
3922+ );
3923+ yield* adapter.startSession({
3924+ provider: ProviderDriverKind.make("opencode"),
3925+ threadId,
3926+ runtimeMode: "full-access",
3927+ });
3928+ const firstTurn = yield* adapter.sendTurn({
3929+ threadId,
3930+ input: "First turn",
3931+ modelSelection: createModelSelection(
3932+ ProviderInstanceId.make("opencode"),
3933+ "opencode/kimi-k3",
3934+ ),
3935+ });
3936+ yield* adapter.interruptTurn(threadId, firstTurn.turnId);
3937+ const secondTurn = yield* adapter.sendTurn({
3938+ threadId,
3939+ input: "Second turn",
3940+ modelSelection: createModelSelection(
3941+ ProviderInstanceId.make("opencode"),
3942+ "opencode/kimi-k3",
3943+ ),
3944+ });
3945+ busyEvent.resolve({
3946+ id: "evt-retry-turn-busy",
3947+ type: "session.status",
3948+ properties: {
3949+ sessionID: "http://127.0.0.1:9999/session",
3950+ status: { type: "busy" },
3951+ },
3952+ });
3953+ idleEvent.resolve({
3954+ id: "evt-retry-turn-idle",
3955+ type: "session.status",
3956+ properties: {
3957+ sessionID: "http://127.0.0.1:9999/session",
3958+ status: { type: "idle" },
3959+ },
3960+ });
3961+ yield* Effect.promise(() => failuresObserved.promise);
3962+ yield* advanceTestClock(250);
3963+
3964+ const completed = Option.getOrUndefined(
3965+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
3966+ );
3967+ NodeAssert.equal(completed?.turnId, secondTurn.turnId);
3968+ NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 3);
3969+ }),
3970+ );
3971+
3972+ it.effect("keeps idle reconciliation after a delayed abort from the stopped turn", () =>
3973+ Effect.gen(function* () {
3974+ const adapter = yield* OpenCodeAdapter;
3975+ const threadId = asThreadId("thread-stale-abort-during-idle-check");
3976+ const busyEvent = promiseWithResolvers<unknown>();
3977+ const idleEvent = promiseWithResolvers<unknown>();
3978+ const staleAbortEvent = promiseWithResolvers<unknown>();
3979+ const statusStarted = promiseWithResolvers<void>();
3980+ const statusRelease = promiseWithResolvers<void>();
3981+ runtimeMock.state.subscribedEvents = [
3982+ busyEvent.promise,
3983+ idleEvent.promise,
3984+ staleAbortEvent.promise,
3985+ ];
3986+ runtimeMock.state.sessionStatusImplementation = async () => {
3987+ statusStarted.resolve(undefined);
3988+ await statusRelease.promise;
3989+ return { data: {} };
3990+ };
3991+
3992+ const completedFiber = yield* adapter.streamEvents.pipe(
3993+ Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
3994+ Stream.runHead,
3995+ Effect.forkChild,
3996+ );
3997+ yield* adapter.startSession({
3998+ provider: ProviderDriverKind.make("opencode"),
3999+ threadId,
4000+ runtimeMode: "full-access",
4001+ });
4002+ const stoppedTurn = yield* adapter.sendTurn({
4003+ threadId,
4004+ input: "First turn",
4005+ modelSelection: createModelSelection(
4006+ ProviderInstanceId.make("opencode"),
4007+ "opencode/kimi-k3",
4008+ ),
4009+ });
4010+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
4011+ const activeTurn = yield* adapter.sendTurn({
4012+ threadId,
4013+ input: "Second turn",
4014+ modelSelection: createModelSelection(
4015+ ProviderInstanceId.make("opencode"),
4016+ "opencode/kimi-k3",
4017+ ),
4018+ });
4019+ busyEvent.resolve({
4020+ id: "evt-stale-abort-busy",
4021+ type: "session.status",
4022+ properties: {
4023+ sessionID: "http://127.0.0.1:9999/session",
4024+ status: { type: "busy" },
4025+ },
4026+ });
4027+ idleEvent.resolve({
4028+ id: "evt-stale-abort-idle",
4029+ type: "session.status",
4030+ properties: {
4031+ sessionID: "http://127.0.0.1:9999/session",
4032+ status: { type: "idle" },
4033+ },
4034+ });
4035+ yield* Effect.promise(() => statusStarted.promise);
4036+ staleAbortEvent.resolve({
4037+ id: "evt-delayed-old-abort",
4038+ type: "session.error",
4039+ properties: {
4040+ sessionID: "http://127.0.0.1:9999/session",
4041+ error: { name: "MessageAbortedError", data: { message: "Aborted" } },
4042+ },
4043+ });
4044+ statusRelease.resolve(undefined);
4045+
4046+ const completed = Option.getOrUndefined(
4047+ yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
4048+ );
4049+ NodeAssert.equal(completed?.turnId, activeTurn.turnId);
4050+ }),
4051+ );
4052+
4053+ it.effect("keeps the newer turn running while status lookup keeps failing", () =>
4054+ Effect.gen(function* () {
4055+ const adapter = yield* OpenCodeAdapter;
4056+ const threadId = asThreadId("thread-idle-status-permanent-failure");
4057+ const busyEvent = promiseWithResolvers<unknown>();
4058+ const idleEvent = promiseWithResolvers<unknown>();
4059+ const firstAttemptFailed = promiseWithResolvers<void>();
4060+ const retryAttemptFailed = promiseWithResolvers<void>();
4061+ runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise];
4062+ runtimeMock.state.sessionStatusImplementation = async () => {
4063+ if (runtimeMock.state.sessionStatusCalls === 2) {
4064+ firstAttemptFailed.resolve(undefined);
4065+ }
4066+ if (runtimeMock.state.sessionStatusCalls === 4) {
4067+ retryAttemptFailed.resolve(undefined);
4068+ }
4069+ throw new Error("status remains unavailable");
4070+ };
4071+
4072+ yield* adapter.startSession({
4073+ provider: ProviderDriverKind.make("opencode"),
4074+ threadId,
4075+ runtimeMode: "full-access",
4076+ });
4077+ const stoppedTurn = yield* adapter.sendTurn({
4078+ threadId,
4079+ input: "First turn",
4080+ modelSelection: createModelSelection(
4081+ ProviderInstanceId.make("opencode"),
4082+ "opencode/kimi-k3",
4083+ ),
4084+ });
4085+ yield* adapter.interruptTurn(threadId, stoppedTurn.turnId);
4086+ const activeTurn = yield* adapter.sendTurn({
4087+ threadId,
4088+ input: "Second turn",
4089+ modelSelection: createModelSelection(
4090+ ProviderInstanceId.make("opencode"),
4091+ "opencode/kimi-k3",
4092+ ),
4093+ });
4094+ busyEvent.resolve({
4095+ id: "evt-permanent-failure-busy",
4096+ type: "session.status",
4097+ properties: {
4098+ sessionID: "http://127.0.0.1:9999/session",
4099+ status: { type: "busy" },
4100+ },
4101+ });
4102+ idleEvent.resolve({
4103+ id: "evt-permanent-failure-idle",
4104+ type: "session.status",
4105+ properties: {
4106+ sessionID: "http://127.0.0.1:9999/session",
4107+ status: { type: "idle" },
4108+ },
4109+ });
4110+ yield* Effect.promise(() => firstAttemptFailed.promise);
4111+ yield* advanceTestClock(250);
4112+ yield* Effect.promise(() => retryAttemptFailed.promise);
4113+
4114+ const sessions = yield* adapter.listSessions();
4115+ const session = sessions.find((candidate) => candidate.threadId === threadId);
4116+ NodeAssert.equal(session?.status, "running");
4117+ NodeAssert.equal(session?.activeTurnId, activeTurn.turnId);
4118+ yield* adapter.stopSession(threadId);
4119+ }),
4120+ );
4121+
4122+ it.effect("ignores delayed stop events around the next turn startup", () =>
4123+ Effect.gen(function* () {
4124+ const adapter = yield* OpenCodeAdapter;
4125+ const threadId = asThreadId("thread-delayed-interrupt-events");
4126+ const staleIdleBeforeBusy = promiseWithResolvers<unknown>();
4127+ const nextBusy = promiseWithResolvers<unknown>();
4128+ const nextUserMessage = promiseWithResolvers<unknown>();
4129+ const staleAbort = promiseWithResolvers<unknown>();
4130+ const staleIdle = promiseWithResolvers<unknown>();
4131+ const secondStaleIdle = promiseWithResolvers<unknown>();
4132+ const nextIdle = promiseWithResolvers<unknown>();
4133+ runtimeMock.state.autoPromptEcho = false;
4134+ runtimeMock.state.subscribedEvents = [
4135+ staleIdleBeforeBusy.promise,
4136+ nextBusy.promise,
4137+ nextUserMessage.promise,
4138+ staleAbort.promise,
4139+ staleIdle.promise,
4140+ secondStaleIdle.promise,
4141+ nextIdle.promise,
4142+ ];
4143+
4144+ const eventsFiber = yield* adapter.streamEvents.pipe(
4145+ Stream.filter((event) => event.threadId === threadId),
4146+ Stream.take(6),
4147+ Stream.runCollect,
4148+ Effect.forkChild,
4149+ );
4150+ yield* adapter.startSession({
4151+ provider: ProviderDriverKind.make("opencode"),
4152+ threadId,
4153+ runtimeMode: "full-access",
4154+ });
4155+ const firstTurn = yield* adapter.sendTurn({
4156+ threadId,
4157+ input: "First turn",
4158+ modelSelection: createModelSelection(
4159+ ProviderInstanceId.make("opencode"),
4160+ "opencode/kimi-k3",
4161+ ),
4162+ });
4163+ yield* adapter.interruptTurn(threadId, firstTurn.turnId);
4164+ const secondTurn = yield* adapter.sendTurn({
4165+ threadId,
4166+ input: "Second turn",
4167+ modelSelection: createModelSelection(
4168+ ProviderInstanceId.make("opencode"),
4169+ "opencode/kimi-k3",
4170+ ),
4171+ });
4172+ const secondMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string })
4173+ .messageID;
4174+
4175+ staleIdleBeforeBusy.resolve({
4176+ id: "evt-delayed-idle-before-busy",
4177+ type: "session.status",
4178+ properties: {
4179+ sessionID: "http://127.0.0.1:9999/session",
4180+ status: { type: "idle" },
4181+ },
4182+ });
4183+ for (let index = 0; index < 2; index += 1) {
4184+ yield* Effect.yieldNow;
4185+ }
4186+ const sessionsBeforeBusy = yield* adapter.listSessions();
4187+ const sessionBeforeBusy = sessionsBeforeBusy.find(
4188+ (candidate) => candidate.threadId === threadId,
4189+ );
4190+ NodeAssert.equal(sessionBeforeBusy?.status, "running");
4191+ NodeAssert.equal(sessionBeforeBusy?.activeTurnId, secondTurn.turnId);
4192+
4193+ runtimeMock.state.sessionStatus = "busy";
4194+ nextBusy.resolve({
4195+ id: "evt-next-busy",
4196+ type: "session.status",
4197+ properties: {
4198+ sessionID: "http://127.0.0.1:9999/session",
4199+ status: { type: "busy" },
4200+ },
4201+ });
4202+ nextUserMessage.resolve({
4203+ id: "evt-next-user-message",
4204+ type: "message.updated",
4205+ properties: {
4206+ sessionID: "http://127.0.0.1:9999/session",
4207+ info: { id: secondMessageId, role: "user" },
4208+ },
4209+ });
4210+ staleAbort.resolve({
4211+ id: "evt-delayed-abort",
4212+ type: "session.error",
4213+ properties: {
4214+ sessionID: "http://127.0.0.1:9999/session",
4215+ error: { name: "MessageAbortedError", data: { message: "Aborted" } },
4216+ },
4217+ });
4218+ staleIdle.resolve({
4219+ id: "evt-delayed-idle",
4220+ type: "session.status",
4221+ properties: {
4222+ sessionID: "http://127.0.0.1:9999/session",
4223+ status: { type: "idle" },
4224+ },
4225+ });
4226+ secondStaleIdle.resolve({
4227+ id: "evt-second-delayed-idle",
4228+ type: "session.status",
4229+ properties: {
4230+ sessionID: "http://127.0.0.1:9999/session",
4231+ status: { type: "idle" },
4232+ },
4233+ });
4234+ for (let index = 0; index < 4; index += 1) {
4235+ yield* Effect.yieldNow;
4236+ }
4237+
4238+ const sessionsBeforeRealIdle = yield* adapter.listSessions();
4239+ const sessionBeforeRealIdle = sessionsBeforeRealIdle.find(
4240+ (candidate) => candidate.threadId === threadId,
4241+ );
4242+ NodeAssert.equal(sessionBeforeRealIdle?.status, "running");
4243+ NodeAssert.equal(sessionBeforeRealIdle?.activeTurnId, secondTurn.turnId);
4244+
4245+ runtimeMock.state.sessionStatus = "idle";
4246+ nextIdle.resolve({
4247+ id: "evt-next-idle",
4248+ type: "session.status",
4249+ properties: {
4250+ sessionID: "http://127.0.0.1:9999/session",
4251+ status: { type: "idle" },
4252+ },
4253+ });
4254+
4255+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
4256+ NodeAssert.deepEqual(
4257+ events
4258+ .filter(
4259+ (event) =>
4260+ event.type === "turn.completed" ||
4261+ event.type === "turn.aborted" ||
4262+ event.type === "runtime.error",
4263+ )
4264+ .map((event) => ({ type: event.type, turnId: event.turnId })),
4265+ [
4266+ { type: "turn.aborted", turnId: firstTurn.turnId },
4267+ { type: "turn.completed", turnId: secondTurn.turnId },
4268+ ],
4269+ );
4270+
4271+ yield* adapter.stopSession(threadId);
4272+ }),
4273+ );
4274+
4275+ it.effect("keeps a genuine provider error visible during a pending user stop", () =>
4276+ Effect.gen(function* () {
4277+ const adapter = yield* OpenCodeAdapter;
4278+ const threadId = asThreadId("thread-interrupt-provider-error");
4279+ const errorEvent = promiseWithResolvers<unknown>();
4280+ const abortStarted = promiseWithResolvers<void>();
4281+ const abortRelease = promiseWithResolvers<void>();
4282+ runtimeMock.state.subscribedEvents = [errorEvent.promise];
4283+ runtimeMock.state.abortImplementation = async () => {
4284+ abortStarted.resolve(undefined);
4285+ await abortRelease.promise;
4286+ };
4287+
4288+ const eventsFiber = yield* adapter.streamEvents.pipe(
4289+ Stream.filter((event) => event.threadId === threadId),
4290+ Stream.take(5),
4291+ Stream.runCollect,
4292+ Effect.forkChild,
4293+ );
4294+ yield* adapter.startSession({
4295+ provider: ProviderDriverKind.make("opencode"),
4296+ threadId,
4297+ runtimeMode: "full-access",
4298+ });
4299+ const turn = yield* adapter.sendTurn({
4300+ threadId,
4301+ input: "Keep working",
4302+ modelSelection: createModelSelection(
4303+ ProviderInstanceId.make("opencode"),
4304+ "opencode/kimi-k3",
4305+ ),
4306+ });
4307+
4308+ const interruptFiber = yield* adapter
4309+ .interruptTurn(threadId, turn.turnId)
4310+ .pipe(Effect.forkChild);
4311+ yield* Effect.promise(() => abortStarted.promise);
4312+ errorEvent.resolve({
4313+ id: "evt-provider-error-after-stop",
4314+ type: "session.error",
4315+ properties: {
4316+ sessionID: "http://127.0.0.1:9999/session",
4317+ error: {
4318+ name: "APIError",
4319+ data: { message: "Upstream failed", isRetryable: false },
4320+ },
4321+ },
4322+ });
4323+ yield* Effect.yieldNow;
4324+ abortRelease.resolve(undefined);
4325+ yield* Fiber.join(interruptFiber);
4326+
4327+ const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
4328+ NodeAssert.deepEqual(
4329+ events
4330+ .filter(
4331+ (event) =>
4332+ event.type === "turn.completed" ||
4333+ event.type === "turn.aborted" ||
4334+ event.type === "runtime.error",
4335+ )
4336+ .map((event) => event.type),
4337+ ["turn.completed", "runtime.error"],
4338+ );
4339+ const failed = events.find((event) => event.type === "turn.completed");
4340+ NodeAssert.equal(
4341+ failed?.type === "turn.completed" ? failed.payload.state : undefined,
4342+ "failed",
4343+ );
4344+
4345+ yield* adapter.stopSession(threadId);
4346+ }),
4347+ );
4348+
4349+ it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
4350+ const instanceId = ProviderInstanceId.make("opencode_zen");
4351+ const adapterLayer = Layer.effect(
4352+ OpenCodeAdapter,
4353+ makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
4354+ ).pipe(
4355+ Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
4356+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
4357+ Layer.provideMerge(ServerSettingsService.layerTest()),
4358+ Layer.provideMerge(providerSessionDirectoryTestLayer),
4359+ Layer.provideMerge(NodeServices.layer),
4360+ );
4361+
4362+ return Effect.gen(function* () {
4363+ const adapter = yield* OpenCodeAdapter;
4364+ yield* adapter.startSession({
4365+ provider: ProviderDriverKind.make("opencode"),
4366+ threadId: asThreadId("thread-custom-instance"),
4367+ runtimeMode: "full-access",
4368+ });
4369+
4370+ yield* adapter.sendTurn({
4371+ threadId: asThreadId("thread-custom-instance"),
4372+ input: "Fix it",
4373+ modelSelection: createModelSelection(
4374+ ProviderInstanceId.make("opencode_zen"),
4375+ "anthropic/claude-sonnet-4-5",
4376+ [
4377+ { id: "agent", value: "github-copilot" },
4378+ { id: "variant", value: "high" },
4379+ ],
4380+ ),
4381+ });
4382+
4383+ const { messageID, ...prompt } = runtimeMock.state.promptCalls.at(-1) as {
4384+ messageID: string;
4385+ [key: string]: unknown;
4386+ };
4387+ NodeAssert.match(messageID, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
4388+ NodeAssert.deepEqual(prompt, {
4389+ sessionID: "http://127.0.0.1:9999/session",
4390+ model: {
4391+ providerID: "anthropic",
4392+ modelID: "claude-sonnet-4-5",
4393+ },
4394+ agent: "github-copilot",
4395+ variant: "high",
4396+ parts: [{ type: "text", text: "Fix it" }],
4397+ });
4398+ }).pipe(Effect.provide(adapterLayer));
4399+ });
4400+
4401+ it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => {
4402+ const instanceId = ProviderInstanceId.make("opencode_zen");
4403+ const adapterLayer = Layer.effect(
4404+ OpenCodeAdapter,
4405+ makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
4406+ ).pipe(
4407+ Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
4408+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
4409+ Layer.provideMerge(ServerSettingsService.layerTest()),
4410+ Layer.provideMerge(providerSessionDirectoryTestLayer),
4411+ Layer.provideMerge(NodeServices.layer),
4412+ );
4413+
4414+ return Effect.gen(function* () {
4415+ const adapter = yield* OpenCodeAdapter;
4416+ const threadId = asThreadId("thread-custom-instance-fallback-model");
4417+ yield* adapter.startSession({
4418+ provider: ProviderDriverKind.make("opencode"),
4419+ threadId,
4420+ runtimeMode: "full-access",
4421+ modelSelection: createModelSelection(
4422+ ProviderInstanceId.make("opencode_zen"),
4423+ "anthropic/claude-sonnet-4-5",
4424+ ),
4425+ });
4426+
4427+ yield* adapter.sendTurn({
4428+ threadId,
4429+ input: "Fix it",
4430+ });
4431+
4432+ const { messageID, ...prompt } = runtimeMock.state.promptCalls.at(-1) as {
4433+ messageID: string;
4434+ [key: string]: unknown;
4435+ };
4436+ NodeAssert.match(messageID, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
4437+ NodeAssert.deepEqual(prompt, {
4438+ sessionID: "http://127.0.0.1:9999/session",
4439+ model: {
4440+ providerID: "anthropic",
4441+ modelID: "claude-sonnet-4-5",
4442+ },
4443+ parts: [{ type: "text", text: "Fix it" }],
4444+ });
4445+ }).pipe(Effect.provide(adapterLayer));
4446+ });
4447+
9014448 it.effect("rejects sendTurn model selections for another instance id", () => {
9024449 const instanceId = ProviderInstanceId.make("opencode_zen");
9034450 const adapterLayer = Layer.effect(
@@ -1289,6 +4836,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 385 unmodified lines
12894836 },
12904837 },
12914838 },
4839+ {
4840+ id: "evt-unrelated-child",
4841+ type: "session.created",
4842+ properties: {
4843+ sessionID: "ses_unrelated_child",
4844+ info: {
4845+ id: "ses_unrelated_child",
4846+ parentID: "ses_unrelated_parent",
4847+ title: "Unrelated child",
4848+ },
4849+ },
4850+ },
4851+ {
4852+ id: "evt-unrelated-permission",
4853+ type: "permission.asked",
4854+ properties: {
4855+ id: "per_unrelated",
4856+ sessionID: "ses_unrelated_child",
4857+ permission: "bash",
4858+ patterns: ["pwd"],
4859+ metadata: {},
4860+ always: [],
4861+ },
4862+ },
4863+ {
4864+ id: "evt-unrelated-question",
4865+ type: "question.asked",
4866+ properties: {
4867+ id: "que_unrelated",
4868+ sessionID: "ses_unrelated_child",
4869+ questions: [],
4870+ },
4871+ },
12924872 {
12934873 type: "message.updated",
12944874 properties: {
apps/server/src/provider/Layers/OpenCodeAdapter.ts+1665−248
@@ -15,13 +15,17 @@ import { · 14 unmodified lines
1515 import * as Cause from "effect/Cause";
1616 import * as Crypto from "effect/Crypto";
1717 import * as DateTime from "effect/DateTime";
18+import * as Deferred from "effect/Deferred";
1819 import * as Effect from "effect/Effect";
1920 import * as Exit from "effect/Exit";
2021 import * as FileSystem from "effect/FileSystem";
22+import * as Fiber from "effect/Fiber";
2123 import * as Path from "effect/Path";
2224 import * as Queue from "effect/Queue";
2325 import * as Ref from "effect/Ref";
26+import * as Schema from "effect/Schema";
2427 import * as Scope from "effect/Scope";
28+import * as Semaphore from "effect/Semaphore";
2529 import * as Stream from "effect/Stream";
2630 import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
2731 import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
@@ -175,6 +179,77 @@ type OpenCodeSubscribedEvent = · 147 unmodified lines
175179 ? TEvent
176180 : never;
177181
182+type OpenCodeSessionStatusEvent = Extract<
183+ OpenCodeSubscribedEvent,
184+ { readonly type: "session.status" }
185+>;
186+
187+const OpenCodeSessionStatusMap = Schema.Record(
188+ Schema.String,
189+ Schema.Struct({ type: Schema.String }),
190+);
191+const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap);
192+
193+interface OpenCodeCancellation {
194+ readonly turnId: TurnId | undefined;
195+ readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>;
196+ acknowledged?: boolean;
197+ deferredIdleEvent?: OpenCodeSessionStatusEvent;
198+}
199+
200+interface OpenCodeIdleReconciliation {
201+ readonly turnId: TurnId;
202+ readonly promptGeneration: number;
203+ raw: unknown;
204+ warned: boolean;
205+ dirty: boolean;
206+ fiber?: Fiber.Fiber<void, never>;
207+}
208+
209+interface OpenCodePromptAdmission {
210+ readonly generation: number;
211+ readonly turnId: TurnId;
212+ readonly messageId: string;
213+ readonly priorAwaitingBusy: boolean;
214+ readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined;
215+ idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined;
216+ idleObservedAfterMessage: boolean;
217+ messageObserved: boolean;
218+ busyObserved: boolean;
219+ idleStatusConfirmations: number;
220+ accepted: boolean;
221+ cancelled: boolean;
222+ readonly acceptance: Deferred.Deferred<void>;
223+ readonly submissionSettled: Deferred.Deferred<void>;
224+ promptFiber?: Fiber.Fiber<void, ProviderAdapterRequestError>;
225+ recoveryFiber?: Fiber.Fiber<void, never>;
226+ recoveryRaw: unknown;
227+}
228+
229+type OpenCodeTerminalRequestEvent = Extract<
230+ OpenCodeSubscribedEvent,
231+ {
232+ readonly type: "permission.replied" | "question.replied" | "question.rejected";
233+ }
234+>;
235+
236+type OpenCodeAskedRequestEvent = Extract<
237+ OpenCodeSubscribedEvent,
238+ { readonly type: "permission.asked" | "question.asked" }
239+>;
240+
241+type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent;
242+
243+interface OpenCodeRequestRelationRetry {
244+ warned: boolean;
245+ fiber?: Fiber.Fiber<void, never>;
246+}
247+
248+interface OpenCodePendingRequestRecovery {
249+ warned: boolean;
250+ rerun: boolean;
251+}
252+
178253 function trimText(value: string | undefined | null): string | undefined {
179254 const trimmed = value?.trim();
180255 return trimmed && trimmed.length > 0 ? trimmed : undefined;
@@ -214,6 +289,28 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und · 33 unmodified lines
214289 return title;
215290 }
216291
292+function isOpenCodeAbortError(error: unknown): boolean {
293+ return (
294+ typeof error === "object" &&
295+ error !== null &&
296+ "name" in error &&
297+ error.name === "MessageAbortedError"
298+ );
299+}
300+
301+function isOpenCodeChildRequestEvent(event: OpenCodeSubscribedEvent): boolean {
302+ switch (event.type) {
303+ case "permission.asked":
304+ case "permission.replied":
305+ case "question.asked":
306+ case "question.replied":
307+ case "question.rejected":
308+ return true;
309+ default:
310+ return false;
311+ }
312+}
313+
217314 const OPENCODE_DEFAULT_TITLE_PATTERN =
218315 /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
219316
@@ -227,6 +324,10 @@ interface OpenCodeSessionContext { · 7 unmodified lines
227324 readonly server: OpenCodeServerConnection;
228325 readonly directory: string;
229326 readonly openCodeSessionId: string;
327+ readonly relatedSessionIds: Set<string>;
328+ readonly resolvedRequestIds: Set<string>;
329+ readonly emittedTerminalRequestIds: Set<string>;
330+ readonly requestRelationRetries: Map<string, OpenCodeRequestRelationRetry>;
230331 readonly pendingPermissions: Map<string, PermissionRequest>;
231332 readonly pendingQuestions: Map<string, QuestionRequest>;
232333 readonly messageRoleById: Map<string, "user" | "assistant">;
@@ -237,6 +338,16 @@ interface OpenCodeSessionContext { · 4 unmodified lines
237338 activeTurnId: TurnId | undefined;
238339 activeAgent: string | undefined;
239340 activeVariant: string | undefined;
341+ cancellation: OpenCodeCancellation | undefined;
342+ interruptedTurnId: TurnId | undefined;
343+ reconcileIdleStatus: boolean;
344+ awaitingBusyAfterInterruption: boolean;
345+ pendingIdleReconciliation: OpenCodeIdleReconciliation | undefined;
346+ pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined;
347+ promptGeneration: number;
348+ promptAdmission: OpenCodePromptAdmission | undefined;
349+ readonly promptSemaphore: Semaphore.Semaphore;
350+ readonly firstConnection: Deferred.Deferred<void, ProviderAdapterRequestError>;
240351 /**
241352 * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`.
242353 * The session lifecycle is owned by `sessionScope`; this Ref exists only
@@ -537,24 +648,99 @@ function updateProviderSession( · 294 unmodified lines
537648 },
538649 ): Effect.Effect<ProviderSession> {
539650 return Effect.gen(function* () {
540− const updatedAt = yield* nowIso;
541− const nextSession = {
542− ...context.session,
543− ...patch,
544− updatedAt,
545− } as ProviderSession & Record<string, unknown>;
546− const mutableSession = nextSession as Record<string, unknown>;
547− if (options?.clearActiveTurnId) {
548− delete mutableSession.activeTurnId;
549− }
550− if (options?.clearLastError) {
551− delete mutableSession.lastError;
552− }
553− context.session = nextSession;
554− return nextSession;
651+ return applyProviderSessionUpdate(context, patch, options, yield* nowIso);
555652 });
556653 }
557654
655+function applyProviderSessionUpdate(
656+ context: OpenCodeSessionContext,
657+ patch: Partial<ProviderSession>,
658+ options:
659+ | {
660+ readonly clearActiveTurnId?: boolean;
661+ readonly clearLastError?: boolean;
662+ }
663+ | undefined,
664+ updatedAt: string,
665+): ProviderSession {
666+ const nextSession = {
667+ ...context.session,
668+ ...patch,
669+ updatedAt,
670+ } as ProviderSession & Record<string, unknown>;
671+ const mutableSession = nextSession as Record<string, unknown>;
672+ if (options?.clearActiveTurnId) {
673+ delete mutableSession.activeTurnId;
674+ }
675+ if (options?.clearLastError) {
676+ delete mutableSession.lastError;
677+ }
678+ context.session = nextSession;
679+ return nextSession;
680+}
681+
682+const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellation")(function* (
683+ context: OpenCodeSessionContext,
684+ detail: string,
685+) {
686+ const cancellation = context.cancellation;
687+ if (!cancellation) {
688+ return;
689+ }
690+ context.cancellation = undefined;
691+ yield* Deferred.fail(
692+ cancellation.completion,
693+ new ProviderAdapterRequestError({
694+ provider: PROVIDER,
695+ method: "session.abort",
696+ detail,
697+ }),
698+ ).pipe(Effect.ignore);
699+});
700+
701+const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) =>
702+ runOpenCodeSdk("session.abort", (signal) =>
703+ context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
704+ ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true }));
705+
706+const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* (
707+ context: OpenCodeSessionContext,
708+) {
709+ const admission = context.promptAdmission;
710+ if (!admission) {
711+ return;
712+ }
713+ admission.cancelled = true;
714+ if (admission.promptFiber) {
715+ yield* Fiber.interrupt(admission.promptFiber);
716+ }
717+ yield* Deferred.await(admission.submissionSettled);
718+});
719+
720+const closeStartingOpenCodeContext = Effect.fn("closeStartingOpenCodeContext")(function* (
721+ context: OpenCodeSessionContext,
722+ abortRemote: boolean,
723+) {
724+ if (yield* Ref.getAndSet(context.stopped, true)) {
725+ return;
726+ }
727+ yield* Deferred.fail(
728+ context.firstConnection,
729+ new ProviderAdapterRequestError({
730+ provider: PROVIDER,
731+ method: "event.subscribe",
732+ detail: "OpenCode session startup ended before the event stream connected.",
733+ }),
734+ ).pipe(Effect.ignore);
735+ yield* cancelPendingOpenCodePrompt(context);
736+ yield* failPendingOpenCodeCancellation(context, "OpenCode session startup was cancelled.");
737+ context.promptAdmission = undefined;
738+ if (abortRemote) {
739+ yield* abortOpenCodeSessionForTeardown(context);
740+ }
741+ yield* Scope.close(context.sessionScope, Exit.void).pipe(Effect.ignore);
742+});
743+
558744 const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
559745 context: OpenCodeSessionContext,
560746 ) {
@@ -562,13 +748,26 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( · 1 unmodified line
562748 if (yield* Ref.getAndSet(context.stopped, true)) {
563749 return false;
564750 }
751+ yield* Deferred.fail(
752+ context.firstConnection,
753+ new ProviderAdapterRequestError({
754+ provider: PROVIDER,
755+ method: "event.subscribe",
756+ detail: "OpenCode session stopped before the event stream connected.",
757+ }),
758+ ).pipe(Effect.ignore);
759+ yield* cancelPendingOpenCodePrompt(context);
760+ const cancellation = context.cancellation;
761+ context.cancellation = undefined;
762+ if (cancellation) {
763+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
764+ }
765+ context.promptAdmission = undefined;
565766
566767 // Best-effort remote abort. The scope close below tears down the local
567768 // handles (event-pump fiber, server-exit fiber, event-subscribe fetch),
568769 // but we still want to tell OpenCode that this session is done.
569− yield* runOpenCodeSdk("session.abort", () =>
570− context.client.session.abort({ sessionID: context.openCodeSessionId }),
571− ).pipe(Effect.ignore({ log: true }));
770+ yield* abortOpenCodeSessionForTeardown(context);
572771
573772 // Closing the session scope interrupts every fiber forked into it and
574773 // runs each finalizer we registered — the `AbortController.abort()` call,
@@ -603,6 +802,24 @@ export function makeOpenCodeAdapter( · 28 unmodified lines
603802 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
604803 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
605804 const sessions = new Map<ThreadId, OpenCodeSessionContext>();
805+ const deleteContextIfCurrent = (context: OpenCodeSessionContext) => {
806+ if (sessions.get(context.session.threadId) === context) {
807+ sessions.delete(context.session.threadId);
808+ }
809+ };
810+ const awaitOpenCodeContextReady = Effect.fn("awaitOpenCodeContextReady")(function* (
811+ context: OpenCodeSessionContext,
812+ ) {
813+ yield* Deferred.await(context.firstConnection);
814+ const current = yield* ensureSessionContext(sessions, context.session.threadId);
815+ if (current !== context) {
816+ return yield* new ProviderAdapterSessionClosedError({
817+ provider: PROVIDER,
818+ threadId: context.session.threadId,
819+ });
820+ }
821+ return current;
822+ });
606823 const randomUUIDv4 = crypto.randomUUIDv4.pipe(
607824 Effect.mapError(
608825 (cause) =>
@@ -614,6 +831,37 @@ export function makeOpenCodeAdapter( · 5 unmodified lines
614831 }),
615832 ),
616833 );
834+ let messageIdEpochMillis = -1;
835+ let messageIdCounter = 0;
836+ // T3 supplies the message ID to match prompt admission events. Keep OpenCode's sortable native shape so equal-time messages retain their upstream order.
837+ const makeOpenCodeMessageId = Effect.fn("makeOpenCodeMessageId")(function* () {
838+ const epochMillis = DateTime.toEpochMillis(yield* DateTime.now);
839+ if (epochMillis !== messageIdEpochMillis) {
840+ messageIdEpochMillis = epochMillis;
841+ messageIdCounter = 0;
842+ }
843+ messageIdCounter += 1;
844+ const encodedTime = BigInt.asUintN(
845+ 48,
846+ BigInt(epochMillis) * 0x1000n + BigInt(messageIdCounter),
847+ )
848+ .toString(16)
849+ .padStart(12, "0");
850+ const randomBytes = yield* crypto.randomBytes(14).pipe(
851+ Effect.mapError(
852+ (cause) =>
853+ new ProviderAdapterRequestError({
854+ provider: PROVIDER,
855+ method: "crypto/randomBytes",
856+ detail: "Failed to generate an OpenCode message identifier.",
857+ cause,
858+ }),
859+ ),
860+ );
861+ const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
862+ const random = Array.from(randomBytes, (byte) => alphabet[byte % alphabet.length]).join("");
863+ return `msg_${encodedTime}${random}`;
864+ });
617865 const buildEventBase = (input: EventBaseInput) =>
618866 Effect.all({
619867 eventId: randomUUIDv4.pipe(Effect.map(EventId.make)),
@@ -683,6 +931,424 @@ export function makeOpenCodeAdapter( · 63 unmodified lines
683931 },
684932 ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void));
685933
934+ const cancelIdleReconciliation = Effect.fn("cancelIdleReconciliation")(function* (
935+ context: OpenCodeSessionContext,
936+ ) {
937+ const pending = context.pendingIdleReconciliation;
938+ context.pendingIdleReconciliation = undefined;
939+ if (pending?.fiber) {
940+ yield* Fiber.interrupt(pending.fiber);
941+ }
942+ });
943+
944+ const completeOpenCodeTurn = Effect.fn("completeOpenCodeTurn")(function* (
945+ context: OpenCodeSessionContext,
946+ turnId: TurnId,
947+ promptGeneration: number,
948+ raw: unknown,
949+ ) {
950+ const updatedAt = yield* nowIso;
951+ const stopped = yield* Ref.get(context.stopped);
952+ if (
953+ stopped ||
954+ context.activeTurnId !== turnId ||
955+ context.promptGeneration !== promptGeneration ||
956+ context.cancellation?.turnId === turnId
957+ ) {
958+ return;
959+ }
960+ const pendingIdleReconciliation = context.pendingIdleReconciliation;
961+ if (
962+ pendingIdleReconciliation?.turnId === turnId &&
963+ pendingIdleReconciliation.promptGeneration === promptGeneration
964+ ) {
965+ context.pendingIdleReconciliation = undefined;
966+ }
967+ context.activeTurnId = undefined;
968+ context.activeAgent = undefined;
969+ context.activeVariant = undefined;
970+ context.interruptedTurnId = undefined;
971+ context.awaitingBusyAfterInterruption = false;
972+ context.reconcileIdleStatus = false;
973+ applyProviderSessionUpdate(
974+ context,
975+ { status: "ready" },
976+ { clearActiveTurnId: true },
977+ updatedAt,
978+ );
979+ if (pendingIdleReconciliation?.fiber) {
980+ yield* Fiber.interrupt(pendingIdleReconciliation.fiber);
981+ }
982+ yield* emit({
983+ ...(yield* buildEventBase({
984+ threadId: context.session.threadId,
985+ turnId,
986+ raw,
987+ })),
988+ type: "turn.completed",
989+ payload: {
990+ state: "completed",
991+ },
992+ });
993+ });
994+
995+ const scheduleIdleReconciliation = Effect.fn("scheduleIdleReconciliation")(function* (
996+ context: OpenCodeSessionContext,
997+ turnId: TurnId,
998+ raw: unknown,
999+ ) {
1000+ const existing = context.pendingIdleReconciliation;
1001+ if (existing?.turnId === turnId && existing.promptGeneration === context.promptGeneration) {
1002+ existing.raw = raw;
1003+ existing.dirty = true;
1004+ return;
1005+ }
1006+ yield* cancelIdleReconciliation(context);
1007+
1008+ const pending: OpenCodeIdleReconciliation = {
1009+ turnId,
1010+ promptGeneration: context.promptGeneration,
1011+ raw,
1012+ warned: false,
1013+ dirty: false,
1014+ };
1015+ context.pendingIdleReconciliation = pending;
1016+ const reconcile = Effect.gen(function* () {
1017+ let retryCount = 0;
1018+ while (context.pendingIdleReconciliation === pending) {
1019+ if (
1020+ context.activeTurnId !== turnId ||
1021+ context.awaitingBusyAfterInterruption ||
1022+ context.promptGeneration !== pending.promptGeneration
1023+ ) {
1024+ context.pendingIdleReconciliation = undefined;
1025+ return;
1026+ }
1027+ const result = yield* runOpenCodeSdk("session.status", (signal) =>
1028+ context.client.session.status(undefined, { signal }),
1029+ ).pipe(
1030+ Effect.timeout("1 second"),
1031+ Effect.retry({ times: 1 }),
1032+ Effect.match({
1033+ onFailure: (cause) => ({ type: "unknown" as const, cause }),
1034+ onSuccess: (response) => {
1035+ const data = Option.getOrUndefined(decodeOpenCodeSessionStatusMap(response.data));
1036+ if (data === undefined) {
1037+ return { type: "unknown" as const, cause: undefined };
1038+ }
1039+ const status = data[context.openCodeSessionId];
1040+ if (status === undefined || status.type === "idle") {
1041+ return { type: "idle" as const };
1042+ }
1043+ if (status.type === "busy" || status.type === "retry") {
1044+ return { type: "busy" as const };
1045+ }
1046+ return { type: "unknown" as const, cause: undefined };
1047+ },
1048+ }),
1049+ );
1050+
1051+ if (
1052+ context.pendingIdleReconciliation !== pending ||
1053+ context.activeTurnId !== turnId ||
1054+ context.promptGeneration !== pending.promptGeneration
1055+ ) {
1056+ return;
1057+ }
1058+ if (result.type === "idle") {
1059+ context.pendingIdleReconciliation = undefined;
1060+ yield* completeOpenCodeTurn(context, turnId, pending.promptGeneration, pending.raw);
1061+ return;
1062+ }
1063+ if (result.type === "busy") {
1064+ if (pending.dirty) {
1065+ pending.dirty = false;
1066+ continue;
1067+ }
1068+ context.pendingIdleReconciliation = undefined;
1069+ return;
1070+ }
1071+ if (!pending.warned) {
1072+ pending.warned = true;
1073+ yield* emit({
1074+ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId })),
1075+ type: "runtime.warning",
1076+ payload: {
1077+ message: "OpenCode turn completion is waiting for session status.",
1078+ detail:
1079+ result.cause === undefined
1080+ ? "session.status returned missing or invalid status data."
1081+ : openCodeRuntimeErrorDetail(result.cause),
1082+ },
1083+ });
1084+ }
1085+ const delayMs = Math.min(250 * 2 ** retryCount, 5_000);
1086+ retryCount += 1;
1087+ yield* Effect.sleep(`${delayMs} millis`);
1088+ }
1089+ }).pipe(
1090+ Effect.catchCause(() => Effect.void),
1091+ Effect.ensuring(
1092+ Effect.sync(() => {
1093+ if (context.pendingIdleReconciliation === pending) {
1094+ context.pendingIdleReconciliation = undefined;
1095+ }
1096+ }),
1097+ ),
1098+ );
1099+ pending.fiber = yield* reconcile.pipe(Effect.forkIn(context.sessionScope));
1100+ });
1101+
1102+ const failPromptAdmissionRecovery = Effect.fn("failPromptAdmissionRecovery")(function* (
1103+ context: OpenCodeSessionContext,
1104+ promptAdmission: OpenCodePromptAdmission,
1105+ ) {
1106+ if (
1107+ context.promptAdmission !== promptAdmission ||
1108+ context.activeTurnId !== promptAdmission.turnId ||
1109+ context.promptGeneration !== promptAdmission.generation
1110+ ) {
1111+ return;
1112+ }
1113+ const detail =
1114+ "OpenCode accepted the prompt, but T3 Code could not confirm its message or session status.";
1115+ const abortExit = yield* Effect.exit(
1116+ runOpenCodeSdk("session.abort", (signal) =>
1117+ context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
1118+ ).pipe(Effect.timeout("1 second")),
1119+ );
1120+ if (Exit.isFailure(abortExit)) {
1121+ yield* emitUnexpectedExit(
1122+ context,
1123+ `${detail} The cleanup abort also failed: ${openCodeRuntimeErrorDetail(Cause.squash(abortExit.cause))}`,
1124+ );
1125+ deleteContextIfCurrent(context);
1126+ return;
1127+ }
1128+ context.promptAdmission = undefined;
1129+ context.activeTurnId = undefined;
1130+ context.activeAgent = undefined;
1131+ context.activeVariant = undefined;
1132+ context.awaitingBusyAfterInterruption = false;
1133+ context.reconcileIdleStatus = false;
1134+ yield* updateProviderSession(
1135+ context,
1136+ { status: "error", lastError: detail },
1137+ { clearActiveTurnId: true },
1138+ );
1139+ yield* emit({
1140+ ...(yield* buildEventBase({
1141+ threadId: context.session.threadId,
1142+ turnId: promptAdmission.turnId,
1143+ raw: promptAdmission.recoveryRaw,
1144+ })),
1145+ type: "turn.completed",
1146+ payload: {
1147+ state: "failed",
1148+ errorMessage: detail,
1149+ },
1150+ });
1151+ yield* emit({
1152+ ...(yield* buildEventBase({
1153+ threadId: context.session.threadId,
1154+ turnId: promptAdmission.turnId,
1155+ raw: promptAdmission.recoveryRaw,
1156+ })),
1157+ type: "runtime.error",
1158+ payload: {
1159+ message: detail,
1160+ class: "transport_error",
1161+ },
1162+ });
1163+ });
1164+
1165+ const schedulePromptAdmissionRecovery = Effect.fn("schedulePromptAdmissionRecovery")(function* (
1166+ context: OpenCodeSessionContext,
1167+ raw: unknown,
1168+ ) {
1169+ const promptAdmission = context.promptAdmission;
1170+ if (!promptAdmission || promptAdmission.cancelled) {
1171+ return;
1172+ }
1173+ if (raw !== undefined) {
1174+ promptAdmission.recoveryRaw = raw;
1175+ }
1176+ if (promptAdmission.recoveryFiber) {
1177+ return;
1178+ }
1179+ const recover = Effect.gen(function* () {
1180+ yield* Deferred.await(promptAdmission.acceptance);
1181+ for (let retryCount = 0; retryCount < 5; retryCount += 1) {
1182+ if (
1183+ context.promptAdmission !== promptAdmission ||
1184+ context.activeTurnId !== promptAdmission.turnId ||
1185+ context.promptGeneration !== promptAdmission.generation ||
1186+ promptAdmission.cancelled ||
1187+ (yield* Ref.get(context.stopped))
1188+ ) {
1189+ return;
1190+ }
1191+
1192+ if (!promptAdmission.messageObserved) {
1193+ const response = yield* runOpenCodeSdk("session.message", (signal) =>
1194+ context.client.session.message(
1195+ {
1196+ sessionID: context.openCodeSessionId,
1197+ messageID: promptAdmission.messageId,
1198+ },
1199+ { signal },
1200+ ),
1201+ ).pipe(Effect.timeout("1 second"), Effect.option);
1202+ const stopped = yield* Ref.get(context.stopped);
1203+ if (
1204+ stopped ||
1205+ sessions.get(context.session.threadId) !== context ||
1206+ context.promptAdmission !== promptAdmission ||
1207+ context.activeTurnId !== promptAdmission.turnId ||
1208+ context.promptGeneration !== promptAdmission.generation ||
1209+ promptAdmission.cancelled
1210+ ) {
1211+ return;
1212+ }
1213+ const message = Option.isSome(response) ? response.value.data : undefined;
1214+ if (message?.info.id === promptAdmission.messageId && message.info.role === "user") {
1215+ promptAdmission.messageObserved = true;
1216+ context.messageRoleById.set(promptAdmission.messageId, "user");
1217+ }
1218+ }
1219+
1220+ const statusResponse = yield* runOpenCodeSdk("session.status", (signal) =>
1221+ context.client.session.status(undefined, { signal }),
1222+ ).pipe(Effect.timeout("1 second"), Effect.option);
1223+ const stopped = yield* Ref.get(context.stopped);
1224+ if (
1225+ stopped ||
1226+ sessions.get(context.session.threadId) !== context ||
1227+ context.promptAdmission !== promptAdmission ||
1228+ context.activeTurnId !== promptAdmission.turnId ||
1229+ context.promptGeneration !== promptAdmission.generation ||
1230+ promptAdmission.cancelled
1231+ ) {
1232+ return;
1233+ }
1234+ const statusData = Option.isSome(statusResponse)
1235+ ? Option.getOrUndefined(decodeOpenCodeSessionStatusMap(statusResponse.value.data))
1236+ : undefined;
1237+ const status = statusData?.[context.openCodeSessionId];
1238+ const isIdle =
1239+ statusData !== undefined && (status === undefined || status.type === "idle");
1240+ const isBusy = status?.type === "busy" || status?.type === "retry";
1241+ if (isBusy) {
1242+ promptAdmission.busyObserved = true;
1243+ promptAdmission.idleStatusConfirmations = 0;
1244+ context.awaitingBusyAfterInterruption = false;
1245+ context.promptAdmission = undefined;
1246+ return;
1247+ }
1248+
1249+ const idle = promptAdmission.idleDuringAdmission ?? promptAdmission.priorIdle;
1250+ if (
1251+ isIdle &&
1252+ idle !== undefined &&
1253+ (promptAdmission.messageObserved || promptAdmission.busyObserved)
1254+ ) {
1255+ context.promptAdmission = undefined;
1256+ context.awaitingBusyAfterInterruption = false;
1257+ yield* scheduleIdleReconciliation(context, promptAdmission.turnId, idle.raw);
1258+ return;
1259+ }
1260+ if (isIdle && promptAdmission.messageObserved) {
1261+ promptAdmission.idleStatusConfirmations += 1;
1262+ if (promptAdmission.idleStatusConfirmations >= 2) {
1263+ context.promptAdmission = undefined;
1264+ context.awaitingBusyAfterInterruption = false;
1265+ yield* completeOpenCodeTurn(
1266+ context,
1267+ promptAdmission.turnId,
1268+ promptAdmission.generation,
1269+ {
1270+ type: "session.status.recovered",
1271+ status: statusData,
1272+ },
1273+ );
1274+ return;
1275+ }
1276+ } else if (!isIdle) {
1277+ promptAdmission.idleStatusConfirmations = 0;
1278+ }
1279+ if (
1280+ isIdle &&
1281+ promptAdmission.messageObserved &&
1282+ promptAdmission.recoveryRaw !== undefined
1283+ ) {
1284+ context.promptAdmission = undefined;
1285+ context.awaitingBusyAfterInterruption = false;
1286+ yield* scheduleIdleReconciliation(
1287+ context,
1288+ promptAdmission.turnId,
1289+ promptAdmission.recoveryRaw,
1290+ );
1291+ return;
1292+ }
1293+
1294+ const delayMs = Math.min(250 * 2 ** retryCount, 2_000);
1295+ yield* Effect.sleep(`${delayMs} millis`);
1296+ }
1297+ yield* failPromptAdmissionRecovery(context, promptAdmission);
1298+ }).pipe(
1299+ Effect.catchCause(() => Effect.void),
1300+ Effect.ensuring(
1301+ Effect.sync(() => {
1302+ delete promptAdmission.recoveryFiber;
1303+ }),
1304+ ),
1305+ );
1306+ promptAdmission.recoveryFiber = yield* recover.pipe(Effect.forkIn(context.sessionScope));
1307+ });
1308+
1309+ const interruptOpenCodeTurn = Effect.fn("interruptOpenCodeTurn")(function* (
1310+ context: OpenCodeSessionContext,
1311+ turnId: TurnId,
1312+ raw?: unknown,
1313+ ) {
1314+ if (context.interruptedTurnId === turnId) {
1315+ return;
1316+ }
1317+ yield* cancelIdleReconciliation(context);
1318+ context.interruptedTurnId = turnId;
1319+ context.reconcileIdleStatus = true;
1320+ context.awaitingBusyAfterInterruption = false;
1321+ const cancellation =
1322+ context.cancellation?.turnId === turnId ? context.cancellation : undefined;
1323+ if (cancellation) {
1324+ context.cancellation = undefined;
1325+ }
1326+ if (context.activeTurnId === turnId) {
1327+ context.activeTurnId = undefined;
1328+ context.activeAgent = undefined;
1329+ context.activeVariant = undefined;
1330+ yield* updateProviderSession(
1331+ context,
1332+ { status: "ready" },
1333+ { clearActiveTurnId: true, clearLastError: true },
1334+ );
1335+ }
1336+ yield* emit({
1337+ ...(yield* buildEventBase({
1338+ threadId: context.session.threadId,
1339+ turnId,
1340+ raw,
1341+ })),
1342+ type: "turn.aborted",
1343+ payload: {
1344+ reason: "Interrupted by user.",
1345+ },
1346+ });
1347+ if (cancellation) {
1348+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
1349+ }
1350+ });
1351+
6861352 const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* (
6871353 context: OpenCodeSessionContext,
6881354 message: string,
@@ -694,8 +1360,21 @@ export function makeOpenCodeAdapter( · 5 unmodified lines
6941360 if (yield* Ref.getAndSet(context.stopped, true)) {
6951361 return;
6961362 }
1363+ yield* Deferred.fail(
1364+ context.firstConnection,
1365+ new ProviderAdapterRequestError({
1366+ provider: PROVIDER,
1367+ method: "event.subscribe",
1368+ detail: "OpenCode session exited before the event stream connected.",
1369+ }),
1370+ ).pipe(Effect.ignore);
1371+ yield* failPendingOpenCodeCancellation(
1372+ context,
1373+ "OpenCode session exited during cancellation.",
1374+ );
1375+ context.promptAdmission = undefined;
6971376 const turnId = context.activeTurnId;
698− sessions.delete(context.session.threadId);
1377+ deleteContextIfCurrent(context);
6991378 // Emit lifecycle events BEFORE tearing down the scope. Both call sites
7001379 // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`;
7011380 // closing that scope triggers the fiber-interrupt finalizer, so any
@@ -726,9 +1405,7 @@ export function makeOpenCodeAdapter( · 24 unmodified lines
7261405 // Inline the teardown that `stopOpenCodeContext` would do; we can't
7271406 // delegate to it because our `getAndSet` above already flipped the
7281407 // one-shot guard, so the call would no-op.
729− yield* runOpenCodeSdk("session.abort", () =>
730− context.client.session.abort({ sessionID: context.openCodeSessionId }),
731− ).pipe(Effect.ignore({ log: true }));
1408+ yield* abortOpenCodeSessionForTeardown(context);
7321409 yield* Scope.close(context.sessionScope, Exit.void);
7331410 });
7341411
@@ -799,35 +1476,447 @@ export function makeOpenCodeAdapter( · 64 unmodified lines
7991476 }
8001477 });
8011478
802− const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* (
1479+ const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* (
8031480 context: OpenCodeSessionContext,
804− event: OpenCodeSubscribedEvent,
1481+ candidateSessionId: string,
8051482 ) {
806− const payloadSessionId = openCodeEventSessionId(event);
807− if (payloadSessionId !== context.openCodeSessionId) {
808− return;
1483+ if (context.relatedSessionIds.has(candidateSessionId)) {
1484+ return true;
8091485 }
8101486
811− const turnId = context.activeTurnId;
812− yield* writeNativeEventBestEffort(context.session.threadId, {
813− observedAt: yield* nowIso,
814− event: {
815− provider: PROVIDER,
816− threadId: context.session.threadId,
817− providerThreadId: context.openCodeSessionId,
818− type: event.type,
819− ...(turnId ? { turnId } : {}),
820− payload: event,
821− },
822− });
1487+ const seen = new Set<string>();
1488+ const getSession = (sessionID: string) =>
1489+ runOpenCodeSdk("session.get", () => context.client.session.get({ sessionID })).pipe(
1490+ Effect.catchIf(
1491+ (cause) => isOpenCodeNotFound(cause),
1492+ () => Effect.succeed(undefined),
1493+ ),
1494+ );
1495+ let sessionId: string | undefined = candidateSessionId;
1496+ for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) {
1497+ if (context.relatedSessionIds.has(sessionId)) {
1498+ context.relatedSessionIds.add(candidateSessionId);
1499+ return true;
1500+ }
1501+ if (seen.has(sessionId)) {
1502+ return false;
1503+ }
1504+ seen.add(sessionId);
1505+ const currentSessionId: string = sessionId;
1506+ const response = yield* getSession(currentSessionId);
1507+ if (response === undefined) {
1508+ return false;
1509+ }
1510+ if (!response.data) {
1511+ return yield* new OpenCodeRuntimeError({
1512+ operation: "session.get",
1513+ detail: `OpenCode session.get returned no session payload for '${currentSessionId}'.`,
1514+ });
1515+ }
1516+ sessionId = response.data.parentID;
1517+ }
1518+ return false;
1519+ });
8231520
824− switch (event.type) {
825− case "session.updated": {
826− const title = openCodeEventSessionTitle(event);
827− if (title) {
828− yield* emit({
829− ...(yield* buildEventBase({
830− threadId: context.session.threadId,
1521+ const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* (
1522+ context: OpenCodeSessionContext,
1523+ event: OpenCodeAskedRequestEvent,
1524+ raw: unknown,
1525+ ) {
1526+ if (context.resolvedRequestIds.has(event.properties.id)) {
1527+ return;
1528+ }
1529+ if (event.type === "permission.asked") {
1530+ const request = event.properties;
1531+ if (context.pendingPermissions.has(request.id)) {
1532+ return;
1533+ }
1534+ context.pendingPermissions.set(request.id, request);
1535+ yield* emit({
1536+ ...(yield* buildEventBase({
1537+ threadId: context.session.threadId,
1538+ turnId: context.activeTurnId,
1539+ requestId: request.id,
1540+ raw,
1541+ })),
1542+ type: "request.opened",
1543+ payload: {
1544+ requestType: mapPermissionToRequestType(request.permission),
1545+ detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission,
1546+ args: request.metadata,
1547+ },
1548+ });
1549+ return;
1550+ }
1551+
1552+ const request = event.properties;
1553+ if (context.pendingQuestions.has(request.id)) {
1554+ return;
1555+ }
1556+ context.pendingQuestions.set(request.id, request);
1557+ yield* emit({
1558+ ...(yield* buildEventBase({
1559+ threadId: context.session.threadId,
1560+ turnId: context.activeTurnId,
1561+ requestId: request.id,
1562+ raw,
1563+ })),
1564+ type: "user-input.requested",
1565+ payload: { questions: normalizeQuestionRequest(request) },
1566+ });
1567+ });
1568+
1569+ const resolvePendingOpenCodeRequest = Effect.fn("resolvePendingOpenCodeRequest")(function* (
1570+ context: OpenCodeSessionContext,
1571+ requestId: string,
1572+ ) {
1573+ context.resolvedRequestIds.add(requestId);
1574+ const retry = context.requestRelationRetries.get(requestId);
1575+ context.requestRelationRetries.delete(requestId);
1576+ if (retry?.fiber) {
1577+ yield* Fiber.interrupt(retry.fiber);
1578+ }
1579+ });
1580+
1581+ const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* (
1582+ context: OpenCodeSessionContext,
1583+ event: OpenCodeTerminalRequestEvent,
1584+ ) {
1585+ const requestId = event.properties.requestID;
1586+ if (context.emittedTerminalRequestIds.has(requestId)) {
1587+ return;
1588+ }
1589+ context.emittedTerminalRequestIds.add(requestId);
1590+ if (event.type === "permission.replied") {
1591+ yield* emit({
1592+ ...(yield* buildEventBase({
1593+ threadId: context.session.threadId,
1594+ turnId: context.activeTurnId,
1595+ requestId,
1596+ raw: event,
1597+ })),
1598+ type: "request.resolved",
1599+ payload: {
1600+ requestType: "unknown",
1601+ decision: mapPermissionDecision(event.properties.reply),
1602+ },
1603+ });
1604+ return;
1605+ }
1606+
1607+ const request = context.pendingQuestions.get(requestId);
1608+ const answers =
1609+ event.type === "question.replied" && request
1610+ ? Object.fromEntries(
1611+ request.questions.map((question, index) => [
1612+ openCodeQuestionId(index, question),
1613+ event.properties.answers[index]?.join(", ") ?? "",
1614+ ]),
1615+ )
1616+ : {};
1617+ yield* emit({
1618+ ...(yield* buildEventBase({
1619+ threadId: context.session.threadId,
1620+ turnId: context.activeTurnId,
1621+ requestId,
1622+ raw: event,
1623+ })),
1624+ type: "user-input.resolved",
1625+ payload: { answers },
1626+ });
1627+ });
1628+
1629+ const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* (
1630+ context: OpenCodeSessionContext,
1631+ event: OpenCodeRoutedRequestEvent,
1632+ raw: unknown = event,
1633+ ) {
1634+ const isAskedEvent = event.type === "permission.asked" || event.type === "question.asked";
1635+ const requestId = isAskedEvent ? event.properties.id : event.properties.requestID;
1636+ if (context.requestRelationRetries.has(requestId)) {
1637+ return;
1638+ }
1639+ if (isAskedEvent && context.resolvedRequestIds.has(requestId)) {
1640+ return;
1641+ }
1642+ const retry: OpenCodeRequestRelationRetry = { warned: false };
1643+ context.requestRelationRetries.set(requestId, retry);
1644+ const run = Effect.gen(function* () {
1645+ let retryCount = 0;
1646+ while (context.requestRelationRetries.get(requestId) === retry) {
1647+ const relation = yield* isRelatedOpenCodeSession(
1648+ context,
1649+ event.properties.sessionID,
1650+ ).pipe(
1651+ Effect.match({
1652+ onFailure: (cause) => ({ type: "unknown" as const, cause }),
1653+ onSuccess: (related) => ({ type: "known" as const, related }),
1654+ }),
1655+ );
1656+ if (context.requestRelationRetries.get(requestId) !== retry) {
1657+ return;
1658+ }
1659+ if (relation.type === "known") {
1660+ context.requestRelationRetries.delete(requestId);
1661+ if (relation.related) {
1662+ if (isAskedEvent) {
1663+ yield* emitPendingOpenCodeRequest(context, event, raw);
1664+ } else {
1665+ yield* emitTerminalOpenCodeRequest(context, event);
1666+ }
1667+ }
1668+ return;
1669+ }
1670+ if (!retry.warned) {
1671+ retry.warned = true;
1672+ yield* emit({
1673+ ...(yield* buildEventBase({
1674+ threadId: context.session.threadId,
1675+ requestId,
1676+ })),
1677+ type: "runtime.warning",
1678+ payload: {
1679+ message: "OpenCode request routing is waiting for session ancestry.",
1680+ detail: openCodeRuntimeErrorDetail(relation.cause),
1681+ },
1682+ });
1683+ }
1684+ const delayMs = Math.min(250 * 2 ** retryCount, 5_000);
1685+ retryCount += 1;
1686+ if (!isAskedEvent && retryCount >= 5) {
1687+ return;
1688+ }
1689+ yield* Effect.sleep(`${delayMs} millis`);
1690+ }
1691+ }).pipe(
1692+ Effect.catchCause(() => Effect.void),
1693+ Effect.ensuring(
1694+ Effect.sync(() => {
1695+ if (context.requestRelationRetries.get(requestId) === retry) {
1696+ context.requestRelationRetries.delete(requestId);
1697+ }
1698+ }),
1699+ ),
1700+ );
1701+ retry.fiber = yield* run.pipe(Effect.forkIn(context.sessionScope));
1702+ });
1703+
1704+ const schedulePendingRequestRecovery = Effect.fn("schedulePendingRequestRecovery")(function* (
1705+ context: OpenCodeSessionContext,
1706+ ) {
1707+ if (context.pendingRequestRecovery) {
1708+ context.pendingRequestRecovery.rerun = true;
1709+ return;
1710+ }
1711+ const recovery: OpenCodePendingRequestRecovery = { warned: false, rerun: false };
1712+ context.pendingRequestRecovery = recovery;
1713+ const run = Effect.gen(function* () {
1714+ let retryCount = 0;
1715+ while (context.pendingRequestRecovery === recovery) {
1716+ const responses = yield* Effect.all({
1717+ permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()),
1718+ questions: runOpenCodeSdk("question.list", () => context.client.question.list()),
1719+ }).pipe(
1720+ Effect.match({
1721+ onFailure: (cause) => ({ type: "failure" as const, cause }),
1722+ onSuccess: (value) => ({ type: "success" as const, value }),
1723+ }),
1724+ );
1725+ if (context.pendingRequestRecovery !== recovery) {
1726+ return;
1727+ }
1728+ if (responses.type === "failure") {
1729+ if (!recovery.warned) {
1730+ recovery.warned = true;
1731+ yield* emit({
1732+ ...(yield* buildEventBase({ threadId: context.session.threadId })),
1733+ type: "runtime.warning",
1734+ payload: {
1735+ message: "OpenCode pending request recovery failed and will retry.",
1736+ detail: openCodeRuntimeErrorDetail(responses.cause),
1737+ },
1738+ });
1739+ }
1740+ const delayMs = Math.min(250 * 2 ** retryCount, 5_000);
1741+ retryCount += 1;
1742+ yield* Effect.sleep(`${delayMs} millis`);
1743+ continue;
1744+ }
1745+ const permissions = responses.value.permissions.data;
1746+ const questions = responses.value.questions.data;
1747+ if (permissions === undefined || questions === undefined) {
1748+ if (!recovery.warned) {
1749+ recovery.warned = true;
1750+ yield* emit({
1751+ ...(yield* buildEventBase({ threadId: context.session.threadId })),
1752+ type: "runtime.warning",
1753+ payload: {
1754+ message: "OpenCode pending request recovery returned no data and will retry.",
1755+ },
1756+ });
1757+ }
1758+ const delayMs = Math.min(250 * 2 ** retryCount, 5_000);
1759+ retryCount += 1;
1760+ yield* Effect.sleep(`${delayMs} millis`);
1761+ continue;
1762+ }
1763+ yield* Effect.forEach(
1764+ permissions,
1765+ (request) =>
1766+ scheduleRequestRelationRetry(
1767+ context,
1768+ { id: `recovered:${request.id}`, type: "permission.asked", properties: request },
1769+ { type: "permission.asked", properties: request, recovered: true },
1770+ ),
1771+ { discard: true },
1772+ );
1773+ yield* Effect.forEach(
1774+ questions,
1775+ (request) =>
1776+ scheduleRequestRelationRetry(
1777+ context,
1778+ { id: `recovered:${request.id}`, type: "question.asked", properties: request },
1779+ { type: "question.asked", properties: request, recovered: true },
1780+ ),
1781+ { discard: true },
1782+ );
1783+ if (recovery.rerun) {
1784+ recovery.rerun = false;
1785+ recovery.warned = false;
1786+ continue;
1787+ }
1788+ context.pendingRequestRecovery = undefined;
1789+ return;
1790+ }
1791+ }).pipe(
1792+ Effect.catchCause(() => Effect.void),
1793+ Effect.ensuring(
1794+ Effect.sync(() => {
1795+ if (context.pendingRequestRecovery === recovery) {
1796+ context.pendingRequestRecovery = undefined;
1797+ }
1798+ }),
1799+ ),
1800+ );
1801+ yield* run.pipe(Effect.forkIn(context.sessionScope));
1802+ });
1803+
1804+ const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* (
1805+ context: OpenCodeSessionContext,
1806+ event: OpenCodeSubscribedEvent,
1807+ ) {
1808+ if (event.type === "server.connected") {
1809+ if (
1810+ (yield* Ref.get(context.stopped)) ||
1811+ sessions.get(context.session.threadId) !== context
1812+ ) {
1813+ return;
1814+ }
1815+ const isFirstConnection = !(yield* Deferred.isDone(context.firstConnection));
1816+ if (isFirstConnection) {
1817+ const updatedAt = yield* nowIso;
1818+ if (
1819+ (yield* Ref.get(context.stopped)) ||
1820+ sessions.get(context.session.threadId) !== context
1821+ ) {
1822+ return;
1823+ }
1824+ applyProviderSessionUpdate(context, { status: "ready" }, undefined, updatedAt);
1825+ if (!(yield* Deferred.succeed(context.firstConnection, undefined))) {
1826+ return;
1827+ }
1828+ }
1829+ yield* schedulePendingRequestRecovery(context);
1830+ if (!isFirstConnection) {
1831+ yield* schedulePromptAdmissionRecovery(context, event);
1832+ }
1833+ return;
1834+ }
1835+ const terminalRequestId =
1836+ event.type === "permission.replied" ||
1837+ event.type === "question.replied" ||
1838+ event.type === "question.rejected"
1839+ ? event.properties.requestID
1840+ : undefined;
1841+ if (terminalRequestId !== undefined) {
1842+ yield* resolvePendingOpenCodeRequest(context, terminalRequestId);
1843+ }
1844+ if (event.type === "session.created" || event.type === "session.updated") {
1845+ const session = event.properties.info;
1846+ if (session.parentID && context.relatedSessionIds.has(session.parentID)) {
1847+ context.relatedSessionIds.add(session.id);
1848+ }
1849+ } else if (event.type === "session.deleted") {
1850+ context.relatedSessionIds.delete(event.properties.info.id);
1851+ }
1852+
1853+ const payloadSessionId = openCodeEventSessionId(event);
1854+ const isParentEvent = payloadSessionId === context.openCodeSessionId;
1855+ let isKnownPendingTerminalEvent = false;
1856+ if (
1857+ payloadSessionId !== undefined &&
1858+ !context.relatedSessionIds.has(payloadSessionId) &&
1859+ isOpenCodeChildRequestEvent(event)
1860+ ) {
1861+ if (event.type === "permission.asked") {
1862+ yield* scheduleRequestRelationRetry(context, event);
1863+ } else if (event.type === "question.asked") {
1864+ yield* scheduleRequestRelationRetry(context, event);
1865+ } else if (
1866+ event.type === "permission.replied" ||
1867+ event.type === "question.replied" ||
1868+ event.type === "question.rejected"
1869+ ) {
1870+ const requestId = event.properties.requestID;
1871+ isKnownPendingTerminalEvent =
1872+ context.pendingPermissions.has(requestId) || context.pendingQuestions.has(requestId);
1873+ if (!isKnownPendingTerminalEvent) {
1874+ yield* scheduleRequestRelationRetry(context, event);
1875+ return;
1876+ }
1877+ }
1878+ }
1879+ const isChildRequestEvent =
1880+ payloadSessionId !== undefined &&
1881+ isOpenCodeChildRequestEvent(event) &&
1882+ (context.relatedSessionIds.has(payloadSessionId) || isKnownPendingTerminalEvent);
1883+ if (!isParentEvent && !isChildRequestEvent) {
1884+ return;
1885+ }
1886+
1887+ const turnId = context.activeTurnId;
1888+ yield* writeNativeEventBestEffort(context.session.threadId, {
1889+ observedAt: yield* nowIso,
1890+ event: {
1891+ provider: PROVIDER,
1892+ threadId: context.session.threadId,
1893+ providerThreadId: context.openCodeSessionId,
1894+ type: event.type,
1895+ ...(turnId ? { turnId } : {}),
1896+ ...(!isParentEvent && payloadSessionId ? { childSessionId: payloadSessionId } : {}),
1897+ payload: event,
1898+ },
1899+ });
1900+
1901+ const suppressInterruptedParentOutput =
1902+ isParentEvent &&
1903+ ((context.activeTurnId === undefined &&
1904+ (context.interruptedTurnId !== undefined || context.reconcileIdleStatus)) ||
1905+ context.awaitingBusyAfterInterruption) &&
1906+ (event.type === "message.part.delta" ||
1907+ event.type === "message.part.updated" ||
1908+ (event.type === "message.updated" && event.properties.info.role === "assistant"));
1909+ if (suppressInterruptedParentOutput) {
1910+ return;
1911+ }
1912+
1913+ switch (event.type) {
1914+ case "session.updated": {
1915+ const title = openCodeEventSessionTitle(event);
1916+ if (title) {
1917+ yield* emit({
1918+ ...(yield* buildEventBase({
1919+ threadId: context.session.threadId,
8311920 raw: event,
8321921 })),
8331922 type: "thread.metadata.updated",
@@ -843,6 +1932,24 @@ export function makeOpenCodeAdapter( · 9 unmodified lines
8431932 }
8441933
8451934 case "message.updated": {
1935+ const promptAdmission = context.promptAdmission;
1936+ if (
1937+ event.properties.info.role === "user" &&
1938+ promptAdmission?.messageId === event.properties.info.id
1939+ ) {
1940+ promptAdmission.messageObserved = true;
1941+ if (promptAdmission.accepted) {
1942+ const idle = promptAdmission.idleDuringAdmission;
1943+ context.awaitingBusyAfterInterruption = false;
1944+ context.promptAdmission = undefined;
1945+ if (promptAdmission.recoveryFiber) {
1946+ yield* Fiber.interrupt(promptAdmission.recoveryFiber);
1947+ }
1948+ if (idle) {
1949+ yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw);
1950+ }
1951+ }
1952+ }
8461953 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
8471954 if (event.properties.info.role === "assistant") {
8481955 for (const part of context.partById.values()) {
@@ -956,101 +2063,44 @@ export function makeOpenCodeAdapter( · 107 unmodified lines
9562063 }
9572064
9582065 case "permission.asked": {
959− context.pendingPermissions.set(event.properties.id, event.properties);
960− yield* emit({
961− ...(yield* buildEventBase({
962− threadId: context.session.threadId,
963− turnId,
964− requestId: event.properties.id,
965− raw: event,
966− })),
967− type: "request.opened",
968− payload: {
969− requestType: mapPermissionToRequestType(event.properties.permission),
970− detail:
971− event.properties.patterns.length > 0
972− ? event.properties.patterns.join("\n")
973− : event.properties.permission,
974− args: event.properties.metadata,
975− },
976− });
2066+ yield* emitPendingOpenCodeRequest(context, event, event);
9772067 break;
9782068 }
9792069
9802070 case "permission.replied": {
9812071 context.pendingPermissions.delete(event.properties.requestID);
982− yield* emit({
983− ...(yield* buildEventBase({
984− threadId: context.session.threadId,
985− turnId,
986− requestId: event.properties.requestID,
987− raw: event,
988− })),
989− type: "request.resolved",
990− payload: {
991− requestType: "unknown",
992− decision: mapPermissionDecision(event.properties.reply),
993− },
994− });
2072+ yield* emitTerminalOpenCodeRequest(context, event);
9952073 break;
9962074 }
9972075
9982076 case "question.asked": {
999− context.pendingQuestions.set(event.properties.id, event.properties);
1000− yield* emit({
1001− ...(yield* buildEventBase({
1002− threadId: context.session.threadId,
1003− turnId,
1004− requestId: event.properties.id,
1005− raw: event,
1006− })),
1007− type: "user-input.requested",
1008− payload: {
1009− questions: normalizeQuestionRequest(event.properties),
1010− },
1011− });
2077+ yield* emitPendingOpenCodeRequest(context, event, event);
10122078 break;
10132079 }
10142080
10152081 case "question.replied": {
1016− const request = context.pendingQuestions.get(event.properties.requestID);
2082+ yield* emitTerminalOpenCodeRequest(context, event);
10172083 context.pendingQuestions.delete(event.properties.requestID);
1018− const answers = Object.fromEntries(
1019− (request?.questions ?? []).map((question, index) => [
1020− openCodeQuestionId(index, question),
1021− event.properties.answers[index]?.join(", ") ?? "",
1022− ]),
1023− );
1024− yield* emit({
1025− ...(yield* buildEventBase({
1026− threadId: context.session.threadId,
1027− turnId,
1028− requestId: event.properties.requestID,
1029− raw: event,
1030− })),
1031− type: "user-input.resolved",
1032− payload: { answers },
1033− });
10342084 break;
10352085 }
10362086
10372087 case "question.rejected": {
10382088 context.pendingQuestions.delete(event.properties.requestID);
1039− yield* emit({
1040− ...(yield* buildEventBase({
1041− threadId: context.session.threadId,
1042− turnId,
1043− requestId: event.properties.requestID,
1044− raw: event,
1045− })),
1046− type: "user-input.resolved",
1047− payload: { answers: {} },
1048− });
2089+ yield* emitTerminalOpenCodeRequest(context, event);
10492090 break;
10502091 }
10512092
10522093 case "session.status": {
10532094 if (event.properties.status.type === "busy") {
2095+ if (turnId === undefined) {
2096+ break;
2097+ }
2098+ yield* cancelIdleReconciliation(context);
2099+ context.awaitingBusyAfterInterruption = false;
2100+ if (context.promptAdmission?.turnId === turnId) {
2101+ context.promptAdmission.busyObserved = true;
2102+ yield* schedulePromptAdmissionRecovery(context, event);
2103+ }
10542104 yield* updateProviderSession(context, {
10552105 status: "running",
10562106 activeTurnId: turnId,
@@ -1074,19 +2124,25 @@ export function makeOpenCodeAdapter( · 17 unmodified lines
10742124 }
10752125
10762126 if (event.properties.status.type === "idle" && turnId) {
1077− context.activeTurnId = undefined;
1078− yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true });
1079− yield* emit({
1080− ...(yield* buildEventBase({
1081− threadId: context.session.threadId,
1082− turnId,
1083− raw: event,
1084− })),
1085− type: "turn.completed",
1086− payload: {
1087− state: "completed",
1088− },
1089− });
2127+ if (context.cancellation?.turnId === turnId) {
2128+ context.cancellation.deferredIdleEvent = event;
2129+ break;
2130+ }
2131+ if (context.promptAdmission?.turnId === turnId) {
2132+ context.promptAdmission.idleDuringAdmission = { turnId, raw: event };
2133+ context.promptAdmission.idleObservedAfterMessage =
2134+ context.promptAdmission.messageObserved;
2135+ yield* schedulePromptAdmissionRecovery(context, event);
2136+ break;
2137+ }
2138+ if (context.awaitingBusyAfterInterruption) {
2139+ break;
2140+ }
2141+ if (context.reconcileIdleStatus) {
2142+ yield* scheduleIdleReconciliation(context, turnId, event);
2143+ break;
2144+ }
2145+ yield* completeOpenCodeTurn(context, turnId, context.promptGeneration, event);
10902146 }
10912147 break;
10922148 }
@@ -1094,7 +2150,32 @@ export function makeOpenCodeAdapter( · 1 unmodified line
10942150 case "session.error": {
10952151 const message = sessionErrorMessage(event.properties.error);
10962152 const activeTurnId = context.activeTurnId;
2153+ const cancellation = context.cancellation;
2154+ if (isOpenCodeAbortError(event.properties.error)) {
2155+ if (cancellation !== undefined && cancellation.turnId === undefined) {
2156+ cancellation.acknowledged = true;
2157+ context.cancellation = undefined;
2158+ context.reconcileIdleStatus = true;
2159+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2160+ break;
2161+ }
2162+ if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) {
2163+ yield* interruptOpenCodeTurn(context, activeTurnId, event);
2164+ break;
2165+ }
2166+ if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) {
2167+ break;
2168+ }
2169+ }
2170+ yield* cancelIdleReconciliation(context);
2171+ if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) {
2172+ context.cancellation = undefined;
2173+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2174+ }
10972175 context.activeTurnId = undefined;
2176+ context.activeAgent = undefined;
2177+ context.activeVariant = undefined;
2178+ context.reconcileIdleStatus = false;
10982179 yield* updateProviderSession(
10992180 context,
11002181 {
@@ -1210,8 +2291,11 @@ export function makeOpenCodeAdapter( · 109 unmodified lines
12102291 const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId;
12112292 const existing = sessions.get(input.threadId);
12122293 if (existing) {
2294+ if (existing.session.status === "connecting" && !(yield* Ref.get(existing.stopped))) {
2295+ return (yield* awaitOpenCodeContextReady(existing)).session;
2296+ }
12132297 yield* stopOpenCodeContext(existing);
1214− sessions.delete(input.threadId);
2298+ deleteContextIfCurrent(existing);
12152299 }
12162300
12172301 const started = yield* Effect.gen(function* () {
@@ -1223,13 +2307,15 @@ export function makeOpenCodeAdapter( · 5 unmodified lines
12232307 // process automatically. No manual `server.close()` needed.
12242308 const server = yield* openCodeRuntime.connectToOpenCodeServer({
12252309 binaryPath,
2310+ directory,
12262311 serverUrl,
2312+ ...(serverPassword ? { serverPassword } : {}),
12272313 ...(options?.environment ? { environment: options.environment } : {}),
12282314 });
12292315 const client = openCodeRuntime.createOpenCodeSdkClient({
12302316 baseUrl: server.url,
12312317 directory,
1232− ...(server.external && serverPassword ? { serverPassword } : {}),
2318+ ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}),
12332319 });
12342320 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
12352321 if (mcpSession && !server.external) {
@@ -1348,29 +2434,11 @@ export function makeOpenCodeAdapter( · 112 unmodified lines
13482434 return startedExit.value;
13492435 });
13502436
1351− // Guard against a concurrent startSession call that may have raced
1352− // and already inserted a session while we were awaiting async work.
1353− const raceWinner = sessions.get(input.threadId);
1354− if (raceWinner) {
1355− // Another call won the race — clean up. Only abort the remote
1356− // session if we created it here; a resumed one is shared upstream
1357− // state the winner is now using.
1358− if (started.created) {
1359− yield* runOpenCodeSdk("session.abort", () =>
1360− started.client.session.abort({
1361− sessionID: started.openCodeSession.id,
1362− }),
1363− ).pipe(Effect.ignore);
1364− }
1365− yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore);
1366− return raceWinner.session;
1367− }
1368−
13692437 const createdAt = yield* nowIso;
13702438 const session: ProviderSession = {
13712439 provider: PROVIDER,
13722440 providerInstanceId: boundInstanceId,
1373− status: "ready",
2441+ status: "connecting",
13742442 runtimeMode: input.runtimeMode,
13752443 cwd: directory,
13762444 ...(input.modelSelection ? { model: input.modelSelection.model } : {}),
@@ -1392,6 +2460,10 @@ export function makeOpenCodeAdapter( · 15 unmodified lines
13922460 server: started.server,
13932461 directory,
13942462 openCodeSessionId: started.openCodeSession.id,
2463+ relatedSessionIds: new Set([started.openCodeSession.id]),
2464+ resolvedRequestIds: new Set(),
2465+ emittedTerminalRequestIds: new Set(),
2466+ requestRelationRetries: new Map(),
13952467 pendingPermissions: new Map(),
13962468 pendingQuestions: new Map(),
13972469 partById: new Map(),
@@ -1402,11 +2474,56 @@ export function makeOpenCodeAdapter( · 4 unmodified lines
14022474 activeTurnId: undefined,
14032475 activeAgent: undefined,
14042476 activeVariant: undefined,
2477+ cancellation: undefined,
2478+ interruptedTurnId: undefined,
2479+ reconcileIdleStatus: false,
2480+ awaitingBusyAfterInterruption: false,
2481+ pendingIdleReconciliation: undefined,
2482+ pendingRequestRecovery: undefined,
2483+ promptGeneration: 0,
2484+ promptAdmission: undefined,
2485+ promptSemaphore: Semaphore.makeUnsafe(1),
2486+ firstConnection: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(),
14052487 stopped: yield* Ref.make(false),
14062488 sessionScope: started.sessionScope,
14072489 };
2490+ const raceWinner = sessions.get(input.threadId);
2491+ if (raceWinner) {
2492+ // Another start published first. A newly created remote session
2493+ // belongs to this loser; a resumed session is shared upstream state.
2494+ yield* closeStartingOpenCodeContext(context, started.created);
2495+ return (yield* awaitOpenCodeContextReady(raceWinner)).session;
2496+ }
14082497 sessions.set(input.threadId, context);
1409− yield* startEventPump(context);
2498+ const cleanupStartingContext = closeStartingOpenCodeContext(context, started.created).pipe(
2499+ Effect.ensuring(Effect.sync(() => deleteContextIfCurrent(context))),
2500+ );
2501+ const connectionExit = yield* Effect.gen(function* () {
2502+ yield* startEventPump(context);
2503+ yield* Deferred.await(context.firstConnection).pipe(
2504+ Effect.timeout("10 seconds"),
2505+ Effect.mapError(
2506+ (cause) =>
2507+ new ProviderAdapterRequestError({
2508+ provider: PROVIDER,
2509+ method: "event.subscribe",
2510+ detail: "OpenCode event stream did not connect within 10 seconds.",
2511+ cause,
2512+ }),
2513+ ),
2514+ );
2515+ }).pipe(
2516+ Effect.onInterrupt(() => cleanupStartingContext),
2517+ Effect.exit,
2518+ );
2519+ if (Exit.isFailure(connectionExit)) {
2520+ yield* cleanupStartingContext;
2521+ return yield* Effect.failCause(connectionExit.cause);
2522+ }
2523+ yield* awaitOpenCodeContextReady(context);
2524+ if (!started.created) {
2525+ yield* schedulePendingRequestRecovery(context);
2526+ }
14102527
14112528 yield* emit({
14122529 ...(yield* buildEventBase({ threadId: input.threadId })),
@@ -1423,17 +2540,13 @@ export function makeOpenCodeAdapter( · 10 unmodified lines
14232540 },
14242541 });
14252542
1426− return session;
2543+ return context.session;
14272544 },
14282545 );
14292546
14302547 const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
14312548 const context = yield* ensureSessionContext(sessions, input.threadId);
1432− // A sendTurn while a turn is active is a steer: OpenCode queues the
1433− // prompt into the busy session and the work continues as one turn, so
1434− // the active turn id is reused instead of opening a new turn.
1435− const steeringTurnId = context.activeTurnId;
1436− const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`);
2549+ yield* awaitOpenCodeContextReady(context);
14372550 const modelSelection =
14382551 input.modelSelection ??
14392552 (context.session.model
@@ -1474,107 +2587,411 @@ export function makeOpenCodeAdapter( · 34 unmodified lines
14742587 });
14752588 }
14762589
1477− const agent = getModelSelectionStringOptionValue(modelSelection, "agent");
1478− const variant = getModelSelectionStringOptionValue(modelSelection, "variant");
2590+ return yield* context.promptSemaphore.withPermit(
2591+ Effect.gen(function* () {
2592+ const freshTurnId = TurnId.make(`opencode-turn-${yield* randomUUIDv4}`);
2593+ const messageId = yield* makeOpenCodeMessageId();
2594+ const pendingCancellation = context.cancellation;
2595+ if (pendingCancellation) {
2596+ const cancellationResult = yield* Deferred.await(pendingCancellation.completion).pipe(
2597+ Effect.result,
2598+ );
2599+ if ((yield* Ref.get(context.stopped)) || sessions.get(input.threadId) !== context) {
2600+ return yield* Effect.interrupt;
2601+ }
2602+ if (cancellationResult._tag === "Failure") {
2603+ return yield* cancellationResult.failure;
2604+ }
2605+ }
2606+ if (sessions.get(input.threadId) !== context || (yield* Ref.get(context.stopped))) {
2607+ return yield* Effect.interrupt;
2608+ }
2609+ // A sendTurn while a turn is active is a steer. OpenCode queues the
2610+ // prompt into the running session, so the active turn id is reused.
2611+ const steeringTurnId = context.activeTurnId;
2612+ const turnId = steeringTurnId ?? freshTurnId;
2613+ const agent = getModelSelectionStringOptionValue(modelSelection, "agent");
2614+ const variant = getModelSelectionStringOptionValue(modelSelection, "variant");
2615+ const pendingIdleReconciliation = context.pendingIdleReconciliation;
2616+ const priorAwaitingBusy = context.awaitingBusyAfterInterruption;
2617+ const priorIdleCandidate = pendingIdleReconciliation
2618+ ? {
2619+ turnId: pendingIdleReconciliation.turnId,
2620+ raw: pendingIdleReconciliation.raw,
2621+ }
2622+ : undefined;
2623+ context.pendingIdleReconciliation = undefined;
2624+ const promptGeneration = context.promptGeneration + 1;
2625+ const promptAdmission: OpenCodePromptAdmission = {
2626+ generation: promptGeneration,
2627+ turnId,
2628+ messageId,
2629+ priorAwaitingBusy,
2630+ priorIdle: priorIdleCandidate,
2631+ idleDuringAdmission: undefined,
2632+ idleObservedAfterMessage: false,
2633+ messageObserved: false,
2634+ busyObserved: false,
2635+ idleStatusConfirmations: 0,
2636+ accepted: false,
2637+ cancelled: false,
2638+ acceptance: Deferred.makeUnsafe<void>(),
2639+ submissionSettled: Deferred.makeUnsafe<void>(),
2640+ recoveryRaw: undefined,
2641+ };
2642+ context.promptGeneration = promptGeneration;
2643+ context.promptAdmission = promptAdmission;
2644+
2645+ context.activeTurnId = turnId;
2646+ context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
2647+ context.activeVariant = variant;
2648+ if (steeringTurnId === undefined) {
2649+ context.awaitingBusyAfterInterruption = context.interruptedTurnId !== undefined;
2650+ }
2651+ if (pendingIdleReconciliation?.fiber) {
2652+ yield* Fiber.interrupt(pendingIdleReconciliation.fiber);
2653+ }
2654+ yield* updateProviderSession(
2655+ context,
2656+ {
2657+ status: "running",
2658+ activeTurnId: turnId,
2659+ model: modelSelection?.model ?? context.session.model,
2660+ },
2661+ { clearLastError: true },
2662+ );
14792663
1480− context.activeTurnId = turnId;
1481− context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
1482− context.activeVariant = variant;
1483− yield* updateProviderSession(
1484− context,
1485− {
1486− status: "running",
1487− activeTurnId: turnId,
1488− model: modelSelection?.model ?? context.session.model,
1489− },
1490− { clearLastError: true },
1491− );
2664+ if (steeringTurnId === undefined) {
2665+ yield* emit({
2666+ ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
2667+ type: "turn.started",
2668+ payload: {
2669+ model: modelSelection?.model ?? context.session.model,
2670+ ...(variant ? { effort: variant } : {}),
2671+ },
2672+ });
2673+ }
14922674
1493− if (steeringTurnId === undefined) {
1494− yield* emit({
1495− ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
1496− type: "turn.started",
1497− payload: {
1498− model: modelSelection?.model ?? context.session.model,
1499− ...(variant ? { effort: variant } : {}),
1500− },
1501− });
1502− }
2675+ if (promptAdmission.cancelled || (yield* Ref.get(context.stopped))) {
2676+ yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe(
2677+ Effect.ignore,
2678+ );
2679+ const cancellation = context.cancellation;
2680+ if (cancellation?.turnId === turnId) {
2681+ yield* Deferred.await(cancellation.completion).pipe(Effect.result);
2682+ }
2683+ return yield* Effect.interrupt;
2684+ }
15032685
1504− yield* runOpenCodeSdk("session.promptAsync", () =>
1505− context.client.session.promptAsync({
1506− sessionID: context.openCodeSessionId,
1507− model: parsedModel,
1508− ...(context.activeAgent ? { agent: context.activeAgent } : {}),
1509− ...(context.activeVariant ? { variant: context.activeVariant } : {}),
1510− parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
1511− }),
1512− ).pipe(
1513− Effect.mapError(toRequestError),
1514− // On failure of a fresh turn: clear active-turn state, flip the
1515− // session back to ready with lastError set, emit turn.aborted, then
1516− // let the typed error propagate. We don't need to rebuild the error
1517− // here — `toRequestError` already produced the right shape. A failed
1518− // steer leaves the still-running original turn untouched.
1519− Effect.tapError((requestError) =>
1520− steeringTurnId !== undefined
1521− ? Effect.void
1522− : Effect.gen(function* () {
1523− context.activeTurnId = undefined;
1524− context.activeAgent = undefined;
1525− context.activeVariant = undefined;
1526− yield* updateProviderSession(
1527− context,
1528− {
1529− status: "ready",
1530− model: modelSelection?.model ?? context.session.model,
1531− lastError: requestError.detail,
1532− },
1533− { clearActiveTurnId: true },
2686+ let promptTimedOut = false;
2687+ const promptEffect = runOpenCodeSdk("session.promptAsync", (signal) =>
2688+ context.client.session.promptAsync(
2689+ {
2690+ sessionID: context.openCodeSessionId,
2691+ messageID: messageId,
2692+ model: parsedModel,
2693+ ...(context.activeAgent ? { agent: context.activeAgent } : {}),
2694+ ...(context.activeVariant ? { variant: context.activeVariant } : {}),
2695+ parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
2696+ },
2697+ { signal },
2698+ ),
2699+ ).pipe(
2700+ Effect.timeout("10 seconds"),
2701+ Effect.catchTags({
2702+ OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
2703+ TimeoutError: (cause) => {
2704+ promptTimedOut = true;
2705+ return Effect.fail(
2706+ new ProviderAdapterRequestError({
2707+ provider: PROVIDER,
2708+ method: "session.promptAsync",
2709+ detail: "OpenCode prompt submission did not complete within 10 seconds.",
2710+ cause,
2711+ }),
15342712 );
1535− yield* emit({
1536− ...(yield* buildEventBase({
1537− threadId: input.threadId,
1538− turnId,
1539− })),
1540− type: "turn.aborted",
1541− payload: {
1542− reason: requestError.detail,
1543− },
1544− });
2713+ },
2714+ }),
2715+ Effect.tapError((requestError) =>
2716+ context.promptAdmission !== promptAdmission || context.activeTurnId !== turnId
2717+ ? Effect.void
2718+ : Effect.gen(function* () {
2719+ if (!promptTimedOut) {
2720+ if (steeringTurnId !== undefined) {
2721+ context.promptAdmission = undefined;
2722+ context.awaitingBusyAfterInterruption = promptAdmission.priorAwaitingBusy;
2723+ const idle =
2724+ promptAdmission.idleDuringAdmission ?? promptAdmission.priorIdle;
2725+ if (idle) {
2726+ yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw);
2727+ }
2728+ return;
2729+ }
2730+ context.promptAdmission = undefined;
2731+ context.activeTurnId = undefined;
2732+ context.activeAgent = undefined;
2733+ context.activeVariant = undefined;
2734+ yield* updateProviderSession(
2735+ context,
2736+ {
2737+ status: "ready",
2738+ model: modelSelection?.model ?? context.session.model,
2739+ lastError: requestError.detail,
2740+ },
2741+ { clearActiveTurnId: true },
2742+ );
2743+ yield* emit({
2744+ ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
2745+ type: "turn.aborted",
2746+ payload: { reason: requestError.detail },
2747+ });
2748+ return;
2749+ }
2750+ const cleanupExit = yield* Effect.exit(
2751+ runOpenCodeSdk("session.abort", (signal) =>
2752+ context.client.session.abort(
2753+ { sessionID: context.openCodeSessionId },
2754+ { signal },
2755+ ),
2756+ ).pipe(Effect.timeout("1 second")),
2757+ );
2758+ if (Exit.isFailure(cleanupExit)) {
2759+ yield* emit({
2760+ ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
2761+ type: "runtime.warning",
2762+ payload: {
2763+ message:
2764+ "OpenCode prompt submission failed and its cleanup abort did not complete.",
2765+ detail: openCodeRuntimeErrorDetail(Cause.squash(cleanupExit.cause)),
2766+ },
2767+ });
2768+ yield* schedulePromptAdmissionRecovery(context, {
2769+ requestError,
2770+ cleanupError: Cause.squash(cleanupExit.cause),
2771+ });
2772+ return;
2773+ }
2774+ context.promptAdmission = undefined;
2775+ context.activeTurnId = undefined;
2776+ context.activeAgent = undefined;
2777+ context.activeVariant = undefined;
2778+ context.awaitingBusyAfterInterruption = false;
2779+ context.reconcileIdleStatus = false;
2780+ yield* updateProviderSession(
2781+ context,
2782+ {
2783+ status: "ready",
2784+ model: modelSelection?.model ?? context.session.model,
2785+ lastError: requestError.detail,
2786+ },
2787+ { clearActiveTurnId: true },
2788+ );
2789+ yield* emit({
2790+ ...(yield* buildEventBase({
2791+ threadId: input.threadId,
2792+ turnId,
2793+ })),
2794+ type: "turn.aborted",
2795+ payload: {
2796+ reason: requestError.detail,
2797+ },
2798+ });
2799+ }),
2800+ ),
2801+ Effect.onExit((exit) =>
2802+ Effect.gen(function* () {
2803+ yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe(
2804+ Effect.ignore,
2805+ );
2806+ if (Exit.isFailure(exit)) {
2807+ yield* Deferred.succeed(promptAdmission.acceptance, undefined).pipe(
2808+ Effect.ignore,
2809+ );
2810+ }
15452811 }),
1546− ),
1547− );
2812+ ),
2813+ Effect.asVoid,
2814+ );
2815+ const promptFiber = yield* promptEffect.pipe(Effect.forkIn(context.sessionScope));
2816+ promptAdmission.promptFiber = promptFiber;
2817+ const promptExit = yield* Effect.exit(Fiber.join(promptFiber));
2818+ delete promptAdmission.promptFiber;
2819+
2820+ const intentionallyCancelled =
2821+ promptAdmission.cancelled ||
2822+ (yield* Ref.get(context.stopped)) ||
2823+ sessions.get(input.threadId) !== context;
2824+ if (Exit.isFailure(promptExit) && !intentionallyCancelled) {
2825+ return yield* Effect.failCause(promptExit.cause);
2826+ }
2827+ const cancelled =
2828+ intentionallyCancelled ||
2829+ context.activeTurnId !== turnId ||
2830+ context.promptGeneration !== promptAdmission.generation;
2831+ if (cancelled) {
2832+ const cancellation = context.cancellation;
2833+ if (cancellation?.turnId === turnId) {
2834+ yield* Deferred.await(cancellation.completion).pipe(Effect.result);
2835+ }
2836+ if (context.promptAdmission === promptAdmission) {
2837+ context.promptAdmission = undefined;
2838+ }
2839+ return yield* Effect.interrupt;
2840+ }
2841+ promptAdmission.accepted = true;
2842+ yield* Deferred.succeed(promptAdmission.acceptance, undefined).pipe(Effect.ignore);
2843+ if (
2844+ context.promptAdmission === promptAdmission &&
2845+ context.activeTurnId === turnId &&
2846+ context.promptGeneration === promptAdmission.generation &&
2847+ promptAdmission.messageObserved
2848+ ) {
2849+ context.awaitingBusyAfterInterruption = false;
2850+ const idle = promptAdmission.idleDuringAdmission;
2851+ if (idle && !promptAdmission.idleObservedAfterMessage) {
2852+ yield* schedulePromptAdmissionRecovery(context, idle.raw);
2853+ } else {
2854+ context.promptAdmission = undefined;
2855+ }
2856+ if (idle && promptAdmission.idleObservedAfterMessage) {
2857+ yield* scheduleIdleReconciliation(context, turnId, idle.raw);
2858+ }
2859+ } else {
2860+ yield* schedulePromptAdmissionRecovery(context, promptAdmission.recoveryRaw);
2861+ }
15482862
1549− return {
1550− threadId: input.threadId,
1551− turnId,
1552− // Re-surface the durable cursor on every turn so the persisted binding
1553− // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex).
1554− ...(context.session.resumeCursor !== undefined
1555− ? { resumeCursor: context.session.resumeCursor }
1556− : {}),
1557− };
2863+ const stopped = yield* Ref.get(context.stopped);
2864+ const finalCancellation = context.cancellation;
2865+ if (
2866+ stopped ||
2867+ sessions.get(input.threadId) !== context ||
2868+ promptAdmission.cancelled ||
2869+ context.activeTurnId !== turnId ||
2870+ context.promptGeneration !== promptAdmission.generation ||
2871+ finalCancellation?.turnId === turnId
2872+ ) {
2873+ if (finalCancellation?.turnId === turnId) {
2874+ yield* Deferred.await(finalCancellation.completion).pipe(Effect.result);
2875+ }
2876+ if (context.promptAdmission === promptAdmission) {
2877+ context.promptAdmission = undefined;
2878+ }
2879+ return yield* Effect.interrupt;
2880+ }
2881+
2882+ return {
2883+ threadId: input.threadId,
2884+ turnId,
2885+ // Re-surface the durable cursor on every turn so the persisted binding
2886+ // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex).
2887+ ...(context.session.resumeCursor !== undefined
2888+ ? { resumeCursor: context.session.resumeCursor }
2889+ : {}),
2890+ };
2891+ }),
2892+ );
15582893 });
15592894
15602895 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
15612896 function* (threadId, turnId) {
15622897 const context = yield* ensureSessionContext(sessions, threadId);
1563− yield* runOpenCodeSdk("session.abort", () =>
1564− context.client.session.abort({ sessionID: context.openCodeSessionId }),
1565− ).pipe(Effect.mapError(toRequestError));
1566− if (turnId ?? context.activeTurnId) {
1567− yield* emit({
1568− ...(yield* buildEventBase({
1569− threadId,
1570− turnId: turnId ?? context.activeTurnId,
1571− })),
1572− type: "turn.aborted",
1573− payload: {
1574− reason: "Interrupted by user.",
1575− },
1576− });
2898+ const activeTurnId = context.activeTurnId;
2899+ if (turnId !== undefined && activeTurnId !== turnId) {
2900+ return;
2901+ }
2902+ const interruptedTurnId = turnId ?? activeTurnId;
2903+ yield* cancelIdleReconciliation(context);
2904+ if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) {
2905+ return;
2906+ }
2907+ const existingCancellation = context.cancellation;
2908+ if (
2909+ existingCancellation !== undefined &&
2910+ existingCancellation.turnId === interruptedTurnId
2911+ ) {
2912+ return yield* Deferred.await(existingCancellation.completion);
2913+ }
2914+ const cancellation: OpenCodeCancellation = {
2915+ turnId: interruptedTurnId,
2916+ completion: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(),
2917+ };
2918+ context.cancellation = cancellation;
2919+ const promptAdmission = context.promptAdmission;
2920+ if (promptAdmission !== undefined && promptAdmission.turnId === interruptedTurnId) {
2921+ promptAdmission.cancelled = true;
2922+ if (promptAdmission.promptFiber) {
2923+ yield* Fiber.interrupt(promptAdmission.promptFiber);
2924+ }
2925+ yield* Deferred.await(promptAdmission.submissionSettled);
2926+ }
2927+
2928+ const abortOutcome = yield* Effect.raceFirst(
2929+ runOpenCodeSdk("session.abort", (signal) =>
2930+ context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
2931+ ).pipe(
2932+ Effect.timeout("10 seconds"),
2933+ Effect.catchTags({
2934+ OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
2935+ TimeoutError: (cause) =>
2936+ Effect.fail(
2937+ new ProviderAdapterRequestError({
2938+ provider: PROVIDER,
2939+ method: "session.abort",
2940+ detail: "OpenCode session abort did not complete within 10 seconds.",
2941+ cause,
2942+ }),
2943+ ),
2944+ }),
2945+ Effect.exit,
2946+ Effect.map((exit) => ({ source: "request" as const, exit })),
2947+ ),
2948+ Deferred.await(cancellation.completion).pipe(
2949+ Effect.exit,
2950+ Effect.map((exit) => ({ source: "event" as const, exit })),
2951+ ),
2952+ );
2953+ if (abortOutcome.source === "event") {
2954+ return Exit.isFailure(abortOutcome.exit)
2955+ ? yield* Effect.failCause(abortOutcome.exit.cause)
2956+ : undefined;
2957+ }
2958+ const abortExit = abortOutcome.exit;
2959+ if (Exit.isFailure(abortExit)) {
2960+ if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) {
2961+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2962+ return;
2963+ }
2964+ if (cancellation.turnId === undefined && cancellation.acknowledged) {
2965+ if (context.cancellation === cancellation) {
2966+ context.cancellation = undefined;
2967+ context.reconcileIdleStatus = true;
2968+ }
2969+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2970+ return;
2971+ }
2972+ if (context.cancellation === cancellation) {
2973+ context.cancellation = undefined;
2974+ if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) {
2975+ yield* scheduleIdleReconciliation(
2976+ context,
2977+ cancellation.turnId,
2978+ cancellation.deferredIdleEvent,
2979+ );
2980+ }
2981+ }
2982+ yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore);
2983+ return yield* Effect.failCause(abortExit.cause);
2984+ }
2985+
2986+ if (context.cancellation === cancellation) {
2987+ if (cancellation.turnId !== undefined) {
2988+ yield* interruptOpenCodeTurn(context, cancellation.turnId);
2989+ } else {
2990+ context.cancellation = undefined;
2991+ context.reconcileIdleStatus = true;
2992+ }
15772993 }
2994+ yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
15782995 },
15792996 );
15802997
@@ -1629,7 +3046,7 @@ export function makeOpenCodeAdapter( · 48 unmodified lines
16293046 });
16303047 }
16313048 const stopped = yield* stopOpenCodeContext(context);
1632− sessions.delete(threadId);
3049+ deleteContextIfCurrent(context);
16333050 if (!stopped) {
16343051 return;
16353052 }
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+148−23
@@ -12,8 +12,10 @@ import { ServerConfig } from "../../config.ts"; · 11 unmodified lines
1212 import {
1313 OpenCodeRuntime,
1414 OpenCodeRuntimeError,
15+ resolveOpenCodeServerPassword,
1516 type OpenCodeRuntimeShape,
1617 } from "../opencodeRuntime.ts";
18+import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
1719 import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts";
1820 import type { OpenCodeInventory } from "../opencodeRuntime.ts";
1921 const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
@@ -34,8 +36,14 @@ const runtimeMock = { · 14 unmodified lines
3436 runVersionError: null as Error | null,
3537 versionStdout: DEFAULT_VERSION_STDOUT,
3638 inventoryError: null as Error | null,
39+ connectionError: null as Error | null,
3740 inventoryCwd: null as string | null,
3841 closeCalls: 0,
42+ sdkClientInputs: [] as Array<{
43+ baseUrl: string;
44+ directory: string;
45+ serverPassword?: string;
46+ }>,
3947 inventory: {
4048 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
4149 agents: [] as unknown[],
@@ -46,8 +54,10 @@ const runtimeMock = { · 4 unmodified lines
4654 this.state.runVersionError = null;
4755 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
4856 this.state.inventoryError = null;
57+ this.state.connectionError = null;
4958 this.state.inventoryCwd = null;
5059 this.state.closeCalls = 0;
60+ this.state.sdkClientInputs.length = 0;
5161 this.state.inventory = {
5262 providerList: { connected: [], all: [] as unknown[], default: {} },
5363 agents: [] as unknown[],
@@ -57,13 +67,37 @@ const runtimeMock = { · 3 unmodified lines
5767 };
5868
5969 const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
60− startOpenCodeServerProcess: () =>
61− Effect.succeed({
62− url: "http://127.0.0.1:4301",
63− exitCode: Effect.never,
70+ startOpenCodeServerProcess: ({ serverPassword, environment }) =>
71+ Effect.gen(function* () {
72+ yield* Effect.addFinalizer(() =>
73+ Effect.sync(() => {
74+ runtimeMock.state.closeCalls += 1;
75+ }),
76+ );
77+ const effectiveServerPassword = resolveOpenCodeServerPassword({
78+ external: false,
79+ ...(serverPassword !== undefined ? { serverPassword } : {}),
80+ ...(environment !== undefined ? { environment } : {}),
81+ });
82+ return {
83+ url: "http://127.0.0.1:4301",
84+ ...(effectiveServerPassword !== undefined
85+ ? { serverPassword: effectiveServerPassword }
86+ : {}),
87+ version: "1.14.19",
88+ isRunning: Effect.succeed(true),
89+ exitCode: Effect.never,
90+ };
6491 }),
65− connectToOpenCodeServer: ({ serverUrl }) =>
92+ connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
6693 Effect.gen(function* () {
94+ if (runtimeMock.state.connectionError) {
95+ return yield* new OpenCodeRuntimeError({
96+ operation: "global.health",
97+ detail: runtimeMock.state.connectionError.message,
98+ cause: runtimeMock.state.connectionError,
99+ });
100+ }
67101 if (!serverUrl) {
68102 yield* Effect.addFinalizer(() =>
69103 Effect.sync(() => {
@@ -73,6 +107,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 3 unmodified lines
73107 }
74108 return {
75109 url: serverUrl ?? "http://127.0.0.1:4301",
110+ ...(serverPassword ? { serverPassword } : {}),
111+ version: "1.14.19",
76112 exitCode: null,
77113 external: Boolean(serverUrl),
78114 };
@@ -87,8 +123,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 8 unmodified lines
87123 }),
88124 )
89125 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
90− createOpenCodeSdkClient: () =>
91− ({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
126+ createOpenCodeSdkClient: (input) => {
127+ runtimeMock.state.sdkClientInputs.push(input);
128+ return {} as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>;
129+ },
92130 loadOpenCodeInventory: () =>
93131 runtimeMock.state.inventoryError
94132 ? Effect.fail(
@@ -132,11 +170,31 @@ const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSe · 37 unmodified lines
132170 ...overrides,
133171 });
134172
173+const checkProvider = Effect.fn("checkProvider")(function* (
174+ settings: OpenCodeSettings,
175+ cwd = process.cwd(),
176+ environment?: NodeJS.ProcessEnv,
177+) {
178+ return yield* Effect.scoped(
179+ Effect.gen(function* () {
180+ const serverOwner = yield* OpenCodeServerOwner.make({
181+ binaryPath: settings.binaryPath,
182+ directory: cwd,
183+ ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
184+ ...(environment ? { environment } : {}),
185+ });
186+ return yield* checkOpenCodeProviderStatus(settings, cwd, environment).pipe(
187+ Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
188+ );
189+ }),
190+ );
191+});
192+
135193 it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
136194 it.effect("shows a codex-style missing binary message", () =>
137195 Effect.gen(function* () {
138196 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
139− const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
197+ const snapshot = yield* checkProvider(makeOpenCodeSettings());
140198
141199 NodeAssert.equal(snapshot.status, "error");
142200 NodeAssert.equal(snapshot.installed, false);
@@ -150,7 +208,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { · 7 unmodified lines
150208 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
151209 Effect.gen(function* () {
152210 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
153− const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
211+ const snapshot = yield* checkProvider(makeOpenCodeSettings());
154212
155213 NodeAssert.equal(snapshot.status, "error");
156214 NodeAssert.equal(snapshot.installed, true);
@@ -190,7 +248,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { · 33 unmodified lines
190248 ],
191249 };
192250
193− const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
251+ const snapshot = yield* checkProvider(makeOpenCodeSettings());
194252 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
195253
196254 NodeAssert.ok(model);
@@ -253,7 +311,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { · 56 unmodified lines
253311 ],
254312 };
255313
256− const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
314+ const snapshot = yield* checkProvider(makeOpenCodeSettings());
257315
258316 NodeAssert.deepEqual(
259317 snapshot.skills.map((skill) => ({
@@ -280,41 +338,109 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { · 20 unmodified lines
280338 }),
281339 );
282340
283− it.effect("does not spawn a local server for health check (uses CLI instead)", () =>
341+ it.effect("loads local inventory from a scoped OpenCode server", () =>
342+ Effect.gen(function* () {
343+ yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" }));
344+
345+ NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
346+ {
347+ baseUrl: "http://127.0.0.1:4301",
348+ directory: process.cwd(),
349+ serverPassword: "secret-password",
350+ },
351+ ]);
352+ NodeAssert.equal(runtimeMock.state.closeCalls, 1);
353+ NodeAssert.equal(runtimeMock.state.inventoryCwd, null);
354+ }),
355+ );
356+
357+ it.effect("uses an environment-only password for local inventory", () =>
284358 Effect.gen(function* () {
285− yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
359+ yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
360+ OPENCODE_SERVER_PASSWORD: "environment-password",
361+ });
286362
287− NodeAssert.equal(runtimeMock.state.closeCalls, 0);
288− NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd());
363+ NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
364+ {
365+ baseUrl: "http://127.0.0.1:4301",
366+ directory: process.cwd(),
367+ serverPassword: "environment-password",
368+ },
369+ ]);
370+ }),
371+ );
372+
373+ it.effect("uses the settings password when local environment auth differs", () =>
374+ Effect.gen(function* () {
375+ yield* checkProvider(
376+ makeOpenCodeSettings({ serverPassword: "settings-password" }),
377+ process.cwd(),
378+ { OPENCODE_SERVER_PASSWORD: "environment-password" },
379+ );
380+
381+ NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password");
289382 }),
290383 );
291384
292385 it.effect("reports local model inventory failures without treating them as empty", () =>
293386 Effect.gen(function* () {
294387 runtimeMock.state.inventoryError = new Error("opencode models failed");
295− const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
388+ const snapshot = yield* checkProvider(makeOpenCodeSettings());
296389
297390 NodeAssert.equal(snapshot.status, "error");
298391 NodeAssert.equal(snapshot.installed, true);
299392 NodeAssert.equal(snapshot.models.length, 0);
300393 NodeAssert.equal(
301394 snapshot.message,
302− "Failed to execute OpenCode CLI health check: opencode models failed",
395+ "Failed to load OpenCode provider inventory: opencode models failed",
303396 );
304397 }),
305398 );
306399 });
307400
308401 it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => {
402+ it.effect("does not send a local environment password to a configured server", () =>
403+ Effect.gen(function* () {
404+ const snapshot = yield* checkProvider(
405+ makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
406+ process.cwd(),
407+ { OPENCODE_SERVER_PASSWORD: "local-secret" },
408+ );
409+
410+ NodeAssert.equal(snapshot.version, "1.14.19");
411+ NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
412+ {
413+ baseUrl: "http://127.0.0.1:9999",
414+ directory: process.cwd(),
415+ },
416+ ]);
417+ }),
418+ );
419+
420+ it.effect("rejects an unsupported server before loading inventory", () =>
421+ Effect.gen(function* () {
422+ runtimeMock.state.connectionError = new Error(
423+ "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
424+ );
425+ const snapshot = yield* checkProvider(
426+ makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
427+ );
428+
429+ NodeAssert.equal(snapshot.status, "error");
430+ NodeAssert.equal(snapshot.models.length, 0);
431+ NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
432+ NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
433+ }),
434+ );
435+
309436 it.effect("surfaces a friendly auth error for configured servers", () =>
310437 Effect.gen(function* () {
311− runtimeMock.state.inventoryError = new Error("401 Unauthorized");
312− const snapshot = yield* checkOpenCodeProviderStatus(
438+ runtimeMock.state.connectionError = new Error("401 Unauthorized");
439+ const snapshot = yield* checkProvider(
313440 makeOpenCodeSettings({
314441 serverUrl: "http://127.0.0.1:9999",
315442 serverPassword: "secret-password",
316443 }),
317− process.cwd(),
318444 );
319445
320446 NodeAssert.equal(snapshot.status, "error");
@@ -328,15 +454,14 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i · 7 unmodified lines
328454
329455 it.effect("surfaces a friendly connection error for configured servers", () =>
330456 Effect.gen(function* () {
331− runtimeMock.state.inventoryError = new Error(
457+ runtimeMock.state.connectionError = new Error(
332458 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
333459 );
334− const snapshot = yield* checkOpenCodeProviderStatus(
460+ const snapshot = yield* checkProvider(
335461 makeOpenCodeSettings({
336462 serverUrl: "http://127.0.0.1:9999",
337463 serverPassword: "secret-password",
338464 }),
339− process.cwd(),
340465 );
341466
342467 NodeAssert.equal(snapshot.status, "error");
apps/server/src/provider/Layers/OpenCodeProvider.ts+52−34
@@ -19,17 +19,18 @@ import { · 18 unmodified lines
1919 type ServerProviderDraft,
2020 } from "../providerSnapshot.ts";
2121 import {
22+ MINIMUM_OPENCODE_VERSION,
2223 OpenCodeRuntime,
2324 openCodeRuntimeErrorDetail,
2425 type OpenCodeInventory,
2526 } from "../opencodeRuntime.ts";
2627 import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
28+import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
2729
2830 const OPENCODE_PRESENTATION = {
2931 displayName: "OpenCode",
3032 showInteractionModeToggle: false,
3133 } as const;
32−const MINIMUM_OPENCODE_VERSION = "1.14.19";
3334
3435 class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
3536 readonly cause: unknown;
@@ -65,6 +66,7 @@ function normalizedErrorMessage(cause: unknown): string | undefined { · 29 unmodified lines
6566 function formatOpenCodeProbeError(input: {
6667 readonly cause: unknown;
6768 readonly isExternalServer: boolean;
69+ readonly phase: "version" | "inventory";
6870 readonly serverUrl: string;
6971 }): { readonly installed: boolean; readonly message: string } {
7072 const detail = normalizedErrorMessage(input.cause);
@@ -127,11 +129,13 @@ function formatOpenCodeProbeError(input: { · 56 unmodified lines
127129 };
128130 }
129131
132+ const failureLabel =
133+ input.phase === "inventory"
134+ ? "Failed to load OpenCode provider inventory"
135+ : "Failed to execute OpenCode CLI health check";
130136 return {
131137 installed: true,
132− message: detail
133− ? `Failed to execute OpenCode CLI health check: ${detail}`
134− : "Failed to execute OpenCode CLI health check.",
138+ message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`,
135139 };
136140 }
137141
@@ -326,17 +330,27 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu · 188 unmodified lines
326330 openCodeSettings: OpenCodeSettings,
327331 cwd: string,
328332 environment?: NodeJS.ProcessEnv,
329−): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> {
333+): Effect.fn.Return<
334+ ServerProviderDraft,
335+ never,
336+ OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner
337+> {
330338 const openCodeRuntime = yield* OpenCodeRuntime;
339+ const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
331340 const resolvedEnvironment = environment ?? process.env;
332341 const checkedAt = DateTime.formatIso(yield* DateTime.now);
333342 const customModels = openCodeSettings.customModels;
334343 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
335344
336− const fallback = (cause: unknown, version: string | null = null) => {
345+ const fallback = (
346+ cause: unknown,
347+ version: string | null = null,
348+ phase: "version" | "inventory" = "version",
349+ ) => {
337350 const failure = formatOpenCodeProbeError({
338351 cause,
339352 isExternalServer,
353+ phase,
340354 serverUrl: openCodeSettings.serverUrl,
341355 });
342356 return buildServerProvider({
@@ -417,48 +431,52 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu · 74 unmodified lines
417431 }
418432 }
419433
420− const inventoryExit = yield* Effect.exit(
421− (isExternalServer
422− ? Effect.scoped(
423− Effect.gen(function* () {
424− const server = yield* openCodeRuntime.connectToOpenCodeServer({
425− binaryPath: openCodeSettings.binaryPath,
426− serverUrl: openCodeSettings.serverUrl,
427− environment: resolvedEnvironment,
428− });
429− return yield* openCodeRuntime.loadOpenCodeInventory(
430− openCodeRuntime.createOpenCodeSdkClient({
431− baseUrl: server.url,
432− directory: cwd,
433− ...(openCodeSettings.serverPassword
434− ? { serverPassword: openCodeSettings.serverPassword }
435− : {}),
436− }),
437− );
438− }),
439− )
440− : openCodeRuntime.loadInventoryFromCli({
434+ const loadInventory = (server: {
435+ readonly url: string;
436+ readonly serverPassword?: string;
437+ readonly version: string;
438+ }) =>
439+ openCodeRuntime
440+ .loadOpenCodeInventory(
441+ openCodeRuntime.createOpenCodeSdkClient({
442+ baseUrl: server.url,
443+ directory: cwd,
444+ ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
445+ }),
446+ )
447+ .pipe(Effect.map((inventory) => ({ inventory, version: server.version })));
448+ const inventoryEffect = isExternalServer
449+ ? openCodeRuntime
450+ .connectToOpenCodeServer({
441451 binaryPath: openCodeSettings.binaryPath,
442− cwd,
443− environment: resolvedEnvironment,
452+ directory: cwd,
453+ serverUrl: openCodeSettings.serverUrl,
454+ ...(openCodeSettings.serverPassword
455+ ? { serverPassword: openCodeSettings.serverPassword }
456+ : {}),
444457 })
445− ).pipe(
458+ .pipe(Effect.flatMap(loadInventory), Effect.scoped)
459+ : serverOwner.withServer(loadInventory);
460+ const inventoryExit = yield* Effect.exit(
461+ inventoryEffect.pipe(
446462 Effect.mapError(
447463 (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),
448464 ),
449465 ),
450466 );
451467 if (inventoryExit._tag === "Failure") {
452− return fallback(Cause.squash(inventoryExit.cause), version);
468+ return fallback(Cause.squash(inventoryExit.cause), version, "inventory");
453469 }
454470
471+ version = inventoryExit.value.version;
472+
455473 const models = providerModelsFromSettings(
456− flattenOpenCodeModels(inventoryExit.value),
474+ flattenOpenCodeModels(inventoryExit.value.inventory),
457475 customModels,
458476 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
459477 );
460− const skills = flattenOpenCodeSkills(inventoryExit.value);
461− const connectedCount = inventoryExit.value.providerList.connected.length;
478+ const skills = flattenOpenCodeSkills(inventoryExit.value.inventory);
479+ const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
462480 return buildServerProvider({
463481 presentation: OPENCODE_PRESENTATION,
464482 enabled: true,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+231−6
@@ -589,18 +589,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 588 unmodified lines
589589 }),
590590 },
591591 ],
592− slashCommands: [],
593− skills: [],
592+ slashCommands: [{ name: "review", description: "Review changes" }],
593+ skills: [
594+ {
595+ name: "typescript",
596+ description: "TypeScript help",
597+ path: "/skills/typescript/SKILL.md",
598+ enabled: true,
599+ },
600+ ],
594601 } as const satisfies ServerProvider;
595602 const refreshedProvider = {
596603 ...previousProvider,
597604 checkedAt: "2026-04-14T00:01:00.000Z",
598605 models: [],
606+ slashCommands: [],
607+ skills: [],
599608 } satisfies ServerProvider;
600609
601610 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
602611 ...previousProvider.models,
603612 ]);
613+ assert.deepStrictEqual(
614+ mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands,
615+ [],
616+ );
617+ assert.deepStrictEqual(
618+ mergeProviderSnapshot(previousProvider, refreshedProvider).skills,
619+ [],
620+ );
604621 });
605622
606623 it("drops stale OpenCode models missing from a successful refresh", () => {
@@ -670,8 +687,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 63 unmodified lines
670687 capabilities: null,
671688 },
672689 ],
673− slashCommands: [],
674− skills: [],
690+ slashCommands: [{ name: "review", description: "Review changes" }],
691+ skills: [
692+ {
693+ name: "typescript",
694+ description: "TypeScript help",
695+ path: "/skills/typescript/SKILL.md",
696+ enabled: true,
697+ },
698+ ],
675699 } as const satisfies ServerProvider;
676700 const refreshedProvider = {
677701 ...previousProvider,
@@ -685,6 +709,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 7 unmodified lines
685709 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
686710 ...previousProvider.models,
687711 ]);
712+ assert.deepStrictEqual(
713+ mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands,
714+ previousProvider.slashCommands,
715+ );
716+ assert.deepStrictEqual(
717+ mergeProviderSnapshot(previousProvider, refreshedProvider).skills,
718+ previousProvider.skills,
719+ );
688720 });
689721
690722 it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => {
@@ -713,8 +745,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 22 unmodified lines
713745 capabilities: null,
714746 },
715747 ],
716− slashCommands: [],
717− skills: [],
748+ slashCommands: [{ name: "review", description: "Review changes" }],
749+ skills: [
750+ {
751+ name: "typescript",
752+ description: "TypeScript help",
753+ path: "/skills/typescript/SKILL.md",
754+ enabled: true,
755+ },
756+ ],
718757 } as const satisfies ServerProvider;
719758 const pendingProvider = {
720759 ...previousProvider,
@@ -732,6 +771,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 11 unmodified lines
732771 auth: { status: "unknown" },
733772 checkedAt: "2026-07-17T00:02:00.000Z",
734773 models: [],
774+ slashCommands: [],
775+ skills: [],
735776 message: "OpenCode is available, but it did not report any connected upstream providers.",
736777 } satisfies ServerProvider;
737778 const missingProvider = {
@@ -765,6 +806,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 27 unmodified lines
765806 mergeProviderSnapshot(previousProvider, loggedOutProvider).models,
766807 [],
767808 );
809+ assert.deepStrictEqual(
810+ mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands,
811+ [],
812+ );
813+ assert.deepStrictEqual(
814+ mergeProviderSnapshot(previousProvider, loggedOutProvider).skills,
815+ [],
816+ );
768817 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []);
769818
770819 const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider);
@@ -896,6 +945,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 125 unmodified lines
896945 }),
897946 );
898947
948+ it.effect("refreshes OpenCode catalogs and preserves other providers", () =>
949+ Effect.gen(function* () {
950+ const codexDriver = ProviderDriverKind.make("codex");
951+ const openCodeDriver = ProviderDriverKind.make("opencode");
952+ const codexInstanceId = ProviderInstanceId.make("codex");
953+ const openCodeInstanceId = ProviderInstanceId.make("opencode");
954+ const codexRefreshCalls = yield* Ref.make(0);
955+ const openCodeRefreshCalls = yield* Ref.make(0);
956+ const codexProvider = {
957+ instanceId: codexInstanceId,
958+ driver: codexDriver,
959+ status: "ready",
960+ enabled: true,
961+ installed: true,
962+ auth: { status: "authenticated" },
963+ checkedAt: "2026-06-10T00:00:00.000Z",
964+ version: "1.0.0",
965+ models: [],
966+ slashCommands: [],
967+ skills: [],
968+ } as const satisfies ServerProvider;
969+ const failedOpenCodeProvider = {
970+ instanceId: openCodeInstanceId,
971+ driver: openCodeDriver,
972+ status: "error",
973+ enabled: true,
974+ installed: true,
975+ auth: { status: "unknown" },
976+ checkedAt: "2026-06-10T00:00:00.000Z",
977+ version: "1.0.0",
978+ message: "Failed to refresh OpenCode models.",
979+ models: [],
980+ slashCommands: [],
981+ skills: [],
982+ } as const satisfies ServerProvider;
983+ const recoveredOpenCodeProvider = {
984+ ...failedOpenCodeProvider,
985+ status: "ready",
986+ auth: { status: "authenticated" },
987+ checkedAt: "2026-06-10T00:01:00.000Z",
988+ message: "One upstream provider connected through OpenCode.",
989+ models: [
990+ {
991+ slug: "github/gpt-5",
992+ name: "GPT-5",
993+ subProvider: "GitHub",
994+ isCustom: false,
995+ capabilities: null,
996+ },
997+ ],
998+ } as const satisfies ServerProvider;
999+ const changedCatalogProvider = {
1000+ ...recoveredOpenCodeProvider,
1001+ checkedAt: "2026-06-10T00:02:00.000Z",
1002+ models: [
1003+ {
1004+ slug: "anthropic/claude-sonnet-4",
1005+ name: "Claude Sonnet 4",
1006+ subProvider: "Anthropic",
1007+ isCustom: false,
1008+ capabilities: null,
1009+ },
1010+ ],
1011+ } as const satisfies ServerProvider;
1012+ const catalogSnapshot = yield* Ref.make<ServerProvider>(recoveredOpenCodeProvider);
1013+ const instances = [
1014+ {
1015+ instanceId: codexInstanceId,
1016+ driverKind: codexDriver,
1017+ continuationIdentity: {
1018+ driverKind: codexDriver,
1019+ continuationKey: "codex:instance:codex",
1020+ },
1021+ displayName: undefined,
1022+ enabled: true,
1023+ snapshot: {
1024+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1025+ provider: codexDriver,
1026+ packageName: null,
1027+ }),
1028+ getSnapshot: Effect.succeed(codexProvider),
1029+ refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe(
1030+ Effect.as(codexProvider),
1031+ ),
1032+ streamChanges: Stream.empty,
1033+ },
1034+ adapter: {} as ProviderInstance["adapter"],
1035+ textGeneration: {} as ProviderInstance["textGeneration"],
1036+ },
1037+ {
1038+ instanceId: openCodeInstanceId,
1039+ driverKind: openCodeDriver,
1040+ continuationIdentity: {
1041+ driverKind: openCodeDriver,
1042+ continuationKey: "opencode:instance:opencode",
1043+ },
1044+ displayName: undefined,
1045+ enabled: true,
1046+ snapshot: {
1047+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1048+ provider: openCodeDriver,
1049+ packageName: null,
1050+ }),
1051+ getSnapshot: Effect.succeed(failedOpenCodeProvider),
1052+ refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe(
1053+ Effect.andThen(Ref.get(catalogSnapshot)),
1054+ ),
1055+ streamChanges: Stream.empty,
1056+ },
1057+ adapter: {} as ProviderInstance["adapter"],
1058+ textGeneration: {} as ProviderInstance["textGeneration"],
1059+ },
1060+ ] satisfies ReadonlyArray<ProviderInstance>;
1061+ const instanceRegistryLayer = Layer.succeed(
1062+ ProviderInstanceRegistry.ProviderInstanceRegistry,
1063+ {
1064+ getInstance: (instanceId) =>
1065+ Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)),
1066+ listInstances: Effect.succeed(instances),
1067+ listUnavailable: Effect.succeed([]),
1068+ streamChanges: Stream.empty,
1069+ subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1070+ },
1071+ );
1072+ const scope = yield* Scope.make();
1073+ yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1074+ const runtimeServices = yield* Layer.build(
1075+ ProviderRegistryLive.pipe(
1076+ Layer.provideMerge(instanceRegistryLayer),
1077+ Layer.provideMerge(
1078+ ServerConfig.layerTest(process.cwd(), {
1079+ prefix: "t3-provider-registry-reconnect-refresh-",
1080+ }),
1081+ ),
1082+ Layer.provideMerge(NodeServices.layer),
1083+ ),
1084+ ).pipe(Scope.provide(scope));
1085+
1086+ yield* Effect.gen(function* () {
1087+ const registry = yield* ProviderRegistry.ProviderRegistry;
1088+ const initialProviders = yield* registry.getProviders;
1089+ assert.strictEqual(
1090+ initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)
1091+ ?.status,
1092+ "error",
1093+ );
1094+
1095+ const recoveredProviders = yield* registry.refresh();
1096+ assert.deepStrictEqual(
1097+ recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId)
1098+ ?.models,
1099+ recoveredOpenCodeProvider.models,
1100+ );
1101+ assert.deepStrictEqual(
1102+ recoveredProviders.find((provider) => provider.instanceId === codexInstanceId),
1103+ codexProvider,
1104+ );
1105+
1106+ yield* Ref.set(catalogSnapshot, changedCatalogProvider);
1107+ const changedProviders = yield* registry.refresh();
1108+ assert.deepStrictEqual(
1109+ changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)
1110+ ?.models,
1111+ changedCatalogProvider.models,
1112+ );
1113+ assert.deepStrictEqual(
1114+ changedProviders.find((provider) => provider.instanceId === codexInstanceId),
1115+ codexProvider,
1116+ );
1117+ }).pipe(Effect.provide(runtimeServices));
1118+
1119+ assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2);
1120+ assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2);
1121+ }),
1122+ );
1123+
8991124 it.effect("persists the merged snapshot when a live update has empty models", () =>
9001125 Effect.gen(function* () {
9011126 const cursorDriver = ProviderDriverKind.make("cursor");
apps/server/src/provider/Layers/ProviderRegistry.ts+14−0
@@ -95,6 +95,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => · 94 unmodified lines
9595 return isPendingInitialProbe || didInstalledProviderProbeFail;
9696 };
9797
98+const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>
99+ provider.driver === ProviderDriverKind.make("opencode") &&
100+ shouldRetainMissingProviderModels(provider);
101+
98102 const mergeProviderModels = (
99103 provider: ServerProvider,
100104 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
@@ -132,6 +136,16 @@ export const mergeProviderSnapshot = ( · 31 unmodified lines
132136 : {
133137 ...nextProvider,
134138 models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models),
139+ ...(shouldRetainMissingOpenCodeMetadata(nextProvider)
140+ ? {
141+ slashCommands:
142+ nextProvider.slashCommands.length === 0
143+ ? previousProvider.slashCommands
144+ : nextProvider.slashCommands,
145+ skills:
146+ nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills,
147+ }
148+ : {}),
135149 };
136150
137151 export const mergeProviderSnapshots = (
apps/server/src/provider/Layers/ProviderService.test.ts+63−0
@@ -24,6 +24,8 @@ import { · 23 unmodified lines
2424 import { createModelSelection } from "@t3tools/shared/model";
2525 import { it, assert, describe, vi } from "@effect/vitest";
2626
27+import * as Cause from "effect/Cause";
28+import * as Deferred from "effect/Deferred";
2729 import * as Effect from "effect/Effect";
2830 import * as Exit from "effect/Exit";
2931 import * as Fiber from "effect/Fiber";
@@ -1531,6 +1533,67 @@ routing.layer("ProviderServiceLive routing", (it) => { · 1501 unmodified lines
15311533 }),
15321534 );
15331535
1536+ it.effect("does not persist running after a concurrent send is interrupted", () =>
1537+ Effect.gen(function* () {
1538+ const provider = yield* ProviderService.ProviderService;
1539+ const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
1540+ const sendStarted = yield* Deferred.make<void>();
1541+ const interrupted = yield* Deferred.make<void>();
1542+ routing.codex.sendTurn.mockImplementationOnce(() =>
1543+ Effect.gen(function* () {
1544+ yield* Deferred.succeed(sendStarted, undefined);
1545+ yield* Deferred.await(interrupted);
1546+ return yield* Effect.interrupt;
1547+ }),
1548+ );
1549+ routing.codex.interruptTurn.mockImplementationOnce(() =>
1550+ Deferred.succeed(interrupted, undefined).pipe(Effect.asVoid),
1551+ );
1552+
1553+ const threadId = asThreadId("thread-interrupted-send-directory");
1554+ const session = yield* provider.startSession(threadId, {
1555+ provider: ProviderDriverKind.make("codex"),
1556+ providerInstanceId: codexInstanceId,
1557+ threadId,
1558+ runtimeMode: "full-access",
1559+ });
1560+ const sendExitFiber = yield* provider
1561+ .sendTurn({
1562+ threadId: session.threadId,
1563+ input: "hold this prompt",
1564+ attachments: [],
1565+ })
1566+ .pipe(Effect.exit, Effect.forkChild);
1567+ yield* Deferred.await(sendStarted);
1568+ yield* provider.interruptTurn({ threadId: session.threadId });
1569+ const sendExit = yield* Fiber.join(sendExitFiber);
1570+
1571+ assert.equal(Exit.isFailure(sendExit), true);
1572+ if (Exit.isFailure(sendExit)) {
1573+ assert.equal(Cause.hasInterruptsOnly(sendExit.cause), true);
1574+ }
1575+ const persisted = yield* runtimeRepository.getByThreadId({
1576+ threadId: session.threadId,
1577+ });
1578+ assert.equal(Option.isSome(persisted), true);
1579+ if (Option.isSome(persisted)) {
1580+ // The directory folds both adapter "ready" and "running" into its
1581+ // runtime "running" state. The payload proves sendTurn did not upsert.
1582+ assert.equal(persisted.value.status, "running");
1583+ const payload = persisted.value.runtimePayload;
1584+ assert.equal(payload !== null && typeof payload === "object", true);
1585+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
1586+ const runtimePayload = payload as {
1587+ activeTurnId?: string | null;
1588+ lastRuntimeEvent?: string | null;
1589+ };
1590+ assert.equal(runtimePayload.activeTurnId ?? null, null);
1591+ assert.notEqual(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
1592+ }
1593+ }
1594+ }),
1595+ );
1596+
15341597 it.effect("reuses persisted resume cursor when startSession is called after a restart", () =>
15351598 Effect.gen(function* () {
15361599 const tempDir = NodeFS.mkdtempSync(
apps/server/src/provider/OpenCodeServerOwner.test.ts+285−0
@@ -0,0 +1,285 @@
1+import { it } from "@effect/vitest";
2+import * as Deferred from "effect/Deferred";
3+import * as Duration from "effect/Duration";
4+import * as Effect from "effect/Effect";
5+import * as Fiber from "effect/Fiber";
6+import * as Ref from "effect/Ref";
7+import * as TestClock from "effect/testing/TestClock";
8+import { expect } from "vite-plus/test";
9+
10+import {
11+ OpenCodeRuntime,
12+ OpenCodeRuntimeError,
13+ type OpenCodeRuntimeShape,
14+} from "./opencodeRuntime.ts";
15+import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
16+
17+const unusedRuntimeMethod = () =>
18+ Effect.fail(
19+ new OpenCodeRuntimeError({
20+ operation: "unused",
21+ detail: "unused test method",
22+ }),
23+ );
24+
25+const makeRuntime = Effect.gen(function* () {
26+ const starts = yield* Ref.make(0);
27+ const closes = yield* Ref.make(0);
28+ const failNextStart = yield* Ref.make(false);
29+ const started = yield* Deferred.make<void>();
30+ const closed = yield* Deferred.make<void>();
31+ const runtime: OpenCodeRuntimeShape = {
32+ startOpenCodeServerProcess: () =>
33+ Effect.gen(function* () {
34+ if (yield* Ref.getAndSet(failNextStart, false)) {
35+ return yield* new OpenCodeRuntimeError({
36+ operation: "startOpenCodeServerProcess",
37+ detail: "start failed",
38+ });
39+ }
40+ const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
41+ yield* Deferred.succeed(started, undefined).pipe(Effect.ignore);
42+ yield* Effect.addFinalizer(() =>
43+ Ref.update(closes, (count) => count + 1).pipe(
44+ Effect.andThen(Deferred.succeed(closed, undefined)),
45+ Effect.ignore,
46+ ),
47+ );
48+ return {
49+ url: `http://127.0.0.1:${index}`,
50+ version: "1.14.19",
51+ isRunning: Effect.succeed(true),
52+ exitCode: Effect.never,
53+ };
54+ }),
55+ connectToOpenCodeServer: unusedRuntimeMethod,
56+ runOpenCodeCommand: unusedRuntimeMethod,
57+ createOpenCodeSdkClient: () => ({}) as never,
58+ loadOpenCodeInventory: unusedRuntimeMethod,
59+ loadInventoryFromCli: unusedRuntimeMethod,
60+ };
61+ return { runtime, starts, closes, failNextStart, started, closed };
62+});
63+
64+it.effect("shares concurrent borrowers and closes after the idle TTL", () =>
65+ Effect.gen(function* () {
66+ const testRuntime = yield* makeRuntime;
67+ const release = yield* Deferred.make<void>();
68+ yield* Effect.scoped(
69+ Effect.gen(function* () {
70+ const owner = yield* OpenCodeServerOwner.make({
71+ binaryPath: "opencode",
72+ directory: "/project",
73+ });
74+ const useServer = owner.withServer((server) =>
75+ Deferred.await(release).pipe(Effect.as(server.url)),
76+ );
77+ const fibers = yield* Effect.all([useServer, useServer], {
78+ concurrency: "unbounded",
79+ }).pipe(Effect.forkChild);
80+ yield* Deferred.await(testRuntime.started);
81+ expect(yield* Ref.get(testRuntime.starts)).toBe(1);
82+ yield* Deferred.succeed(release, undefined);
83+ expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]);
84+ yield* TestClock.adjust(Duration.seconds(31));
85+ yield* Deferred.await(testRuntime.closed);
86+ expect(yield* Ref.get(testRuntime.closes)).toBe(1);
87+ }),
88+ ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime));
89+ }).pipe(Effect.provide(TestClock.layer())),
90+);
91+
92+it.effect("retries a failed start and closes on owner scope shutdown", () =>
93+ Effect.gen(function* () {
94+ const testRuntime = yield* makeRuntime;
95+ yield* Ref.set(testRuntime.failNextStart, true);
96+ yield* Effect.scoped(
97+ Effect.gen(function* () {
98+ const owner = yield* OpenCodeServerOwner.make({
99+ binaryPath: "opencode",
100+ directory: "/project",
101+ });
102+ expect(
103+ (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag,
104+ ).toBe("Failure");
105+ expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
106+ "http://127.0.0.1:1",
107+ );
108+ }),
109+ ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime));
110+ expect(yield* Ref.get(testRuntime.starts)).toBe(1);
111+ expect(yield* Ref.get(testRuntime.closes)).toBe(1);
112+ }),
113+);
114+
115+it.effect("invalidates an exited process so the next borrower starts a new one", () =>
116+ Effect.gen(function* () {
117+ const starts = yield* Ref.make(0);
118+ const processExits: Array<Deferred.Deferred<number>> = [];
119+ const processClosed = yield* Deferred.make<void>();
120+ const runtime: OpenCodeRuntimeShape = {
121+ startOpenCodeServerProcess: () =>
122+ Effect.gen(function* () {
123+ const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
124+ const exitCode = yield* Deferred.make<number>();
125+ processExits.push(exitCode);
126+ yield* Effect.addFinalizer(() =>
127+ Deferred.succeed(processClosed, undefined).pipe(Effect.ignore),
128+ );
129+ return {
130+ url: `http://127.0.0.1:${index}`,
131+ version: "1.14.19",
132+ isRunning: Effect.succeed(true),
133+ exitCode: Deferred.await(exitCode),
134+ };
135+ }),
136+ connectToOpenCodeServer: unusedRuntimeMethod,
137+ runOpenCodeCommand: unusedRuntimeMethod,
138+ createOpenCodeSdkClient: () => ({}) as never,
139+ loadOpenCodeInventory: unusedRuntimeMethod,
140+ loadInventoryFromCli: unusedRuntimeMethod,
141+ };
142+
143+ yield* Effect.scoped(
144+ Effect.gen(function* () {
145+ const owner = yield* OpenCodeServerOwner.make({
146+ binaryPath: "opencode",
147+ directory: "/project",
148+ });
149+ expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
150+ "http://127.0.0.1:1",
151+ );
152+ yield* Deferred.succeed(processExits[0]!, 1);
153+ yield* Deferred.await(processClosed);
154+ expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
155+ "http://127.0.0.1:2",
156+ );
157+ }),
158+ ).pipe(Effect.provideService(OpenCodeRuntime, runtime));
159+ expect(yield* Ref.get(starts)).toBe(2);
160+ }),
161+);
162+
163+it.effect("replaces a dead cached process before its exit watcher runs", () =>
164+ Effect.gen(function* () {
165+ const starts = yield* Ref.make(0);
166+ const closes = yield* Ref.make(0);
167+ const processRunning: Array<Ref.Ref<boolean>> = [];
168+ const runtime: OpenCodeRuntimeShape = {
169+ startOpenCodeServerProcess: () =>
170+ Effect.gen(function* () {
171+ const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
172+ const isRunning = yield* Ref.make(true);
173+ processRunning.push(isRunning);
174+ yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1));
175+ return {
176+ url: `http://127.0.0.1:${index}`,
177+ version: "1.14.19",
178+ isRunning: Ref.get(isRunning),
179+ exitCode: Effect.never,
180+ };
181+ }),
182+ connectToOpenCodeServer: unusedRuntimeMethod,
183+ runOpenCodeCommand: unusedRuntimeMethod,
184+ createOpenCodeSdkClient: () => ({}) as never,
185+ loadOpenCodeInventory: unusedRuntimeMethod,
186+ loadInventoryFromCli: unusedRuntimeMethod,
187+ };
188+
189+ yield* Effect.scoped(
190+ Effect.gen(function* () {
191+ const owner = yield* OpenCodeServerOwner.make({
192+ binaryPath: "opencode",
193+ directory: "/project",
194+ });
195+ expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
196+ "http://127.0.0.1:1",
197+ );
198+ yield* Ref.set(processRunning[0]!, false);
199+
200+ expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
201+ "http://127.0.0.1:2",
202+ );
203+ expect(yield* Ref.get(starts)).toBe(2);
204+ expect(yield* Ref.get(closes)).toBe(1);
205+ }),
206+ ).pipe(Effect.provideService(OpenCodeRuntime, runtime));
207+ }),
208+);
209+
210+it.effect("cleans up an interrupted startup and allows a retry", () =>
211+ Effect.gen(function* () {
212+ const starts = yield* Ref.make(0);
213+ const firstStartEntered = yield* Deferred.make<void>();
214+ const firstStartClosed = yield* Deferred.make<void>();
215+ const runtime: OpenCodeRuntimeShape = {
216+ startOpenCodeServerProcess: () =>
217+ Effect.gen(function* () {
218+ const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
219+ yield* Effect.addFinalizer(() =>
220+ index === 1
221+ ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore)
222+ : Effect.void,
223+ );
224+ if (index === 1) {
225+ yield* Deferred.succeed(firstStartEntered, undefined);
226+ return yield* Effect.never;
227+ }
228+ return {
229+ url: `http://127.0.0.1:${index}`,
230+ version: "1.14.19",
231+ isRunning: Effect.succeed(true),
232+ exitCode: Effect.never,
233+ };
234+ }),
235+ connectToOpenCodeServer: unusedRuntimeMethod,
236+ runOpenCodeCommand: unusedRuntimeMethod,
237+ createOpenCodeSdkClient: () => ({}) as never,
238+ loadOpenCodeInventory: unusedRuntimeMethod,
239+ loadInventoryFromCli: unusedRuntimeMethod,
240+ };
241+
242+ yield* Effect.scoped(
243+ Effect.gen(function* () {
244+ const owner = yield* OpenCodeServerOwner.make({
245+ binaryPath: "opencode",
246+ directory: "/project",
247+ });
248+ const firstBorrower = yield* owner
249+ .withServer((server) => Effect.succeed(server.url))
250+ .pipe(Effect.forkChild);
251+ yield* Deferred.await(firstStartEntered);
252+ yield* Fiber.interrupt(firstBorrower);
253+ yield* Deferred.await(firstStartClosed);
254+ expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
255+ "http://127.0.0.1:2",
256+ );
257+ }),
258+ ).pipe(Effect.provideService(OpenCodeRuntime, runtime));
259+ }),
260+);
261+
262+it.effect("releases an interrupted borrower and closes after the idle TTL", () =>
263+ Effect.gen(function* () {
264+ const testRuntime = yield* makeRuntime;
265+ const borrowerEntered = yield* Deferred.make<void>();
266+ yield* Effect.scoped(
267+ Effect.gen(function* () {
268+ const owner = yield* OpenCodeServerOwner.make({
269+ binaryPath: "opencode",
270+ directory: "/project",
271+ });
272+ const borrower = yield* owner
273+ .withServer(() =>
274+ Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)),
275+ )
276+ .pipe(Effect.forkChild);
277+ yield* Deferred.await(borrowerEntered);
278+ yield* Fiber.interrupt(borrower);
279+ yield* TestClock.adjust(Duration.seconds(31));
280+ yield* Deferred.await(testRuntime.closed);
281+ expect(yield* Ref.get(testRuntime.closes)).toBe(1);
282+ }),
283+ ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime));
284+ }).pipe(Effect.provide(TestClock.layer())),
285+);
apps/server/src/provider/OpenCodeServerOwner.ts+184−0
@@ -0,0 +1,184 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Exit from "effect/Exit";
4+import * as Fiber from "effect/Fiber";
5+import * as Layer from "effect/Layer";
6+import * as Scope from "effect/Scope";
7+import * as Semaphore from "effect/Semaphore";
8+
9+import * as OpenCodeRuntime from "./opencodeRuntime.ts";
10+
11+export const OPENCODE_SERVER_IDLE_TTL = "30 seconds";
12+
13+interface OpenCodeServerOwnerState {
14+ server: OpenCodeRuntime.OpenCodeServerProcess | null;
15+ serverScope: Scope.Closeable | null;
16+ borrowers: number;
17+ idleCloseFiber: Fiber.Fiber<void, never> | null;
18+}
19+
20+export class OpenCodeServerOwner extends Context.Service<
21+ OpenCodeServerOwner,
22+ {
23+ readonly withServer: <A, E, R>(
24+ use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>,
25+ ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
26+ }
27+>()("t3/provider/OpenCodeServerOwner") {}
28+
29+/** Owns the lazy local OpenCode server shared by one provider instance. */
30+export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: {
31+ readonly binaryPath: string;
32+ readonly directory: string;
33+ readonly serverPassword?: string;
34+ readonly environment?: NodeJS.ProcessEnv;
35+}) {
36+ const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
37+ const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
38+ Scope.close(scope, Exit.void),
39+ );
40+ const mutex = yield* Semaphore.make(1);
41+ const state: OpenCodeServerOwnerState = {
42+ server: null,
43+ serverScope: null,
44+ borrowers: 0,
45+ idleCloseFiber: null,
46+ };
47+
48+ const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () {
49+ const fiber = state.idleCloseFiber;
50+ state.idleCloseFiber = null;
51+ if (fiber !== null) {
52+ yield* Fiber.interrupt(fiber).pipe(Effect.ignore);
53+ }
54+ });
55+
56+ const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* (
57+ expected?: OpenCodeRuntime.OpenCodeServerProcess,
58+ ) {
59+ if (expected !== undefined && state.server !== expected) {
60+ return;
61+ }
62+ const scope = state.serverScope;
63+ state.server = null;
64+ state.serverScope = null;
65+ if (scope !== null) {
66+ yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
67+ }
68+ });
69+
70+ const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* (
71+ server: OpenCodeRuntime.OpenCodeServerProcess,
72+ ) {
73+ yield* server.exitCode;
74+ yield* mutex.withPermit(
75+ Effect.gen(function* () {
76+ if (state.server !== server) {
77+ return;
78+ }
79+ yield* cancelIdleClose();
80+ yield* closeServer(server);
81+ }),
82+ );
83+ });
84+
85+ const acquireServer = mutex.withPermit(
86+ Effect.gen(function* () {
87+ yield* cancelIdleClose();
88+ if (state.server !== null) {
89+ if (yield* state.server.isRunning) {
90+ state.borrowers += 1;
91+ return state.server;
92+ }
93+ yield* closeServer(state.server);
94+ }
95+
96+ return yield* Effect.uninterruptibleMask((restore) =>
97+ Effect.gen(function* () {
98+ const serverScope = yield* Scope.make();
99+ const started = yield* Effect.exit(
100+ restore(
101+ runtime
102+ .startOpenCodeServerProcess({
103+ binaryPath: input.binaryPath,
104+ directory: input.directory,
105+ ...(input.serverPassword !== undefined
106+ ? { serverPassword: input.serverPassword }
107+ : {}),
108+ ...(input.environment ? { environment: input.environment } : {}),
109+ })
110+ .pipe(Effect.provideService(Scope.Scope, serverScope)),
111+ ),
112+ );
113+ if (Exit.isFailure(started)) {
114+ yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
115+ return yield* Effect.failCause(started.cause);
116+ }
117+
118+ const server = started.value;
119+ state.server = server;
120+ state.serverScope = serverScope;
121+ state.borrowers = 1;
122+ yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope));
123+ return server;
124+ }),
125+ );
126+ }),
127+ );
128+
129+ const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
130+ mutex.withPermit(
131+ Effect.gen(function* () {
132+ if (state.server !== server) {
133+ return;
134+ }
135+ state.borrowers = Math.max(0, state.borrowers - 1);
136+ if (state.borrowers > 0) {
137+ return;
138+ }
139+ yield* cancelIdleClose();
140+ state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe(
141+ Effect.andThen(
142+ mutex.withPermit(
143+ Effect.gen(function* () {
144+ if (state.server !== server || state.borrowers > 0) {
145+ return;
146+ }
147+ state.idleCloseFiber = null;
148+ yield* closeServer(server);
149+ }),
150+ ),
151+ ),
152+ Effect.forkIn(ownerScope),
153+ );
154+ }),
155+ );
156+
157+ yield* Effect.addFinalizer(() =>
158+ mutex.withPermit(
159+ Effect.gen(function* () {
160+ yield* cancelIdleClose();
161+ state.borrowers = 0;
162+ yield* closeServer();
163+ }),
164+ ),
165+ );
166+
167+ return OpenCodeServerOwner.of({
168+ withServer: (use) =>
169+ Effect.uninterruptibleMask((restore) =>
170+ restore(acquireServer).pipe(
171+ Effect.flatMap((server) =>
172+ restore(use(server)).pipe(Effect.ensuring(releaseServer(server))),
173+ ),
174+ ),
175+ ),
176+ });
177+});
178+
179+export const layer = (input: {
180+ readonly binaryPath: string;
181+ readonly directory: string;
182+ readonly serverPassword?: string;
183+ readonly environment?: NodeJS.ProcessEnv;
184+}) => Layer.effect(OpenCodeServerOwner, make(input));
apps/server/src/provider/makeManagedServerProvider.test.ts+69−0
@@ -250,6 +250,40 @@ describe("makeManagedServerProvider", () => { · 249 unmodified lines
250250 ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))),
251251 );
252252
253+ it.effect("keeps manual refresh when interval refresh is disabled", () =>
254+ Effect.scoped(
255+ Effect.gen(function* () {
256+ const checkCalls = yield* Ref.make(0);
257+ const initialCheckDone = yield* Deferred.make<void>();
258+ const provider = yield* makeManagedServerProvider<TestSettings>({
259+ maintenanceCapabilities,
260+ getSettings: Effect.succeed({ enabled: true }),
261+ streamSettings: Stream.empty,
262+ haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
263+ initialSnapshot: () => Effect.succeed(initialSnapshot),
264+ checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
265+ Effect.tap((count) =>
266+ count === 1
267+ ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)
268+ : Effect.void,
269+ ),
270+ Effect.as(refreshedSnapshot),
271+ ),
272+ refreshInterval: "1 second",
273+ refreshOnInterval: false,
274+ });
275+
276+ yield* Deferred.await(initialCheckDone);
277+ yield* TestClock.adjust("5 minutes");
278+ yield* Effect.yieldNow;
279+ assert.strictEqual(yield* Ref.get(checkCalls), 1);
280+
281+ yield* provider.refresh;
282+ assert.strictEqual(yield* Ref.get(checkCalls), 2);
283+ }),
284+ ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))),
285+ );
286+
253287 it.effect("wakes a sleeping provider refresh loop when its interval changes", () =>
254288 Effect.scoped(
255289 Effect.gen(function* () {
@@ -355,6 +389,41 @@ describe("makeManagedServerProvider", () => { · 99 unmodified lines
355389 ).pipe(Effect.provide(AlwaysRunTestLayer)),
356390 );
357391
392+ it.effect("can update settings and disable periodic checks without probing again", () =>
393+ Effect.scoped(
394+ Effect.gen(function* () {
395+ const settingsChanges = yield* PubSub.unbounded<TestSettings>();
396+ const checkCalls = yield* Ref.make(0);
397+ const initialCheckDone = yield* Deferred.make<void>();
398+ const enrichmentCalls = yield* Ref.make(0);
399+ yield* makeManagedServerProvider<TestSettings>({
400+ maintenanceCapabilities,
401+ getSettings: Effect.succeed({ enabled: true }),
402+ streamSettings: Stream.fromPubSub(settingsChanges),
403+ haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
404+ checkProviderOnSettingsChange: () => false,
405+ refreshOnInterval: false,
406+ initialSnapshot: () => Effect.succeed(initialSnapshot),
407+ checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
408+ Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)),
409+ Effect.as(refreshedSnapshot),
410+ ),
411+ enrichSnapshot: () => Ref.update(enrichmentCalls, (count) => count + 1),
412+ refreshInterval: "1 second",
413+ });
414+
415+ yield* Deferred.await(initialCheckDone);
416+ yield* PubSub.publish(settingsChanges, { enabled: false });
417+ yield* Effect.yieldNow;
418+ yield* TestClock.adjust("1 second");
419+ yield* Effect.yieldNow;
420+
421+ assert.strictEqual(yield* Ref.get(checkCalls), 1);
422+ assert.strictEqual(yield* Ref.get(enrichmentCalls), 2);
423+ }),
424+ ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))),
425+ );
426+
358427 it.effect("streams supplemental snapshot updates after the base provider check completes", () =>
359428 Effect.scoped(
360429 Effect.gen(function* () {
apps/server/src/provider/makeManagedServerProvider.ts+20−1
@@ -40,6 +40,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 39 unmodified lines
4040 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
4141 }) => Effect.Effect<void>;
4242 readonly refreshInterval?: Duration.Input;
43+ readonly refreshOnInterval?: boolean;
44+ readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean;
4345 }): Effect.fn.Return<
4446 ServerProviderShape,
4547 ServerSettingsError,
@@ -121,6 +123,21 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 75 unmodified lines
121123 return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot));
122124 }
123125
126+ if (
127+ !forceRefresh &&
128+ input.checkProviderOnSettingsChange?.(previousSettings, nextSettings) === false
129+ ) {
130+ const state = yield* Ref.get(snapshotStateRef);
131+ const nextGeneration = state.enrichmentGeneration + 1;
132+ yield* Ref.set(snapshotStateRef, {
133+ ...state,
134+ enrichmentGeneration: nextGeneration,
135+ });
136+ yield* Ref.set(settingsRef, nextSettings);
137+ yield* restartSnapshotEnrichment(nextSettings, state.snapshot, nextGeneration);
138+ return state.snapshot;
139+ }
140+
124141 const nextSnapshot = yield* input.checkProvider;
125142 const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => {
126143 const generation = input.enrichSnapshot
@@ -199,7 +216,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 72 unmodified lines
199216 Queue.take(refreshIntervalChanges).pipe(Effect.as(false)),
200217 ).pipe(
201218 Effect.flatMap((intervalElapsed) =>
202− intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0
219+ input.refreshOnInterval !== false &&
220+ intervalElapsed &&
221+ Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0
203222 ? hasProviderStatusDemand.pipe(
204223 Effect.flatMap((shouldRefresh) =>
205224 shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void,
apps/server/src/provider/opencodeRuntime.environment.test.ts+130−1
@@ -1,6 +1,16 @@
1+import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2+import { it as effectIt } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import * as TestClock from "effect/testing/TestClock";
16 import { describe, expect, it } from "vite-plus/test";
27
3−import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts";
8+import {
9+ OpenCodeRuntimeError,
10+ resolveOpenCodeConfigContent,
11+ resolveOpenCodeServerPassword,
12+ verifyOpenCodeServerVersion,
13+} from "./opencodeRuntime.ts";
414
515 describe("resolveOpenCodeConfigContent", () => {
616 it("prefers the caller environment over the inherited environment", () => {
@@ -21,3 +31,122 @@ describe("resolveOpenCodeConfigContent", () => { · 14 unmodified lines
2131 expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}");
2232 });
2333 });
34+
35+describe("resolveOpenCodeServerPassword", () => {
36+ it("uses the local environment password when settings do not provide one", () => {
37+ expect(
38+ resolveOpenCodeServerPassword(
39+ { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } },
40+ {},
41+ ),
42+ ).toBe(" env password ");
43+ });
44+
45+ it("uses the settings password for a local server", () => {
46+ expect(
47+ resolveOpenCodeServerPassword({ external: false, serverPassword: " settings password " }, {}),
48+ ).toBe(" settings password ");
49+ });
50+
51+ it("uses the settings password when local settings and environment differ", () => {
52+ expect(
53+ resolveOpenCodeServerPassword(
54+ {
55+ external: false,
56+ serverPassword: "settings-password",
57+ environment: { OPENCODE_SERVER_PASSWORD: "environment-password" },
58+ },
59+ {},
60+ ),
61+ ).toBe("settings-password");
62+ });
63+
64+ it("does not send an inherited local password to an external server", () => {
65+ expect(
66+ resolveOpenCodeServerPassword(
67+ { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } },
68+ { OPENCODE_SERVER_PASSWORD: "inherited-secret" },
69+ ),
70+ ).toBeUndefined();
71+ });
72+});
73+
74+function makeHealthClient(
75+ result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>,
76+): OpencodeClient {
77+ return {
78+ global: {
79+ health: result,
80+ },
81+ } as unknown as OpencodeClient;
82+}
83+
84+describe("verifyOpenCodeServerVersion", () => {
85+ effectIt.effect("accepts a supported server version", () =>
86+ Effect.gen(function* () {
87+ const version = yield* verifyOpenCodeServerVersion(
88+ makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })),
89+ );
90+ expect(version).toBe("1.14.19");
91+ }),
92+ );
93+
94+ effectIt.effect("rejects a server below the supported version", () =>
95+ Effect.gen(function* () {
96+ const error = yield* verifyOpenCodeServerVersion(
97+ makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })),
98+ ).pipe(Effect.flip);
99+ expect(error).toBeInstanceOf(OpenCodeRuntimeError);
100+ expect(error.detail).toContain("v1.14.18 is too old");
101+ }),
102+ );
103+
104+ for (const data of [
105+ { healthy: true },
106+ { healthy: true, version: "not-a-version" },
107+ { healthy: false, version: "1.14.19" },
108+ ]) {
109+ effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () =>
110+ Effect.gen(function* () {
111+ const error = yield* verifyOpenCodeServerVersion(
112+ makeHealthClient(() => Promise.resolve({ data })),
113+ ).pipe(Effect.flip);
114+ expect(error).toBeInstanceOf(OpenCodeRuntimeError);
115+ expect(error.detail).toContain("requires OpenCode v1.14.19 or newer");
116+ }),
117+ );
118+ }
119+
120+ effectIt.effect("preserves an unauthorized health error", () =>
121+ Effect.gen(function* () {
122+ const error = yield* verifyOpenCodeServerVersion(
123+ makeHealthClient(() =>
124+ Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }),
125+ ),
126+ ).pipe(Effect.flip);
127+ expect(error).toBeInstanceOf(OpenCodeRuntimeError);
128+ expect(error.detail).toContain("status=401");
129+ expect(error.detail).toContain("Unauthorized");
130+ }),
131+ );
132+
133+ effectIt.effect("aborts a health request when the version check times out", () =>
134+ Effect.gen(function* () {
135+ let requestSignal: AbortSignal | undefined;
136+ const checkFiber = yield* verifyOpenCodeServerVersion(
137+ makeHealthClient((options) => {
138+ requestSignal = options?.signal;
139+ return new Promise(() => undefined);
140+ }),
141+ ).pipe(Effect.flip, Effect.forkChild);
142+
143+ yield* Effect.yieldNow;
144+ expect(requestSignal).toBeDefined();
145+ yield* TestClock.adjust("6 seconds");
146+
147+ const error = yield* Fiber.join(checkFiber);
148+ expect(error.detail).toBe("Timed out while checking the OpenCode server version.");
149+ expect(requestSignal?.aborted).toBe(true);
150+ }).pipe(Effect.provide(TestClock.layer())),
151+ );
152+});
apps/server/src/provider/opencodeRuntime.inventory.test.ts+28−0
@@ -18,6 +18,34 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; · 17 unmodified lines
1818 const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
1919
2020 it.layer(testLayer)("OpenCodeRuntime inventory", (it) => {
21+ it.effect("keeps provider inventory when agent discovery fails", () =>
22+ Effect.gen(function* () {
23+ const runtime = yield* OpenCodeRuntime;
24+ const client = {
25+ provider: {
26+ list: () =>
27+ Promise.resolve({
28+ data: {
29+ connected: ["openai"],
30+ all: [],
31+ default: {},
32+ },
33+ }),
34+ },
35+ app: {
36+ agents: () => Promise.reject(new Error("agents endpoint unavailable")),
37+ skills: () => Promise.resolve({ data: [] }),
38+ },
39+ } as unknown as OpencodeClient;
40+
41+ const inventory = yield* runtime.loadOpenCodeInventory(client);
42+
43+ NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
44+ NodeAssert.deepEqual(inventory.agents, []);
45+ NodeAssert.deepEqual(inventory.skills, []);
46+ }),
47+ );
48+
2149 it.effect("keeps provider inventory when skill discovery fails", () =>
2250 Effect.gen(function* () {
2351 const runtime = yield* OpenCodeRuntime;
apps/server/src/provider/opencodeRuntime.permissions.test.ts+1−0
@@ -39,6 +39,7 @@ describe("buildOpenCodePermissionRules", () => { · 38 unmodified lines
3939 it("allows everything only under full access", () => {
4040 NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [
4141 { permission: "*", pattern: "*", action: "allow" },
42+ { permission: "external_directory", pattern: "*", action: "allow" },
4243 ]);
4344 });
4445 });
apps/server/src/provider/opencodeRuntime.ts+136−21
@@ -33,10 +33,20 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; · 32 unmodified lines
3333 import { collectStreamAsString } from "./providerSnapshot.ts";
3434 import * as NetService from "@t3tools/shared/Net";
3535 import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
36+import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
3637 import { resolveSpawnCommand } from "@t3tools/shared/shell";
3738 const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839 const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
3940
41+export const MINIMUM_OPENCODE_VERSION = "1.14.19";
42+const OPENCODE_HEALTH_TIMEOUT = "5 seconds";
43+
44+const OpenCodeHealthSchema = Schema.Struct({
45+ healthy: Schema.Literal(true),
46+ version: Schema.String,
47+});
48+const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema);
49+
4050 export function resolveOpenCodeConfigContent(
4151 inputEnvironment: Readonly<Record<string, string | undefined>> | undefined,
4252 inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
@@ -48,17 +58,41 @@ export function resolveOpenCodeConfigContent( · 5 unmodified lines
4858 );
4959 }
5060
61+export function resolveOpenCodeServerPassword(
62+ input: {
63+ readonly external: boolean;
64+ readonly serverPassword?: string;
65+ readonly environment?: Readonly<Record<string, string | undefined>>;
66+ },
67+ inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
68+): string | undefined {
69+ if (input.serverPassword !== undefined) {
70+ return input.serverPassword;
71+ }
72+ if (input.external) {
73+ return undefined;
74+ }
75+ return input.environment === undefined
76+ ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD
77+ : input.environment.OPENCODE_SERVER_PASSWORD;
78+}
79+
5180 const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
5281 const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
5382 const DEFAULT_HOSTNAME = "127.0.0.1";
5483 const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
5584 export interface OpenCodeServerProcess {
5685 readonly url: string;
86+ readonly serverPassword?: string;
87+ readonly version: string;
88+ readonly isRunning: Effect.Effect<boolean>;
5789 readonly exitCode: Effect.Effect<number, never>;
5890 }
5991
6092 export interface OpenCodeServerConnection {
6193 readonly url: string;
94+ readonly serverPassword?: string;
95+ readonly version: string;
6296 readonly exitCode: Effect.Effect<number, never> | null;
6397 readonly external: boolean;
6498 }
@@ -96,7 +130,7 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { · 31 unmodified lines
96130
97131 export const runOpenCodeSdk = <A>(
98132 operation: string,
99− fn: () => Promise<A>,
133+ fn: (signal: AbortSignal) => Promise<A>,
100134 ): Effect.Effect<A, OpenCodeRuntimeError> =>
101135 Effect.tryPromise({
102136 try: fn,
@@ -104,6 +138,44 @@ export const runOpenCodeSdk = <A>( · 1 unmodified line
104138 new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }),
105139 }).pipe(Effect.withSpan(`opencode.${operation}`));
106140
141+export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* (
142+ client: OpencodeClient,
143+) {
144+ const healthOption = yield* runOpenCodeSdk("global.health", (signal) =>
145+ client.global.health({ signal }),
146+ ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT));
147+ if (Option.isNone(healthOption)) {
148+ return yield* new OpenCodeRuntimeError({
149+ operation: "global.health",
150+ detail: "Timed out while checking the OpenCode server version.",
151+ });
152+ }
153+
154+ const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe(
155+ Effect.mapError(
156+ (cause) =>
157+ new OpenCodeRuntimeError({
158+ operation: "global.health",
159+ detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
160+ cause,
161+ }),
162+ ),
163+ );
164+ if (parseSemver(health.version) === null) {
165+ return yield* new OpenCodeRuntimeError({
166+ operation: "global.health",
167+ detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
168+ });
169+ }
170+ if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) {
171+ return yield* new OpenCodeRuntimeError({
172+ operation: "global.health",
173+ detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,
174+ });
175+ }
176+ return health.version;
177+});
178+
107179 export interface OpenCodeCommandResult {
108180 readonly stdout: string;
109181 readonly stderr: string;
@@ -145,6 +217,8 @@ export interface OpenCodeRuntimeShape { · 35 unmodified lines
145217 */
146218 readonly startOpenCodeServerProcess: (input: {
147219 readonly binaryPath: string;
220+ readonly directory: string;
221+ readonly serverPassword?: string;
148222 readonly environment?: NodeJS.ProcessEnv;
149223 readonly port?: number;
150224 readonly hostname?: string;
@@ -157,7 +231,9 @@ export interface OpenCodeRuntimeShape { · 6 unmodified lines
157231 */
158232 readonly connectToOpenCodeServer: (input: {
159233 readonly binaryPath: string;
234+ readonly directory: string;
160235 readonly serverUrl?: string | null;
236+ readonly serverPassword?: string;
161237 readonly environment?: NodeJS.ProcessEnv;
162238 readonly port?: number;
163239 readonly hostname?: string;
@@ -398,7 +474,10 @@ export function toOpenCodeFileParts(input: { · 234 unmodified lines
398474
399475 export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {
400476 if (runtimeMode === "full-access") {
401− return [{ permission: "*", pattern: "*", action: "allow" }];
477+ return [
478+ { permission: "*", pattern: "*", action: "allow" },
479+ { permission: "external_directory", pattern: "*", action: "allow" },
480+ ];
402481 }
403482
404483 // "Auto-accept edits" is documented as "auto-approve edits, ask before other
@@ -514,6 +593,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 109 unmodified lines
514593 ),
515594 );
516595
596+ const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) =>
597+ createOpencodeClient({
598+ baseUrl: input.baseUrl,
599+ directory: input.directory,
600+ ...(input.serverPassword
601+ ? {
602+ headers: {
603+ Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
604+ },
605+ }
606+ : {}),
607+ throwOnError: true,
608+ });
609+
517610 const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) =>
518611 Effect.gen(function* () {
519612 // Bind this server's lifetime to the caller's scope. When the caller's
@@ -537,6 +630,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 17 unmodified lines
537630 const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
538631 const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];
539632 const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment);
633+ const serverPassword = resolveOpenCodeServerPassword({
634+ external: false,
635+ ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
636+ ...(input.environment !== undefined ? { environment: input.environment } : {}),
637+ });
540638
541639 const child = yield* spawner
542640 .spawn(
@@ -545,6 +643,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 2 unmodified lines
545643 shell: spawnCommand.shell,
546644 env: {
547645 ...input.environment,
646+ ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}),
548647 // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or
549648 // the inherited process environment, only falling back to the
550649 // empty config when neither is set. Setting it unconditionally
@@ -670,8 +769,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 119 unmodified lines
670769 });
671770 }
672771
772+ const url = readyOption.value;
773+ const version = yield* verifyOpenCodeServerVersion(
774+ createOpenCodeSdkClient({
775+ baseUrl: url,
776+ directory: input.directory,
777+ ...(serverPassword !== undefined ? { serverPassword } : {}),
778+ }),
779+ );
780+
673781 return {
674− url: readyOption.value,
782+ url,
783+ ...(serverPassword !== undefined ? { serverPassword } : {}),
784+ version,
785+ isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)),
675786 exitCode: child.exitCode.pipe(
676787 Effect.map(Number),
677788 Effect.orElseSucceed(() => 0),
@@ -682,16 +793,31 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 4 unmodified lines
682793 const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => {
683794 const serverUrl = input.serverUrl?.trim();
684795 if (serverUrl) {
685− // We don't own externally-configured servers — no scope interaction.
686− return Effect.succeed({
687− url: serverUrl,
688− exitCode: null,
796+ const serverPassword = resolveOpenCodeServerPassword({
689797 external: true,
798+ ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
690799 });
800+ return verifyOpenCodeServerVersion(
801+ createOpenCodeSdkClient({
802+ baseUrl: serverUrl,
803+ directory: input.directory,
804+ ...(serverPassword !== undefined ? { serverPassword } : {}),
805+ }),
806+ ).pipe(
807+ Effect.map((version) => ({
808+ url: serverUrl,
809+ ...(serverPassword !== undefined ? { serverPassword } : {}),
810+ version,
811+ exitCode: null,
812+ external: true,
813+ })),
814+ );
691815 }
692816
693817 return startOpenCodeServerProcess({
694818 binaryPath: input.binaryPath,
819+ directory: input.directory,
820+ ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
695821 ...(input.environment !== undefined ? { environment: input.environment } : {}),
696822 ...(input.port !== undefined ? { port: input.port } : {}),
697823 ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
@@ -699,26 +825,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 1 unmodified line
699825 }).pipe(
700826 Effect.map((server) => ({
701827 url: server.url,
828+ ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
829+ version: server.version,
702830 exitCode: server.exitCode,
703831 external: false,
704832 })),
705833 );
706834 };
707835
708− const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) =>
709− createOpencodeClient({
710− baseUrl: input.baseUrl,
711− directory: input.directory,
712− ...(input.serverPassword
713− ? {
714− headers: {
715− Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
716− },
717− }
718− : {}),
719− throwOnError: true,
720− });
721−
722836 const loadProviders = (client: OpencodeClient) =>
723837 runOpenCodeSdk("provider.list", () => client.provider.list()).pipe(
724838 Effect.filterMapOrFail(
@@ -738,6 +852,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 13 unmodified lines
738852 const loadAgents = (client: OpencodeClient) =>
739853 runOpenCodeSdk("app.agents", () => client.app.agents()).pipe(
740854 Effect.map((result) => result.data ?? []),
855+ Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
741856 );
742857
743858 const loadSkills = (client: OpencodeClient) =>
apps/server/src/server.test.ts+46−0
@@ -57,6 +57,7 @@ import * as Option from "effect/Option"; · 56 unmodified lines
5757 import * as Path from "effect/Path";
5858 import * as PubSub from "effect/PubSub";
5959 import * as Queue from "effect/Queue";
60+import * as Ref from "effect/Ref";
6061 import * as Schema from "effect/Schema";
6162 import * as Stream from "effect/Stream";
6263 import * as TestClock from "effect/testing/TestClock";
@@ -4918,6 +4919,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 4855 unmodified lines
49184919 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
49194920 );
49204921
4922+ it.effect("refreshes providers for each subscribeServerConfig connection", () =>
4923+ Effect.gen(function* () {
4924+ const refreshCalls = yield* Ref.make(0);
4925+ const firstRefreshDone = yield* Deferred.make<void>();
4926+ const secondRefreshDone = yield* Deferred.make<void>();
4927+
4928+ yield* buildAppUnderTest({
4929+ layers: {
4930+ providerRegistry: {
4931+ refresh: () =>
4932+ Ref.updateAndGet(refreshCalls, (count) => count + 1).pipe(
4933+ Effect.tap((count) =>
4934+ Deferred.succeed(
4935+ count === 1 ? firstRefreshDone : secondRefreshDone,
4936+ undefined,
4937+ ).pipe(Effect.ignore),
4938+ ),
4939+ Effect.as([]),
4940+ ),
4941+ },
4942+ },
4943+ });
4944+
4945+ const wsUrl = yield* getWsServerUrl("/ws");
4946+ yield* Effect.scoped(
4947+ withWsRpcClient(wsUrl, (client) =>
4948+ Effect.gen(function* () {
4949+ yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead);
4950+ yield* Deferred.await(firstRefreshDone);
4951+ }),
4952+ ),
4953+ );
4954+ yield* Effect.scoped(
4955+ withWsRpcClient(wsUrl, (client) =>
4956+ Effect.gen(function* () {
4957+ yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead);
4958+ yield* Deferred.await(secondRefreshDone);
4959+ }),
4960+ ),
4961+ );
4962+
4963+ assert.equal(yield* Ref.get(refreshCalls), 2);
4964+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4965+ );
4966+
49214967 it.effect("routes websocket resource telemetry through the subscription", () =>
49224968 Effect.gen(function* () {
49234969 yield* buildAppUnderTest();
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+124−8
@@ -11,6 +11,7 @@ import { beforeEach, expect } from "vite-plus/test"; · 10 unmodified lines
1111
1212 import * as ServerConfig from "../config.ts";
1313 import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
14+import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
1415 import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts";
1516 import * as TextGeneration from "./TextGeneration.ts";
1617
@@ -21,6 +22,8 @@ const runtimeMock = { · 4 unmodified lines
2122 promptParts: [] as ReadonlyArray<unknown>[],
2223 authHeaders: [] as Array<string | null>,
2324 closeCalls: [] as string[],
25+ sessionCreateCalls: 0,
26+ connectionError: undefined as Error | undefined,
2427 sessionCreateError: undefined as unknown,
2528 sessionResult: undefined as { data?: { id: string } } | undefined,
2629 promptRequestError: undefined as unknown,
@@ -34,6 +37,8 @@ const runtimeMock = { · 7 unmodified lines
3437 this.state.promptParts.length = 0;
3538 this.state.authHeaders.length = 0;
3639 this.state.closeCalls.length = 0;
40+ this.state.sessionCreateCalls = 0;
41+ this.state.connectionError = undefined;
3742 this.state.sessionCreateError = undefined;
3843 this.state.sessionResult = undefined;
3944 this.state.promptRequestError = undefined;
@@ -42,7 +47,7 @@ const runtimeMock = { · 2 unmodified lines
4247 };
4348
4449 const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
45− startOpenCodeServerProcess: ({ binaryPath }) =>
50+ startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) =>
4651 Effect.gen(function* () {
4752 const index = runtimeMock.state.startCalls.length + 1;
4853 const url = `http://127.0.0.1:${4_300 + index}`;
@@ -54,22 +59,43 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { · 5 unmodified lines
5459 runtimeMock.state.closeCalls.push(url);
5560 }),
5661 );
62+ const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
63+ external: false,
64+ ...(serverPassword !== undefined ? { serverPassword } : {}),
65+ ...(environment !== undefined ? { environment } : {}),
66+ });
5767 return {
5868 url,
69+ ...(effectiveServerPassword !== undefined
70+ ? { serverPassword: effectiveServerPassword }
71+ : {}),
72+ version: "1.14.19",
73+ isRunning: Effect.succeed(true),
5974 exitCode: Effect.never,
6075 };
6176 }),
62− connectToOpenCodeServer: ({ serverUrl }) =>
63− Effect.succeed({
64− url: serverUrl ?? "http://127.0.0.1:4301",
65− exitCode: null,
66− external: Boolean(serverUrl),
67− }),
77+ connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
78+ runtimeMock.state.connectionError
79+ ? Effect.fail(
80+ new OpenCodeRuntime.OpenCodeRuntimeError({
81+ operation: "global.health",
82+ detail: runtimeMock.state.connectionError.message,
83+ cause: runtimeMock.state.connectionError,
84+ }),
85+ )
86+ : Effect.succeed({
87+ url: serverUrl ?? "http://127.0.0.1:4301",
88+ ...(serverPassword ? { serverPassword } : {}),
89+ version: "1.14.19",
90+ exitCode: null,
91+ external: Boolean(serverUrl),
92+ }),
6893 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
6994 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
7095 ({
7196 session: {
7297 create: async () => {
98+ runtimeMock.state.sessionCreateCalls += 1;
7399 if (runtimeMock.state.sessionCreateError !== undefined) {
74100 throw runtimeMock.state.sessionCreateError;
75101 }
@@ -163,18 +189,35 @@ const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( · 87 unmodified lines
163189 const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
164190 binaryPath: "fake-opencode",
165191 });
192+const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
193+ binaryPath: "fake-opencode",
194+ serverPassword: "secret-password",
195+});
166196 const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
167197 binaryPath: "fake-opencode",
168198 serverUrl: "http://127.0.0.1:9999",
169199 serverPassword: "secret-password",
170200 });
201+const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
202+ binaryPath: "fake-opencode",
203+ serverUrl: "http://127.0.0.1:9999",
204+});
171205
172206 function withOpenCodeTextGeneration<A, E, R>(
173207 settings: OpenCodeSettings,
174208 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
209+ environment?: NodeJS.ProcessEnv,
175210 ) {
176211 return Effect.gen(function* () {
177− const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings);
212+ const serverOwner = yield* OpenCodeServerOwner.make({
213+ binaryPath: settings.binaryPath,
214+ directory: process.cwd(),
215+ ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
216+ ...(environment ? { environment } : {}),
217+ });
218+ const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe(
219+ Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
220+ );
178221 return yield* effectFn(textGeneration);
179222 }).pipe(Effect.scoped);
180223 }
@@ -229,6 +272,49 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 48 unmodified lines
229272 ),
230273 );
231274
275+ it.effect("passes configured authentication to a locally spawned server", () =>
276+ withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) =>
277+ Effect.gen(function* () {
278+ yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
279+
280+ expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
281+ expect(runtimeMock.state.authHeaders).toEqual([
282+ `Basic ${btoa("opencode:secret-password")}`,
283+ ]);
284+ }),
285+ ),
286+ );
287+
288+ it.effect("uses an environment-only password for a locally spawned server", () =>
289+ withOpenCodeTextGeneration(
290+ DEFAULT_OPENCODE_SETTINGS,
291+ (textGeneration) =>
292+ Effect.gen(function* () {
293+ yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
294+
295+ expect(runtimeMock.state.authHeaders).toEqual([
296+ `Basic ${btoa("opencode:environment-password")}`,
297+ ]);
298+ }),
299+ { OPENCODE_SERVER_PASSWORD: "environment-password" },
300+ ),
301+ );
302+
303+ it.effect("uses settings auth when the local environment password differs", () =>
304+ withOpenCodeTextGeneration(
305+ LOCAL_AUTH_OPENCODE_SETTINGS,
306+ (textGeneration) =>
307+ Effect.gen(function* () {
308+ yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
309+
310+ expect(runtimeMock.state.authHeaders).toEqual([
311+ `Basic ${btoa("opencode:secret-password")}`,
312+ ]);
313+ }),
314+ { OPENCODE_SERVER_PASSWORD: "environment-password" },
315+ ),
316+ );
317+
232318 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
233319 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
234320 Effect.gen(function* () {
@@ -460,6 +546,36 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 225 unmodified lines
460546 it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
461547 "OpenCodeTextGeneration with configured server URL",
462548 (it) => {
549+ it.effect("does not send a local environment password to a configured server", () =>
550+ withOpenCodeTextGeneration(
551+ EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS,
552+ (textGeneration) =>
553+ Effect.gen(function* () {
554+ yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT);
555+ expect(runtimeMock.state.authHeaders).toEqual([null]);
556+ }),
557+ { OPENCODE_SERVER_PASSWORD: "local-secret" },
558+ ),
559+ );
560+
561+ it.effect("does not create a session when the server version is unsupported", () =>
562+ withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
563+ Effect.gen(function* () {
564+ runtimeMock.state.connectionError = new Error(
565+ "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
566+ );
567+
568+ const error = yield* textGeneration
569+ .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
570+ .pipe(Effect.flip);
571+
572+ expect(error).toBeInstanceOf(TextGenerationError);
573+ expect(error.message).toContain("v1.14.18 is too old");
574+ expect(runtimeMock.state.sessionCreateCalls).toBe(0);
575+ }),
576+ ),
577+ );
578+
463579 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
464580 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
465581 Effect.gen(function* () {
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+30−194
@@ -1,9 +1,5 @@
11 import * as Effect from "effect/Effect";
2−import * as Exit from "effect/Exit";
3−import * as Fiber from "effect/Fiber";
42 import * as Schema from "effect/Schema";
5−import * as Scope from "effect/Scope";
6−import * as Semaphore from "effect/Semaphore";
73
84 import {
95 NonNegativeInt,
@@ -31,8 +27,7 @@ import { · 21 unmodified lines
3127 sanitizeThreadTitle,
3228 } from "./TextGenerationUtils.ts";
3329 import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
34−
35−const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
30+import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
3631
3732 const OpenCodeTextGenerationOperation = Schema.Literals([
3833 "generateCommitMessage",
@@ -175,188 +170,12 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str · 136 unmodified lines
175170 .trim();
176171 }
177172
178−interface SharedOpenCodeTextGenerationServerState {
179− server: OpenCodeRuntime.OpenCodeServerProcess | null;
180− /**
181− * The scope that owns the shared server's lifetime. Closing this scope
182− * terminates the OpenCode child process and interrupts any fibers the
183− * runtime forked during startup. We don't hold a `close()` function on
184− * the server handle anymore — the scope is the only lifecycle handle.
185− */
186− serverScope: Scope.Closeable | null;
187− binaryPath: string | null;
188− activeRequests: number;
189− idleCloseFiber: Fiber.Fiber<void, never> | null;
190−}
191−
192173 export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
193174 openCodeSettings: OpenCodeSettings,
194− environment?: NodeJS.ProcessEnv,
195175 ) {
196176 const serverConfig = yield* ServerConfig.ServerConfig;
197177 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
198− const resolvedEnvironment = environment ?? process.env;
199− const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
200− Scope.close(scope, Exit.void),
201− );
202− const sharedServerMutex = yield* Semaphore.make(1);
203− const sharedServerState: SharedOpenCodeTextGenerationServerState = {
204− server: null,
205− serverScope: null,
206− binaryPath: null,
207− activeRequests: 0,
208− idleCloseFiber: null,
209− };
210−
211− const closeSharedServer = Effect.fn("closeSharedServer")(function* () {
212− const scope = sharedServerState.serverScope;
213− sharedServerState.server = null;
214− sharedServerState.serverScope = null;
215− sharedServerState.binaryPath = null;
216− if (scope !== null) {
217− yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
218− }
219− });
220−
221− const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
222− const idleCloseFiber = sharedServerState.idleCloseFiber;
223− sharedServerState.idleCloseFiber = null;
224− if (idleCloseFiber !== null) {
225− yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore);
226− }
227− });
228−
229− const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
230− server: OpenCodeRuntime.OpenCodeServerProcess,
231− ) {
232− yield* cancelIdleCloseFiber();
233− const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(
234− Effect.andThen(
235− sharedServerMutex.withPermit(
236− Effect.gen(function* () {
237− if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
238− return;
239− }
240− sharedServerState.idleCloseFiber = null;
241− yield* closeSharedServer();
242− }),
243− ),
244− ),
245− Effect.forkIn(idleFiberScope),
246− );
247− sharedServerState.idleCloseFiber = fiber;
248− });
249−
250− const acquireSharedServer = (input: {
251− readonly binaryPath: string;
252− readonly operation:
253− | "generateCommitMessage"
254− | "generatePrContent"
255− | "generateBranchName"
256− | "generateThreadTitle";
257− }) =>
258− sharedServerMutex.withPermit(
259− Effect.gen(function* () {
260− yield* cancelIdleCloseFiber();
261−
262− const existingServer = sharedServerState.server;
263− if (existingServer !== null) {
264− if (
265− sharedServerState.binaryPath !== input.binaryPath &&
266− sharedServerState.activeRequests === 0
267− ) {
268− yield* closeSharedServer();
269− } else {
270− if (sharedServerState.binaryPath !== input.binaryPath) {
271− yield* Effect.logWarning(
272− "OpenCode shared server binary path mismatch: requested " +
273− input.binaryPath +
274− " but active server uses " +
275− sharedServerState.binaryPath +
276− "; reusing existing server because there are active requests",
277− );
278− }
279− sharedServerState.activeRequests += 1;
280− return existingServer;
281− }
282− }
283−
284− // Create a fresh scope that owns this shared server. The runtime
285− // will attach its child-process and fiber finalizers to this scope;
286− // closing it kills the server and interrupts those fibers.
287− //
288− // The `Scope.make` / spawn / record-or-close transitions run inside
289− // `uninterruptibleMask` so an interrupt arriving between any two
290− // steps can't orphan the scope (and the child process attached to
291− // it) before we either close it on failure or hand ownership to
292− // `sharedServerState`. `restore` keeps the actual spawn
293− // interruptible; an interrupt during the spawn is captured by
294− // `Effect.exit` and drives us through the failure branch that
295− // closes the fresh scope.
296− return yield* Effect.uninterruptibleMask((restore) =>
297− Effect.gen(function* () {
298− const serverScope = yield* Scope.make();
299− const startedExit = yield* Effect.exit(
300− restore(
301− openCodeRuntime
302− .startOpenCodeServerProcess({
303− binaryPath: input.binaryPath,
304− environment: resolvedEnvironment,
305− })
306− .pipe(
307− Effect.provideService(Scope.Scope, serverScope),
308− Effect.mapError(
309− (cause) =>
310− new TextGenerationError({
311− operation: input.operation,
312− detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
313− cause,
314− }),
315− ),
316− ),
317− ),
318− );
319− if (startedExit._tag === "Failure") {
320− yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
321− return yield* Effect.failCause(startedExit.cause);
322− }
323−
324− const server = startedExit.value;
325− sharedServerState.server = server;
326− sharedServerState.serverScope = serverScope;
327− sharedServerState.binaryPath = input.binaryPath;
328− sharedServerState.activeRequests = 1;
329− return server;
330− }),
331− );
332− }),
333− );
334−
335− const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
336− sharedServerMutex.withPermit(
337− Effect.gen(function* () {
338− if (sharedServerState.server !== server) {
339− return;
340− }
341− sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1);
342− if (sharedServerState.activeRequests === 0) {
343− yield* scheduleIdleClose(server);
344− }
345− }),
346− );
347−
348− // Module-level finalizer: on layer shutdown, cancel the idle close fiber
349− // and close the shared server scope. Consumers therefore cannot leak
350− // the shared OpenCode server by forgetting to call anything.
351− yield* Effect.addFinalizer(() =>
352− sharedServerMutex.withPermit(
353− Effect.gen(function* () {
354− yield* cancelIdleCloseFiber();
355− sharedServerState.activeRequests = 0;
356− yield* closeSharedServer();
357− }),
358− ),
359− );
178+ const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
360179
361180 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
362181 readonly operation: OpenCodeTextGenerationOperation;
@@ -381,13 +200,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" · 18 unmodified lines
381200 });
382201
383202 const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")(
384− function* (server: Pick<OpenCodeRuntime.OpenCodeServerConnection, "url">) {
203+ function* (
204+ server: Pick<
205+ OpenCodeRuntime.OpenCodeServerConnection,
206+ "url" | "serverPassword" | "version"
207+ >,
208+ ) {
385209 const client = openCodeRuntime.createOpenCodeSdkClient({
386210 baseUrl: server.url,
387211 directory: input.cwd,
388− ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword
389− ? { serverPassword: openCodeSettings.serverPassword }
390− : {}),
212+ ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
391213 });
392214 const session = yield* Effect.tryPromise({
393215 try: () =>
@@ -496,17 +318,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" · 102 unmodified lines
496318 }),
497319 );
498320
499− const rawOutput =
321+ const serverOutput =
500322 openCodeSettings.serverUrl.length > 0
501− ? yield* runAgainstServer({ url: openCodeSettings.serverUrl })
502− : yield* Effect.acquireUseRelease(
503− acquireSharedServer({
323+ ? openCodeRuntime
324+ .connectToOpenCodeServer({
504325 binaryPath: openCodeSettings.binaryPath,
326+ directory: input.cwd,
327+ serverUrl: openCodeSettings.serverUrl,
328+ ...(openCodeSettings.serverPassword
329+ ? { serverPassword: openCodeSettings.serverPassword }
330+ : {}),
331+ })
332+ .pipe(Effect.flatMap(runAgainstServer), Effect.scoped)
333+ : serverOwner.withServer(runAgainstServer);
334+ const rawOutput = yield* serverOutput.pipe(
335+ Effect.catchTags({
336+ OpenCodeRuntimeError: (cause) =>
337+ Effect.fail(
338+ new TextGenerationError({
505339 operation: input.operation,
340+ detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
341+ cause,
506342 }),
507− runAgainstServer,
508− releaseSharedServer,
509− );
343+ ),
344+ }),
345+ );
510346
511347 const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson));
512348 return yield* decodeOutput(extractJsonObject(rawOutput)).pipe(
apps/web/src/components/chat/ChatComposer.tsx+11−3
@@ -997,16 +997,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 996 unmodified lines
997997 // Instance-keyed option list so the picker can show each configured
998998 // instance (built-in + custom) as a first-class sidebar entry. The
999999 // options are server-reported models plus that exact instance's
1000− // configured custom models; selected slugs are not injected into lists.
1000+ // configured custom models. A missing OpenCode selection is included as
1001+ // an unavailable row until the catalog reports it again.
10011002 const modelOptionsByInstance = useMemo<
10021003 ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>
10031004 >(() => {
10041005 const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
10051006 for (const entry of providerInstanceEntries) {
1006− out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
1007+ out.set(
1008+ entry.instanceId,
1009+ getAppModelOptionsForInstance(
1010+ settings,
1011+ entry,
1012+ entry.instanceId === selectedInstanceId ? selectedModelForPicker : null,
1013+ ),
1014+ );
10071015 }
10081016 return out;
1009− }, [providerInstanceEntries, settings]);
1017+ }, [providerInstanceEntries, selectedInstanceId, selectedModelForPicker, settings]);
10101018 const selectedModelForPickerWithCustomFallback = useMemo(() => {
10111019 const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? [];
10121020 return currentOptions.some((option) => option.slug === selectedModelForPicker)
apps/web/src/components/chat/ModelListRow.tsx+7−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 } from "./providerIconUtils";
1010 import { ComboboxItem } from "../ui/combobox";
1111 import { Button } from "../ui/button";
12+import { Badge } from "../ui/badge";
1213 import { Kbd } from "../ui/kbd";
1314 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1415 import { cn } from "~/lib/utils";
@@ -34,6 +35,7 @@ export const ModelListRow = memo(function ModelListRow(props: { · 19 unmodified lines
3435 preferShortName?: boolean;
3536 useTriggerLabel?: boolean;
3637 showNewBadge?: boolean;
38+ unavailable?: boolean;
3739 jumpLabel?: string | null;
3840 disabledReason?: string | null;
3941 onToggleFavorite: () => void;
@@ -75,6 +77,11 @@ export const ModelListRow = memo(function ModelListRow(props: { · 35 unmodified lines
7577 New
7678 </span>
7779 ) : null}
80+ {props.unavailable ? (
81+ <Badge variant="outline" size="sm">
82+ Unavailable
83+ </Badge>
84+ ) : null}
7885 </div>
7986 {props.showProvider && (
8087 <div className="mt-1 flex items-center gap-1.5">
apps/web/src/components/chat/ModelPickerContent.test.ts+67−0
@@ -0,0 +1,67 @@
1+import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { deriveProviderInstanceEntries } from "../../providerInstances";
5+import { shouldIncludeModelPickerOption } from "./ModelPickerContent";
6+
7+function entry(status: ServerProvider["status"]) {
8+ return deriveProviderInstanceEntries([
9+ {
10+ instanceId: ProviderInstanceId.make("opencode_work"),
11+ driver: ProviderDriverKind.make("opencode"),
12+ enabled: true,
13+ installed: true,
14+ version: null,
15+ status,
16+ auth: { status: "authenticated" },
17+ checkedAt: "2026-08-28T00:00:00.000Z",
18+ models: [],
19+ slashCommands: [],
20+ skills: [],
21+ },
22+ ])[0]!;
23+}
24+
25+describe("shouldIncludeModelPickerOption", () => {
26+ it.each(["error", "warning"] as const)(
27+ "keeps only the active synthetic OpenCode row when the provider status is %s",
28+ (status) => {
29+ const providerEntry = entry(status);
30+ const activeInstanceId = ProviderInstanceId.make("opencode_work");
31+ const activeModel = "openrouter/kimi-k3";
32+
33+ expect(
34+ shouldIncludeModelPickerOption({
35+ entry: providerEntry,
36+ option: {
37+ slug: activeModel,
38+ name: activeModel,
39+ isUnavailable: true,
40+ },
41+ activeInstanceId,
42+ activeModel,
43+ }),
44+ ).toBe(true);
45+ expect(
46+ shouldIncludeModelPickerOption({
47+ entry: providerEntry,
48+ option: { slug: "stale/model", name: "Stale model" },
49+ activeInstanceId,
50+ activeModel,
51+ }),
52+ ).toBe(false);
53+ expect(
54+ shouldIncludeModelPickerOption({
55+ entry: providerEntry,
56+ option: {
57+ slug: "other/missing",
58+ name: "Other missing",
59+ isUnavailable: true,
60+ },
61+ activeInstanceId,
62+ activeModel,
63+ }),
64+ ).toBe(false);
65+ },
66+ );
67+});
apps/web/src/components/chat/ModelPickerContent.tsx+51−13
@@ -53,8 +53,25 @@ type ModelPickerItem = { · 52 unmodified lines
5353 instanceAccentColor?: string | undefined;
5454 continuationGroupKey?: string | undefined;
5555 isLegacy?: boolean | undefined;
56+ isUnavailable?: boolean | undefined;
5657 };
5758
59+export function shouldIncludeModelPickerOption(input: {
60+ readonly entry: ProviderInstanceEntry;
61+ readonly option: ModelEsque;
62+ readonly activeInstanceId: ProviderInstanceId;
63+ readonly activeModel: string;
64+}): boolean {
65+ if (isProviderInstancePickerReady(input.entry)) return true;
66+ return (
67+ input.entry.enabled &&
68+ input.entry.driverKind === "opencode" &&
69+ input.entry.instanceId === input.activeInstanceId &&
70+ input.option.slug === input.activeModel &&
71+ input.option.isUnavailable === true
72+ );
73+}
74+
5875 const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
5976
6077 function ModelListSeparator() {
@@ -107,9 +124,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 46 unmodified lines
107124 const modelListRef = useRef<LegendListRef | null>(null);
108125 const highlightedModelKeyRef = useRef<string | null>(null);
109126 const favorites = useClientSettings((s) => s.favorites ?? []);
127+ const activeEntry = props.instanceEntries.find(
128+ (entry) => entry.instanceId === props.activeInstanceId,
129+ );
130+ const activeInstanceHasSelectableUnavailableModel =
131+ activeEntry !== undefined &&
132+ (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>
133+ shouldIncludeModelPickerOption({
134+ entry: activeEntry,
135+ option,
136+ activeInstanceId: props.activeInstanceId,
137+ activeModel: props.model,
138+ }),
139+ ) &&
140+ !isProviderInstancePickerReady(activeEntry);
110141 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
111142 () => {
112− if (props.lockedProvider !== null) {
143+ if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) {
113144 // When locked, prime the sidebar to the currently-active instance
114145 // so jumping into the picker keeps the focused instance visible.
115146 return props.activeInstanceId;
@@ -189,15 +220,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 73 unmodified lines
189220 [props.lockedContinuationGroupKey, props.lockedProvider],
190221 );
191222
192− const readyInstanceSet = useMemo(() => {
193− const ready = new Set<ProviderInstanceId>();
194− for (const entry of instanceEntries) {
195− if (isProviderInstancePickerReady(entry)) {
196− ready.add(entry.instanceId);
197− }
223+ const selectableUnavailableInstanceIds = useMemo(() => {
224+ if (!activeInstanceHasSelectableUnavailableModel) {
225+ return undefined;
198226 }
199− return ready;
200− }, [instanceEntries]);
227+ return new Set([props.activeInstanceId]);
228+ }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]);
201229
202230 // Flatten models into a searchable array. One pass over the
203231 // instance-keyed map; each model carries its instance id + driver kind
@@ -212,16 +240,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 8 unmodified lines
212240 // its models — stale options shouldn't appear in the picker.
213241 continue;
214242 }
215− if (!readyInstanceSet.has(instanceId)) {
216− continue;
217− }
218243 for (const model of models) {
244+ if (
245+ !shouldIncludeModelPickerOption({
246+ entry,
247+ option: model,
248+ activeInstanceId: props.activeInstanceId,
249+ activeModel: props.model,
250+ })
251+ ) {
252+ continue;
253+ }
219254 out.push({
220255 slug: model.slug,
221256 name: model.name,
222257 ...(model.shortName ? { shortName: model.shortName } : {}),
223258 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
224259 ...(model.isLegacy ? { isLegacy: true } : {}),
260+ ...(model.isUnavailable ? { isUnavailable: true } : {}),
225261 instanceId,
226262 driverKind: entry.driverKind,
227263 instanceDisplayName: entry.displayName,
@@ -233,7 +269,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 5 unmodified lines
233269 }
234270 }
235271 return out;
236− }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]);
272+ }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]);
237273
238274 const isLocked = props.lockedProvider !== null;
239275 const isSearching = searchQuery.trim().length > 0;
@@ -609,6 +645,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 369 unmodified lines
609645 onSelectInstance={handleSelectInstance}
610646 instanceEntries={sidebarInstanceEntries}
611647 showFavorites
648+ {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})}
612649 {...(lockedDisabledInstanceIds
613650 ? {
614651 disabledInstanceIds: lockedDisabledInstanceIds,
@@ -768,6 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 153 unmodified lines
768805 preferShortName={!isLocked}
769806 useTriggerLabel={false}
770807 showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
808+ unavailable={model.isUnavailable === true}
771809 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
772810 disabledReason={disabledReason}
773811 onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
apps/web/src/components/chat/ModelPickerSidebar.tsx+7−2
@@ -54,6 +54,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 53 unmodified lines
5454 showFavorites?: boolean;
5555 /** Instance ids shown in the rail but unavailable for the current picker context. */
5656 disabledInstanceIds?: ReadonlySet<ProviderInstanceId>;
57+ /** Non-ready instances whose selected unavailable model remains reachable. */
58+ selectableUnavailableInstanceIds?: ReadonlySet<ProviderInstanceId>;
5759 getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string;
5860 /**
5961 * Instance id values that should render the "new" sparkle badge. Callers
@@ -135,7 +137,10 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 75 unmodified lines
135137 {props.instanceEntries.map((entry) => {
136138 const isUnavailable = !isProviderInstancePickerReady(entry);
137139 const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false;
138− const isDisabled = isUnavailable || isContextDisabled;
140+ const unavailableSelectionIsReachable =
141+ props.selectableUnavailableInstanceIds?.has(entry.instanceId) ?? false;
142+ const isDisabled =
143+ (isUnavailable && !unavailableSelectionIsReachable) || isContextDisabled;
139144 const isSelected = props.selectedInstanceId === entry.instanceId;
140145 const isHovered = hoveredInstanceId === entry.instanceId;
141146 const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false;
@@ -168,7 +173,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 26 unmodified lines
168173 disabled={isDisabled}
169174 type="button"
170175 aria-label={
171− isDisabled
176+ isUnavailable || isContextDisabled
172177 ? tooltip
173178 : showNewBadge
174179 ? `${entry.displayName}, new`
apps/web/src/components/chat/ProviderModelPicker.test.tsx+102−0
@@ -0,0 +1,102 @@
1+import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2+import { renderToStaticMarkup } from "react-dom/server";
3+import { describe, expect, it } from "vite-plus/test";
4+
5+import { deriveProviderInstanceEntries } from "../../providerInstances";
6+import { ProviderModelPicker } from "./ProviderModelPicker";
7+import type { ModelEsque } from "./providerIconUtils";
8+
9+function providerEntry(instanceId: string, driver: string) {
10+ const provider: ServerProvider = {
11+ instanceId: ProviderInstanceId.make(instanceId),
12+ driver: ProviderDriverKind.make(driver),
13+ enabled: true,
14+ installed: true,
15+ version: null,
16+ status: "ready",
17+ auth: { status: "authenticated" },
18+ checkedAt: "2026-08-28T00:00:00.000Z",
19+ models: [],
20+ slashCommands: [],
21+ skills: [],
22+ };
23+ return deriveProviderInstanceEntries([provider])[0]!;
24+}
25+
26+function renderPicker(input: {
27+ instanceId: string;
28+ driver: string;
29+ model: string;
30+ options: ReadonlyArray<ModelEsque>;
31+ includeEntry?: boolean;
32+}) {
33+ const instanceId = ProviderInstanceId.make(input.instanceId);
34+ const entry = providerEntry(input.instanceId, input.driver);
35+ return renderToStaticMarkup(
36+ <ProviderModelPicker
37+ activeInstanceId={instanceId}
38+ model={input.model}
39+ lockedProvider={null}
40+ instanceEntries={input.includeEntry === false ? [] : [entry]}
41+ modelOptionsByInstance={new Map([[instanceId, input.options]])}
42+ onInstanceModelChange={() => {}}
43+ />,
44+ );
45+}
46+
47+describe("ProviderModelPicker", () => {
48+ it("shows a missing model slug for a custom OpenCode instance", () => {
49+ const markup = renderPicker({
50+ instanceId: "team_runtime",
51+ driver: "opencode",
52+ model: "openrouter/missing-model",
53+ options: [{ slug: "openrouter/fallback", name: "Fallback model" }],
54+ });
55+
56+ expect(markup).toContain("openrouter/missing-model");
57+ expect(markup).not.toContain("Fallback model");
58+ });
59+
60+ it.each(["codex", "claudeAgent", "cursor", "grok"])(
61+ "uses the first option label for a missing %s model",
62+ (driver) => {
63+ const markup = renderPicker({
64+ instanceId: `${driver}_work`,
65+ driver,
66+ model: "missing-model",
67+ options: [{ slug: "fallback-model", name: "Fallback model" }],
68+ });
69+
70+ expect(markup).toContain("Fallback model");
71+ expect(markup).not.toContain(">missing-model<");
72+ },
73+ );
74+
75+ it("prefers a matching model for OpenCode", () => {
76+ const markup = renderPicker({
77+ instanceId: "custom_runtime",
78+ driver: "opencode",
79+ model: "openrouter/selected",
80+ options: [
81+ { slug: "openrouter/fallback", name: "Fallback model" },
82+ { slug: "openrouter/selected", name: "Selected model" },
83+ ],
84+ });
85+
86+ expect(markup).toContain("Selected model");
87+ expect(markup).not.toContain("Fallback model");
88+ });
89+
90+ it("uses the first option when the active instance entry is missing", () => {
91+ const markup = renderPicker({
92+ instanceId: "missing_instance",
93+ driver: "opencode",
94+ model: "missing-model",
95+ options: [{ slug: "fallback-model", name: "Fallback model" }],
96+ includeEntry: false,
97+ });
98+
99+ expect(markup).toContain("Fallback model");
100+ expect(markup).not.toContain(">missing-model<");
101+ });
102+});
apps/web/src/components/chat/ProviderModelPicker.tsx+12−6
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 } from "@t3tools/contracts";
66 import { memo, useEffect, useMemo, useState } from "react";
77 import type { VariantProps } from "class-variance-authority";
8+import { Badge } from "../ui/badge";
89 import { buttonVariants } from "../ui/button";
910 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1011 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -58,15 +59,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 47 unmodified lines
5859
5960 const activeInstanceId = props.activeInstanceId;
6061 const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? [];
61− // If the current slug belongs to a different instance (for example after
62− // a provider switch or disable), prefer the active instance's first
63− // option so the trigger icon and label stay in sync instead of showing
64− // a stale foreign slug.
62+ // OpenCode can keep a model through a transient catalog refresh. Other
63+ // providers keep the active instance's first option as their normal fallback.
6564 const selectedModel =
6665 selectedInstanceOptions.find((option) => option.slug === props.model) ??
67− selectedInstanceOptions[0];
66+ (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]);
6867 const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model;
69− const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model;
68+ const triggerLabel = selectedModel
69+ ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
70+ : props.model;
7071 const showInstanceBadge =
7172 activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries);
7273
@@ -179,6 +180,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 106 unmodified lines
179180 </TooltipTrigger>
180181 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
181182 </Tooltip>
183+ {selectedModel?.isUnavailable ? (
184+ <Badge variant="outline" size="sm">
185+ Unavailable
186+ </Badge>
187+ ) : null}
182188 </span>
183189 <span aria-hidden="true" className="flex items-center">
184190 <ComposerControlChevron />
apps/web/src/components/chat/TraitsPicker.test.ts+34−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "vite-plus/test";
22 import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts";
3−import { buildTraitsTriggerDisplay } from "./TraitsPicker";
3+import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker";
44
55 function selectDescriptor(
66 id: string,
@@ -155,3 +155,36 @@ describe("buildTraitsTriggerDisplay", () => { · 148 unmodified lines
155155 ).toEqual({ label: "Ultrathink", showFastModeIcon: true });
156156 });
157157 });
158+
159+describe("buildUnavailableModelOptionDescriptors", () => {
160+ it("shows only saved values without inventing alternatives", () => {
161+ expect(
162+ buildUnavailableModelOptionDescriptors([
163+ { id: "variant", value: "max" },
164+ { id: "agent", value: "build" },
165+ { id: "fastMode", value: true },
166+ ]),
167+ ).toEqual([
168+ {
169+ id: "variant",
170+ label: "Variant",
171+ type: "select",
172+ options: [{ id: "max", label: "max" }],
173+ currentValue: "max",
174+ },
175+ {
176+ id: "agent",
177+ label: "Agent",
178+ type: "select",
179+ options: [{ id: "build", label: "build" }],
180+ currentValue: "build",
181+ },
182+ {
183+ id: "fastMode",
184+ label: "Fast Mode",
185+ type: "boolean",
186+ currentValue: true,
187+ },
188+ ]);
189+ });
190+});
apps/web/src/components/chat/TraitsPicker.tsx+81−5
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 getProviderOptionCurrentValue,
1414 getProviderOptionDescriptors,
1515 isClaudeUltrathinkPrompt,
16+ normalizeModelSlug,
1617 } from "@t3tools/shared/model";
1718 import { memo, useCallback, useState } from "react";
1819 import type { VariantProps } from "class-variance-authority";
@@ -35,6 +36,42 @@ import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ · 16 unmodified lines
3536
3637 type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
3738
39+const SAVED_OPTION_LABELS: Readonly<Record<string, string>> = {
40+ agent: "Agent",
41+ effort: "Effort",
42+ reasoningEffort: "Reasoning effort",
43+ variant: "Variant",
44+};
45+
46+function savedOptionLabel(id: string): string {
47+ return (
48+ SAVED_OPTION_LABELS[id] ??
49+ id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (character) => character.toUpperCase())
50+ );
51+}
52+
53+/** Read-only descriptors for saved values whose OpenCode model metadata is unavailable. */
54+export function buildUnavailableModelOptionDescriptors(
55+ selections: ProviderOptions | null | undefined,
56+): ReadonlyArray<ProviderOptionDescriptor> {
57+ return (selections ?? []).map((selection) =>
58+ typeof selection.value === "boolean"
59+ ? {
60+ id: selection.id,
61+ label: savedOptionLabel(selection.id),
62+ type: "boolean" as const,
63+ currentValue: selection.value,
64+ }
65+ : {
66+ id: selection.id,
67+ label: savedOptionLabel(selection.id),
68+ type: "select" as const,
69+ options: [{ id: selection.value, label: selection.value }],
70+ currentValue: selection.value,
71+ },
72+ );
73+}
74+
3875 type TraitsPersistence =
3976 | {
4077 threadRef?: ScopedThreadRef;
@@ -99,10 +136,19 @@ function getSelectedTraits( · 58 unmodified lines
99136 planModeEnabled: boolean,
100137 ) {
101138 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
102− const descriptors = getProviderOptionDescriptors({
103− caps,
104− selections: modelOptions,
105− });
139+ const modelIsUnavailable =
140+ provider === "opencode" &&
141+ !models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider));
142+ const descriptors = modelIsUnavailable
143+ ? buildUnavailableModelOptionDescriptors(
144+ planModeEnabled
145+ ? modelOptions
146+ : modelOptions?.filter((option) => option.id !== "agent" || option.value !== "plan"),
147+ )
148+ : getProviderOptionDescriptors({
149+ caps,
150+ selections: modelOptions,
151+ });
106152 const selectDescriptors = descriptors.filter(
107153 (descriptor): descriptor is Extract<ProviderOptionDescriptor, { type: "select" }> =>
108154 descriptor.type === "select",
@@ -158,6 +204,7 @@ function getSelectedTraits( · 49 unmodified lines
158204 ultrathinkInBodyText,
159205 selectedAgent,
160206 selectedAgentLabel,
207+ modelIsUnavailable,
161208 };
162209 }
163210
@@ -193,7 +240,13 @@ function getTraitsSectionVisibility(input: { · 29 unmodified lines
193240 showFastMode,
194241 showContextWindow,
195242 showAgent,
196− hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow || showAgent,
243+ hasAnyControls:
244+ showEffort ||
245+ showThinking ||
246+ showFastMode ||
247+ showContextWindow ||
248+ showAgent ||
249+ (selected.modelIsUnavailable && selected.descriptors.length > 0),
197250 };
198251 }
199252
@@ -262,6 +315,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 62 unmodified lines
262315 ultrathinkPromptControlled,
263316 ultrathinkInBodyText,
264317 hasAnyControls,
318+ modelIsUnavailable,
265319 } = getTraitsSectionVisibility({
266320 provider,
267321 models,
@@ -300,6 +354,28 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 32 unmodified lines
300354 return null;
301355 }
302356
357+ if (modelIsUnavailable) {
358+ return (
359+ <>
360+ {descriptors.map((descriptor, index) => {
361+ const value = getProviderOptionCurrentLabel(descriptor);
362+ if (!value) return null;
363+ return (
364+ <div key={descriptor.id}>
365+ {index > 0 ? <MenuDivider /> : null}
366+ <MenuGroup>
367+ <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
368+ {descriptor.label}
369+ </div>
370+ <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">{value}</div>
371+ </MenuGroup>
372+ </div>
373+ );
374+ })}
375+ </>
376+ );
377+ }
378+
303379 return (
304380 <>
305381 {selectDescriptors.map((descriptor, index) => {
apps/web/src/components/chat/composerProviderState.test.tsx+88−0
@@ -238,6 +238,94 @@ describe("getComposerProviderState", () => { · 237 unmodified lines
238238 });
239239 });
240240
241+ it("preserves explicit options when the selected model is absent from the catalog", () => {
242+ const state = getComposerProviderState({
243+ provider: ProviderDriverKind.make("opencode"),
244+ model: "opencode/kimi-k3",
245+ models: [
246+ {
247+ slug: "opencode/big-pickle",
248+ name: "Big Pickle",
249+ isCustom: false,
250+ capabilities: {},
251+ },
252+ ],
253+ modelOptions: selections(["variant", "max"], ["agent", "build"]),
254+ planModeEnabled: false,
255+ });
256+
257+ expect(state.modelOptionsForDispatch).toEqual(
258+ selections(["variant", "max"], ["agent", "build"]),
259+ );
260+ });
261+
262+ it.each(["codex", "claudeAgent", "cursor", "grok"])(
263+ "does not preserve unknown options for a missing %s model",
264+ (provider) => {
265+ const state = getComposerProviderState({
266+ provider: ProviderDriverKind.make(provider),
267+ model: "missing-model",
268+ models: modelWith([]),
269+ modelOptions: selections(["unknown", "value"]),
270+ planModeEnabled: true,
271+ });
272+
273+ expect(state.modelOptionsForDispatch).toBeUndefined();
274+ },
275+ );
276+
277+ it("preserves explicit options while the catalog is empty", () => {
278+ const state = getComposerProviderState({
279+ provider: ProviderDriverKind.make("opencode"),
280+ model: "opencode/kimi-k3",
281+ models: [],
282+ modelOptions: selections(["variant", "max"], ["agent", "build"]),
283+ planModeEnabled: false,
284+ });
285+
286+ expect(state.modelOptionsForDispatch).toEqual(
287+ selections(["variant", "max"], ["agent", "build"]),
288+ );
289+ });
290+
291+ it("validates options for a known model selected through a legacy alias", () => {
292+ const state = getComposerProviderState({
293+ provider: ProviderDriverKind.make("claudeAgent"),
294+ model: "opus",
295+ models: [
296+ {
297+ slug: "claude-opus-5",
298+ name: "Claude Opus 5",
299+ isCustom: false,
300+ capabilities: {
301+ optionDescriptors: [
302+ selectDescriptor("effort", [
303+ { id: "low", label: "Low" },
304+ { id: "high", label: "High", isDefault: true },
305+ ]),
306+ ],
307+ },
308+ },
309+ ],
310+ modelOptions: selections(["effort", "low"], ["unknown", "value"]),
311+ planModeEnabled: false,
312+ });
313+
314+ expect(state.modelOptionsForDispatch).toEqual(selections(["effort", "low"]));
315+ });
316+
317+ it("still drops the plan agent when an absent model has a saved plan selection", () => {
318+ const state = getComposerProviderState({
319+ provider: ProviderDriverKind.make("opencode"),
320+ model: "opencode/kimi-k3",
321+ models: [],
322+ modelOptions: selections(["variant", "max"], ["agent", "plan"]),
323+ planModeEnabled: false,
324+ });
325+
326+ expect(state.modelOptionsForDispatch).toEqual(selections(["variant", "max"]));
327+ });
328+
241329 it("adds ultrathink class names when the prompt triggers a promptInjectedValues descriptor", () => {
242330 const state = getComposerProviderState({
243331 provider: PROVIDER,
apps/web/src/components/chat/composerProviderState.tsx+16−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 getProviderOptionCurrentValue,
1111 getProviderOptionDescriptors,
1212 isClaudeUltrathinkPrompt,
13+ normalizeModelSlug,
1314 } from "@t3tools/shared/model";
1415 import type { ReactNode } from "react";
1516
@@ -63,6 +64,21 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com · 47 unmodified lines
6364 promptInjectionState = "none",
6465 planModeEnabled,
6566 } = input;
67+ if (provider === "opencode") {
68+ const normalizedModel = normalizeModelSlug(model, provider);
69+ const modelIsInCatalog = models.some((candidate) => candidate.slug === normalizedModel);
70+ if (!modelIsInCatalog) {
71+ const preservedOptions = modelOptions?.filter(
72+ (option) => planModeEnabled || option.id !== "agent" || option.value !== "plan",
73+ );
74+ return {
75+ provider,
76+ promptEffort: null,
77+ modelOptionsForDispatch:
78+ preservedOptions && preservedOptions.length > 0 ? preservedOptions : undefined,
79+ };
80+ }
81+ }
6682 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
6783 const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });
6884 const primarySelectDescriptor = descriptors.find(
apps/web/src/components/chat/providerIconUtils.ts+1−0
@@ -27,6 +27,7 @@ export type ModelEsque = { · 26 unmodified lines
2727 shortName?: string | undefined;
2828 subProvider?: string | undefined;
2929 isLegacy?: boolean | undefined;
30+ isUnavailable?: boolean | undefined;
3031 };
3132
3233 function escapeRegExp(value: string): string {
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6−5
@@ -417,6 +417,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { · 416 unmodified lines
417417 // ----- default model -----
418418 const storedSelection = representative.defaultModelSelection;
419419 const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);
420+ const resolvedInstanceId = resolvedSelection?.instanceId ?? null;
421+ const resolvedModel = resolvedSelection?.model ?? null;
420422 const instanceEntries = useMemo(
421423 () =>
422424 sortProviderInstanceEntries(
@@ -425,12 +427,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { · 2 unmodified lines
425427 [serverProviders, settings],
426428 );
427429 const modelOptionsByInstance = useMemo(
428− () => getCustomModelOptionsByInstance(settings, serverProviders),
429− [serverProviders, settings],
430− );
431− const activeEntry = instanceEntries.find(
432− (entry) => entry.instanceId === resolvedSelection?.instanceId,
430+ () =>
431+ getCustomModelOptionsByInstance(settings, serverProviders, resolvedInstanceId, resolvedModel),
432+ [resolvedInstanceId, resolvedModel, serverProviders, settings],
433433 );
434+ const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);
434435 const setDefaultModel = useCallback(
435436 (selection: ModelSelection | null) =>
436437 void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"),
apps/web/src/composerDraftStore.ts+6−0
@@ -1025,6 +1025,10 @@ export function deriveEffectiveComposerModelState(input: { · 1024 unmodified lines
10251025 }): EffectiveComposerModelState {
10261026 const baseModelCandidate =
10271027 input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null;
1028+ const preserveThreadModel =
1029+ input.selectedInstanceId !== null &&
1030+ input.selectedInstanceId !== undefined &&
1031+ input.threadModelSelection?.instanceId === input.selectedInstanceId;
10281032 const baseModel =
10291033 (input.selectedInstanceId
10301034 ? resolveAppModelSelectionForInstance(
@@ -1032,6 +1036,7 @@ export function deriveEffectiveComposerModelState(input: { · 1 unmodified line
10321036 input.settings,
10331037 input.providers,
10341038 baseModelCandidate,
1039+ { preserveUnavailableSelection: preserveThreadModel },
10351040 )
10361041 : null) ??
10371042 resolveAppModelSelection(
@@ -1061,6 +1066,7 @@ export function deriveEffectiveComposerModelState(input: { · 23 unmodified lines
10611066 input.settings,
10621067 input.providers,
10631068 activeSelection.model,
1069+ { preserveUnavailableSelection: true },
10641070 ) ??
10651071 resolveAppModelSelection(
10661072 input.selectedProvider,
apps/web/src/modelSelection.test.ts+293−0
@@ -2,8 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 · 1 unmodified line
22 import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
33 import { describe, expect, it } from "vite-plus/test";
44 import { createModelSelection } from "@t3tools/shared/model";
5+import { deriveEffectiveComposerModelState } from "./composerDraftStore";
6+import { getComposerProviderState } from "./components/chat/composerProviderState";
57 import { deriveProviderInstanceEntries } from "./providerInstances";
68 import {
9+ getCustomModelOptionsByInstance,
710 getAppModelOptionsForInstance,
811 resolveAppModelSelectionForInstance,
912 resolveAppModelSelectionState,
@@ -275,6 +278,15 @@ describe("instance-scoped model selection", () => { · 265 unmodified lines
275278 "claude-opus-4-6",
276279 ),
277280 ).toBe("claude-sonnet-4-6");
281+ expect(
282+ resolveAppModelSelectionForInstance(
283+ ProviderInstanceId.make("claudeAgent"),
284+ settings,
285+ providers,
286+ "claude-opus-4-6",
287+ { preserveUnavailableSelection: true },
288+ ),
289+ ).toBe("claude-sonnet-4-6");
278290 });
279291
280292 it("falls back instead of resolving a custom slug against the wrong instance", () => {
@@ -299,6 +311,287 @@ describe("instance-scoped model selection", () => { · 18 unmodified lines
299311 ).toBe("claude-sonnet-4-6");
300312 });
301313
314+ it("preserves an existing OpenCode model when a catalog refresh no longer contains it", () => {
315+ const providers = [
316+ provider({
317+ provider: ProviderDriverKind.make("opencode"),
318+ instanceId: "opencode",
319+ models: ["opencode/big-pickle"],
320+ }),
321+ ];
322+
323+ expect(
324+ resolveAppModelSelectionForInstance(
325+ ProviderInstanceId.make("opencode"),
326+ settingsWithProviderInstances(),
327+ providers,
328+ "opencode/kimi-k3",
329+ { preserveUnavailableSelection: true },
330+ ),
331+ ).toBe("opencode/kimi-k3");
332+ expect(
333+ resolveAppModelSelectionForInstance(
334+ ProviderInstanceId.make("opencode"),
335+ settingsWithProviderInstances(),
336+ providers,
337+ "opencode/kimi-k3",
338+ ),
339+ ).toBe("opencode/big-pickle");
340+ });
341+
342+ it("adds the selected missing OpenCode model as an unavailable option", () => {
343+ const providers = [
344+ provider({
345+ provider: ProviderDriverKind.make("opencode"),
346+ instanceId: "opencode",
347+ models: ["opencode/big-pickle"],
348+ }),
349+ ];
350+ const entry = deriveProviderInstanceEntries(providers)[0]!;
351+
352+ expect(
353+ getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "opencode/kimi-k3"),
354+ ).toEqual([
355+ expect.objectContaining({ slug: "opencode/big-pickle" }),
356+ expect.objectContaining({
357+ slug: "opencode/kimi-k3",
358+ name: "opencode/kimi-k3",
359+ isUnavailable: true,
360+ }),
361+ ]);
362+ });
363+
364+ it("keeps a missing OpenCode option scoped to the selected instance", () => {
365+ const selectedInstanceId = ProviderInstanceId.make("opencode_work");
366+ const otherInstanceId = ProviderInstanceId.make("opencode_personal");
367+ const driver = ProviderDriverKind.make("opencode");
368+ const providers = [
369+ provider({ provider: driver, instanceId: selectedInstanceId, models: [] }),
370+ provider({ provider: driver, instanceId: otherInstanceId, models: [] }),
371+ ];
372+ const options = getCustomModelOptionsByInstance(
373+ settingsWithProviderInstances(),
374+ providers,
375+ selectedInstanceId,
376+ "openrouter/kimi-k3",
377+ );
378+
379+ expect(options.get(selectedInstanceId)).toEqual([
380+ expect.objectContaining({ slug: "openrouter/kimi-k3", isUnavailable: true }),
381+ ]);
382+ expect(options.get(otherInstanceId)).toEqual([]);
383+ });
384+
385+ it("replaces the unavailable marker with catalog metadata after recovery", () => {
386+ const instanceId = ProviderInstanceId.make("opencode");
387+ const driver = ProviderDriverKind.make("opencode");
388+ const selectedModel = "opencode/kimi-k3";
389+ const pendingProviders = [
390+ provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] }),
391+ ];
392+ const recoveredProviders = [
393+ provider({ provider: driver, instanceId, models: ["opencode/big-pickle", selectedModel] }),
394+ ];
395+
396+ expect(
397+ getAppModelOptionsForInstance(
398+ settingsWithProviderInstances(),
399+ deriveProviderInstanceEntries(pendingProviders)[0]!,
400+ selectedModel,
401+ ).find((option) => option.slug === selectedModel)?.isUnavailable,
402+ ).toBe(true);
403+ expect(
404+ getAppModelOptionsForInstance(
405+ settingsWithProviderInstances(),
406+ deriveProviderInstanceEntries(recoveredProviders)[0]!,
407+ selectedModel,
408+ ).find((option) => option.slug === selectedModel)?.isUnavailable,
409+ ).toBeUndefined();
410+ expect(
411+ resolveAppModelSelectionForInstance(
412+ instanceId,
413+ settingsWithProviderInstances(),
414+ recoveredProviders,
415+ selectedModel,
416+ { preserveUnavailableSelection: true },
417+ ),
418+ ).toBe(selectedModel);
419+ });
420+
421+ it("does not resurrect a hidden OpenCode model when the raw catalog omits it", () => {
422+ const instanceId = ProviderInstanceId.make("opencode");
423+ const driver = ProviderDriverKind.make("opencode");
424+ const settings: UnifiedSettings = {
425+ ...settingsWithProviderInstances(),
426+ providerModelPreferences: {
427+ [instanceId]: {
428+ hiddenModels: ["opencode/kimi-k3"],
429+ modelOrder: [],
430+ },
431+ },
432+ };
433+ const providers = [provider({ provider: driver, instanceId, models: [] })];
434+ const entry = deriveProviderInstanceEntries(providers)[0]!;
435+
436+ expect(getAppModelOptionsForInstance(settings, entry, "opencode/kimi-k3")).toEqual([]);
437+ expect(
438+ resolveAppModelSelectionForInstance(instanceId, settings, providers, "opencode/kimi-k3", {
439+ preserveUnavailableSelection: true,
440+ }),
441+ ).toBeNull();
442+ });
443+
444+ it("does not add unavailable options for other providers", () => {
445+ const providers = [
446+ provider({
447+ provider: ProviderDriverKind.make("codex"),
448+ instanceId: "codex",
449+ models: ["gpt-5.6-sol"],
450+ }),
451+ ];
452+ const entry = deriveProviderInstanceEntries(providers)[0]!;
453+
454+ expect(
455+ getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "gpt-missing").map(
456+ (option) => option.slug,
457+ ),
458+ ).toEqual(["gpt-5.6-sol"]);
459+ expect(
460+ resolveAppModelSelectionForInstance(
461+ ProviderInstanceId.make("codex"),
462+ settingsWithProviderInstances(),
463+ providers,
464+ "gpt-missing",
465+ { preserveUnavailableSelection: true },
466+ ),
467+ ).toBe("gpt-5.6-sol");
468+ });
469+
470+ it("falls back from an explicit non-OpenCode draft with a missing model", () => {
471+ const instanceId = ProviderInstanceId.make("codex");
472+ const driver = ProviderDriverKind.make("codex");
473+ const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })];
474+ const state = deriveEffectiveComposerModelState({
475+ draft: {
476+ activeProvider: instanceId,
477+ modelSelectionByProvider: {
478+ [instanceId]: createModelSelection(instanceId, "gpt-missing", [
479+ { id: "effort", value: "max" },
480+ ]),
481+ },
482+ },
483+ providers,
484+ selectedProvider: driver,
485+ selectedInstanceId: instanceId,
486+ threadModelSelection: null,
487+ projectModelSelection: null,
488+ settings: settingsWithProviderInstances(),
489+ });
490+ const dispatch = getComposerProviderState({
491+ provider: driver,
492+ model: state.selectedModel,
493+ models: providers[0]!.models,
494+ modelOptions: state.modelOptions?.[instanceId],
495+ planModeEnabled: false,
496+ });
497+
498+ expect(state.selectedModel).toBe("gpt-5.6-sol");
499+ expect(dispatch.modelOptionsForDispatch).toBeUndefined();
500+ });
501+
502+ it("preserves an explicit draft OpenCode selection while the catalog is empty", () => {
503+ const instanceId = ProviderInstanceId.make("opencode_work");
504+ const driver = ProviderDriverKind.make("opencode");
505+ const draftSelection = createModelSelection(instanceId, "openrouter/kimi-k3", [
506+ { id: "variant", value: "max" },
507+ { id: "agent", value: "build" },
508+ ]);
509+ const providers = [provider({ provider: driver, instanceId, models: [] })];
510+ const state = deriveEffectiveComposerModelState({
511+ draft: {
512+ activeProvider: instanceId,
513+ modelSelectionByProvider: { [instanceId]: draftSelection },
514+ },
515+ providers,
516+ selectedProvider: driver,
517+ selectedInstanceId: instanceId,
518+ threadModelSelection: null,
519+ projectModelSelection: null,
520+ settings: settingsWithProviderInstances(),
521+ });
522+
523+ expect(state.selectedModel).toBe("openrouter/kimi-k3");
524+ expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options);
525+ });
526+
527+ it("preserves saved options through dispatch when the model is absent from the catalog", () => {
528+ const instanceId = ProviderInstanceId.make("opencode");
529+ const driver = ProviderDriverKind.make("opencode");
530+ const providers = [provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] })];
531+ const saved = createModelSelection(instanceId, "opencode/kimi-k3", [
532+ { id: "variant", value: "max" },
533+ { id: "agent", value: "build" },
534+ ]);
535+ const state = deriveEffectiveComposerModelState({
536+ draft: null,
537+ providers,
538+ selectedProvider: driver,
539+ selectedInstanceId: instanceId,
540+ threadModelSelection: saved,
541+ projectModelSelection: null,
542+ settings: settingsWithProviderInstances(),
543+ });
544+ const dispatch = getComposerProviderState({
545+ provider: driver,
546+ model: state.selectedModel,
547+ models: providers[0]!.models,
548+ modelOptions: state.modelOptions?.[instanceId],
549+ planModeEnabled: false,
550+ });
551+
552+ expect(
553+ createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch),
554+ ).toEqual(saved);
555+ });
556+
557+ it("keeps a custom-instance draft model while dropping unsupported options", () => {
558+ const instanceId = ProviderInstanceId.make("claude_openrouter");
559+ const driver = ProviderDriverKind.make("claudeAgent");
560+ const providers = [
561+ provider({ provider: driver, instanceId: "claudeAgent", models: ["claude-opus-5"] }),
562+ provider({ provider: driver, instanceId, models: ["claude-opus-5"] }),
563+ ];
564+ const threadSelection = createModelSelection(instanceId, "claude-opus-5", [
565+ { id: "effort", value: "high" },
566+ ]);
567+ const draftSelection = createModelSelection(instanceId, "openai/gpt-5.5", [
568+ { id: "effort", value: "max" },
569+ ]);
570+ const state = deriveEffectiveComposerModelState({
571+ draft: {
572+ activeProvider: instanceId,
573+ modelSelectionByProvider: { [instanceId]: draftSelection },
574+ },
575+ providers,
576+ selectedProvider: driver,
577+ selectedInstanceId: instanceId,
578+ threadModelSelection: threadSelection,
579+ projectModelSelection: null,
580+ settings: settingsWithProviderInstances(),
581+ });
582+ const dispatch = getComposerProviderState({
583+ provider: driver,
584+ model: state.selectedModel,
585+ models: providers[1]!.models,
586+ modelOptions: state.modelOptions?.[instanceId],
587+ planModeEnabled: false,
588+ });
589+
590+ expect(
591+ createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch),
592+ ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5"));
593+ });
594+
302595 it("preserves custom provider instances in settings model selection", () => {
303596 const providers = [
304597 provider({
apps/web/src/modelSelection.ts+66−13
@@ -78,6 +78,27 @@ export interface AppModelOption { · 77 unmodified lines
7878 isCustom: boolean;
7979 isDefault?: boolean;
8080 isLegacy?: boolean;
81+ isUnavailable?: boolean;
82+}
83+
84+function appendUnavailableOpenCodeSelection(
85+ options: AppModelOption[],
86+ rawModels: ReadonlyArray<ServerProvider["models"][number]>,
87+ provider: ProviderDriverKind,
88+ selectedModel: string | null | undefined,
89+ hiddenModels: ReadonlyArray<string>,
90+): AppModelOption[] {
91+ if (provider !== "opencode") return options;
92+ const slug = normalizeCustomModelSlug(selectedModel);
93+ if (!slug) return options;
94+
95+ // A model that exists in the raw catalog can be absent from `options`
96+ // because the user hid it. Keep that preference authoritative.
97+ if (rawModels.some((model) => model.slug === slug)) return options;
98+ if (hiddenModels.includes(slug)) return options;
99+ if (options.some((option) => option.slug === slug)) return options;
100+
101+ return [...options, { slug, name: slug, isCustom: false, isUnavailable: true }];
81102 }
82103
83104 function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption {
@@ -151,9 +172,10 @@ export function getAppModelOptions( · 67 unmodified lines
151172 settings: UnifiedSettings,
152173 providers: ReadonlyArray<ServerProvider>,
153174 provider: ProviderDriverKind,
154− _selectedModel?: string | null,
175+ selectedModel?: string | null,
155176 ): AppModelOption[] {
156− const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption);
177+ const rawModels = getProviderModels(providers, provider);
178+ const options: AppModelOption[] = rawModels.map(toAppModelOption);
157179 const seen = new Set(options.map((option) => option.slug));
158180 const builtInModelSlugs = new Set(
159181 Arr.filterMap(getProviderModels(providers, provider), (model) =>
@@ -180,9 +202,13 @@ export function getAppModelOptions( · 20 unmodified lines
180202 });
181203 }
182204
183− return applyInstanceModelPreferences(
184− options,
185− readInstanceModelPreferences(settings, defaultInstanceId),
205+ const preferences = readInstanceModelPreferences(settings, defaultInstanceId);
206+ return appendUnavailableOpenCodeSelection(
207+ applyInstanceModelPreferences(options, preferences),
208+ rawModels,
209+ provider,
210+ selectedModel,
211+ preferences.hiddenModels,
186212 );
187213 }
188214
@@ -200,6 +226,7 @@ export function getAppModelOptions( · 11 unmodified lines
200226 export function getAppModelOptionsForInstance(
201227 settings: UnifiedSettings,
202228 entry: ProviderInstanceEntry,
229+ selectedModel?: string | null,
203230 ): AppModelOption[] {
204231 const options: AppModelOption[] = entry.models.map(toAppModelOption);
205232 const seen = new Set(options.map((option) => option.slug));
@@ -219,9 +246,13 @@ export function getAppModelOptionsForInstance( · 13 unmodified lines
219246 options.push({ slug, name: slug, isCustom: true });
220247 }
221248
222− return applyInstanceModelPreferences(
223− options,
224− readInstanceModelPreferences(settings, entry.instanceId),
249+ const preferences = readInstanceModelPreferences(settings, entry.instanceId);
250+ return appendUnavailableOpenCodeSelection(
251+ applyInstanceModelPreferences(options, preferences),
252+ entry.models,
253+ entry.driverKind,
254+ selectedModel,
255+ preferences.hiddenModels,
225256 );
226257 }
227258
@@ -244,14 +275,29 @@ export function resolveAppModelSelectionForInstance( · 16 unmodified lines
244275 settings: UnifiedSettings,
245276 providers: ReadonlyArray<ServerProvider>,
246277 selectedModel: string | null | undefined,
278+ resolutionOptions?: { readonly preserveUnavailableSelection?: boolean },
247279 ): string | null {
248280 const entry = deriveProviderInstanceEntries(providers).find(
249281 (candidate) => candidate.instanceId === instanceId,
250282 );
251283 if (!entry) return null;
252− const options = getAppModelOptionsForInstance(settings, entry);
284+ const options = getAppModelOptionsForInstance(
285+ settings,
286+ entry,
287+ resolutionOptions?.preserveUnavailableSelection ? selectedModel : null,
288+ );
289+ const resolvedSelection = resolveSelectableModel(entry.driverKind, selectedModel, options);
290+ if (resolvedSelection) {
291+ return resolvedSelection;
292+ }
293+ if (resolutionOptions?.preserveUnavailableSelection && entry.driverKind === "opencode") {
294+ const unavailableSelection = normalizeCustomModelSlug(selectedModel);
295+ const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels;
296+ if (unavailableSelection && !hiddenModels.includes(unavailableSelection)) {
297+ return unavailableSelection;
298+ }
299+ }
253300 return (
254− resolveSelectableModel(entry.driverKind, selectedModel, options) ??
255301 options.find((option) => option.isDefault)?.slug ??
256302 options[0]?.slug ??
257303 entry.models.find((model) => model.isDefault)?.slug ??
@@ -268,12 +314,19 @@ export function resolveAppModelSelectionForInstance( · 10 unmodified lines
268314 export function getCustomModelOptionsByInstance(
269315 settings: UnifiedSettings,
270316 providers: ReadonlyArray<ServerProvider>,
271− _selectedInstanceId?: ProviderInstanceId | null,
272− _selectedModel?: string | null,
317+ selectedInstanceId?: ProviderInstanceId | null,
318+ selectedModel?: string | null,
273319 ): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> {
274320 const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>();
275321 for (const entry of deriveProviderInstanceEntries(providers)) {
276− out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
322+ out.set(
323+ entry.instanceId,
324+ getAppModelOptionsForInstance(
325+ settings,
326+ entry,
327+ entry.instanceId === selectedInstanceId ? selectedModel : null,
328+ ),
329+ );
277330 }
278331 return out;
279332 }
docs/README.md+1−1
@@ -13,7 +13,7 @@ · 12 unmodified lines
1313 - [Keeping app and server in sync](./user/updating.md)
1414 - [Source control integrations](./user/source-control.md)
1515 - [Background service (Linux)](./user/background-service.md)
16−- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md)
16+- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md)
1717
1818 Mobile app: [apps/mobile/README.md](../apps/mobile/README.md)
1919
docs/internals/providers.md+45−0
@@ -39,6 +39,50 @@ directory to route session and turn operations for a thread, so callers name a t · 38 unmodified lines
3939 Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No
4040 orchestration, contract, or client change is required for the common case.
4141
42+## OpenCode server ownership and catalog
43+
44+Each OpenCode provider instance owns one lazy local server for catalog discovery and
45+text-generation helpers through [`OpenCodeServerOwner.ts`][opencode-server-owner]. Concurrent
46+borrowers share startup. The server closes 30 seconds after the last borrower releases it, or
47+when the provider instance closes. A failed or exited process can be started again on the next
48+use. An externally configured OpenCode server remains externally owned.
49+
50+The local server and its SDK clients use one resolved password. An explicit provider password
51+overrides `OPENCODE_SERVER_PASSWORD` in the spawned environment. Without an explicit password,
52+the client uses the password from the environment that the process inherits. External servers use
53+only their explicit provider password and never inherit the host's local password.
54+
55+Every server connection must pass the authenticated `/global/health` check before inventory or
56+session operations start. The response must contain a valid version at or above 1.14.19. Local
57+owners cache this result for the lifetime of the spawned process. External actions check once when
58+they create their server connection, not for each model or SDK request.
59+
60+Chat adapters keep their own server per thread. They register a thread-specific `t3-code` MCP
61+connection, while OpenCode stores MCP connections by directory. Sharing these chat servers
62+without changing MCP routing would let two threads in one directory replace each other's
63+connection.
64+
65+OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The
66+provider registry keeps the snapshot in memory and persists it in the existing per-instance cache.
67+Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the
68+OpenCode catalog from the current helper. The `serverRefreshProviders` request also refreshes it.
69+Periodic OpenCode probes remain disabled. OpenCode reads credentials for each inventory request,
70+but its native configuration files can remain cached for the lifetime of the helper process. The
71+helper closes 30 seconds after its last inventory or text-generation borrower releases it. A
72+refresh after that idle period starts a new helper and reads file changes. Repeated refreshes and
73+active text-generation work can extend process reuse. Changes to the provider configuration or
74+environment replace the instance and start a new discovery. Changes to unrelated settings only
75+update snapshot enrichment. Other providers retain their existing refresh policy.
76+
77+T3 Code does not own an external OpenCode process. Native configuration changes there can require
78+an external reload or restart before T3 Code's next refresh sees them.
79+
80+The shared server's idle shutdown does not clear the catalog. Failed discovery keeps the last
81+known models, slash commands, and skills through the registry's existing merge rules. A successful
82+empty inventory is authoritative. Existing threads keep their explicit model identifier and
83+options when catalog metadata is missing; the catalog is not permission to choose a different
84+model for a thread.
85+
4286 ## Model manifest
4387
4488 The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
@@ -121,6 +165,7 @@ when a request opens (approval) or user input is requested, via · 76 unmodified lines
121165 [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
122166 [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
123167 [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
168+[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts
124169 [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
125170 [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
126171 [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts
docs/user/providers-opencode.md+53−0
@@ -0,0 +1,53 @@
1+# OpenCode
2+
3+T3 Code uses the OpenCode setup on the connected environment. With a remote environment, its
4+OpenCode login and configuration apply, not the setup on your desktop or phone.
5+
6+T3 Code requires OpenCode 1.14.19 or newer. It checks the server version before it loads models or
7+starts work. If the check fails, update OpenCode or fix the server URL and password, then refresh
8+the provider status. Reconnecting the client also runs the check again.
9+
10+## Server authentication
11+
12+Without a server URL, T3 Code starts a local OpenCode server. The process inherits
13+`OPENCODE_SERVER_PASSWORD` from the environment. A password in the provider settings overrides
14+that environment value for both the local process and T3 Code.
15+
16+With a server URL, T3 Code connects to that external server and uses only the password in the
17+provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
18+OpenCode uses this password for HTTP Basic authentication.
19+
20+## Refresh the model list
21+
22+T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its
23+cache. Reconnecting a client or using a refresh control asks OpenCode for the list again. The
24+periodic provider health setting does not refresh OpenCode's catalog.
25+
26+After changing an OpenCode login or configuration outside T3 Code, open **Settings > Providers**,
27+select the environment, and choose **Refresh provider status**. Changing the provider's
28+configuration in T3 Code also replaces that provider connection.
29+
30+On mobile, open the thread settings and select **Refresh models**. The control stays disabled while
31+the refresh runs and shows an error if the refresh fails.
32+
33+OpenCode reads credential changes on each model-list request. Native OpenCode configuration files
34+can stay cached while the local helper is running. The helper closes after 30 seconds with no
35+model-list or text-generation work. Refresh after that idle period to start a new helper and read
36+the file changes. Repeated refreshes or active helper work can extend this wait.
37+
38+T3 Code does not own an external OpenCode server. Native configuration changes on that server can
39+require its own reload or restart before a refresh returns the new list.
40+
41+If a refresh fails, T3 Code keeps the last known models, slash commands, and skills. Fix the
42+connection, then refresh again. A successful refresh can remove entries that OpenCode no longer
43+offers.
44+
45+## Continue an existing thread
46+
47+An existing thread keeps its selected model and options when that model is temporarily absent
48+from the catalog. The web picker shows an **Unavailable** row and keeps saved option values visible
49+until the model metadata returns. T3 Code does not switch the thread to the first model in the
50+list.
51+
52+The stored selection does not guarantee that OpenCode can still run the model. If the provider
53+rejects it, select an available model before trying again.