authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 15:17:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 15:17:59-07:00
logcb49e5d7201ef6bcb288bf447785bbd374e8644d
tree9602dacfbf8c8bf1688f49cdf5c2791ccca024dd
parent0009aacdf146e0532327fa3d9d0109d5adca68b9
signature Signed by PGP key B5690EEEBB952194

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


42 files changed, 7983 insertions(+), 675 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+1
......@@ -621,6 +621,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
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 @@
11import type {
2 EnvironmentId,
23 ModelSelection,
34 ProviderOptionDescriptor,
45 ProviderOptionSelection,
......@@ -27,7 +28,7 @@ import {
2728 useState,
2829 type ReactNode,
2930} from "react";
30import { Platform, Pressable, ScrollView, TextInput, View } from "react-native";
31import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native";
3132import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
3233import { useSafeAreaInsets } from "react-native-safe-area-context";
3334
......@@ -47,7 +48,13 @@ import {
4748 nativeHeaderScrollEdgeEffects,
4849} from "../../native/StackHeader";
4950import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
51import { serverEnvironment } from "../../state/server";
52import { useAtomCommand } from "../../state/use-atom-command";
5053import { useNewTaskFlow } from "./new-task-flow-provider";
54import {
55 createProviderCatalogRefreshRunner,
56 providerCatalogRefreshError,
57} from "./provider-catalog-refresh";
5158import {
5259 createNativeMailSearchToolbarItem,
5360 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
......@@ -293,6 +300,7 @@ type ThreadSettingsSubmenuPage =
293300 | { readonly kind: "runtime" };
294301
295302type 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() {
344352}
345353
346354type 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(
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(
490500 hasLegacyModels,
491501 isApplied,
492502 isDisplayed,
503 props.environmentId,
493504 pendingModel,
494505 pressModel,
495506 providerFilter,
......@@ -958,6 +969,23 @@ function ThreadSettingsModelsScreen() {
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() {
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() {
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() {
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 created+46
......@@ -0,0 +1,46 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import {
7 createProviderCatalogRefreshRunner,
8 providerCatalogRefreshError,
9} from "./provider-catalog-refresh";
10
11describe("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 created+34
......@@ -0,0 +1,34 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7
8type RefreshProvidersTarget = {
9 readonly environmentId: EnvironmentId;
10 readonly input: Record<never, never>;
11};
12
13/** Deduplicates taps while the server refresh command is still running. */
14export 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
28export 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 {
3434import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
3535import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3636import { OpenCodeRuntime } from "../opencodeRuntime.ts";
37import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
3738import {
3839 defaultProviderContinuationIdentity,
3940 type ProviderDriver,
......@@ -141,13 +142,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
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>
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 @@
11import * as NodeAssert from "node:assert/strict";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { it } from "@effect/vitest";
4import * as Cause from "effect/Cause";
45import * as Context from "effect/Context";
56import * as Effect from "effect/Effect";
67import * as Exit from "effect/Exit";
......@@ -14,8 +15,10 @@ import * as Scope from "effect/Scope";
1415import * as Stream from "effect/Stream";
1516import * as TestClock from "effect/testing/TestClock";
1617import { beforeEach } from "vite-plus/test";
18import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
1719
1820import {
21 ApprovalRequestId,
1922 OpenCodeSettings,
2023 ProviderDriverKind,
2124 ProviderInstanceId,
......@@ -59,19 +62,48 @@ const runtimeMock = {
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 = {
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
100156const 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 = {
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 = {
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 = {
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 = {
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 = {
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 = {
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(() => {
280450const advanceTestClock = (ms: number) =>
281451 TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow));
282452
453function 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
463const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({
464 id,
465 sessionID,
466 permission: "bash",
467 patterns: ["pwd"],
468 metadata: {},
469 always: [],
470});
471
472const 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
283484it.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) => {
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) => {
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) => {
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 {
1515import * as Cause from "effect/Cause";
1616import * as Crypto from "effect/Crypto";
1717import * as DateTime from "effect/DateTime";
18import * as Deferred from "effect/Deferred";
1819import * as Effect from "effect/Effect";
1920import * as Exit from "effect/Exit";
2021import * as FileSystem from "effect/FileSystem";
22import * as Fiber from "effect/Fiber";
2123import * as Path from "effect/Path";
2224import * as Queue from "effect/Queue";
2325import * as Ref from "effect/Ref";
26import * as Schema from "effect/Schema";
2427import * as Scope from "effect/Scope";
28import * as Semaphore from "effect/Semaphore";
2529import * as Stream from "effect/Stream";
2630import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
2731import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
......@@ -175,6 +179,77 @@ type OpenCodeSubscribedEvent =
175179 ? TEvent
176180 : never;
177181
182type OpenCodeSessionStatusEvent = Extract<
183 OpenCodeSubscribedEvent,
184 { readonly type: "session.status" }
185>;
186
187const OpenCodeSessionStatusMap = Schema.Record(
188 Schema.String,
189 Schema.Struct({ type: Schema.String }),
190);
191const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap);
192
193interface OpenCodeCancellation {
194 readonly turnId: TurnId | undefined;
195 readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>;
196 acknowledged?: boolean;
197 deferredIdleEvent?: OpenCodeSessionStatusEvent;
198}
199
200interface 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
209interface 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
229type OpenCodeTerminalRequestEvent = Extract<
230 OpenCodeSubscribedEvent,
231 {
232 readonly type: "permission.replied" | "question.replied" | "question.rejected";
233 }
234>;
235
236type OpenCodeAskedRequestEvent = Extract<
237 OpenCodeSubscribedEvent,
238 { readonly type: "permission.asked" | "question.asked" }
239>;
240
241type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent;
242
243interface OpenCodeRequestRelationRetry {
244 warned: boolean;
245 fiber?: Fiber.Fiber<void, never>;
246}
247
248interface OpenCodePendingRequestRecovery {
249 warned: boolean;
250 rerun: boolean;
251}
252
178253function 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
214289 return title;
215290}
216291
292function isOpenCodeAbortError(error: unknown): boolean {
293 return (
294 typeof error === "object" &&
295 error !== null &&
296 "name" in error &&
297 error.name === "MessageAbortedError"
298 );
299}
300
301function 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
217314const 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 {
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 {
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(
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
655function 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
682const 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
701const 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
706const 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
720const 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
558744const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
559745 context: OpenCodeSessionContext,
560746) {
......@@ -562,13 +748,26 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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";
1212import {
1313 OpenCodeRuntime,
1414 OpenCodeRuntimeError,
15 resolveOpenCodeServerPassword,
1516 type OpenCodeRuntimeShape,
1617} from "../opencodeRuntime.ts";
18import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
1719import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts";
1820import type { OpenCodeInventory } from "../opencodeRuntime.ts";
1921const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
......@@ -34,8 +36,14 @@ const runtimeMock = {
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 = {
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 = {
5767};
5868
5969const 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 = {
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 = {
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
132170 ...overrides,
133171 });
134172
173const 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
135193it.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) => {
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) => {
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) => {
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) => {
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
308401it.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
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 {
1919 type ServerProviderDraft,
2020} from "../providerSnapshot.ts";
2121import {
22 MINIMUM_OPENCODE_VERSION,
2223 OpenCodeRuntime,
2324 openCodeRuntimeErrorDetail,
2425 type OpenCodeInventory,
2526} from "../opencodeRuntime.ts";
2627import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
28import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
2729
2830const OPENCODE_PRESENTATION = {
2931 displayName: "OpenCode",
3032 showInteractionModeToggle: false,
3133} as const;
32const MINIMUM_OPENCODE_VERSION = "1.14.19";
3334
3435class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
3536 readonly cause: unknown;
......@@ -65,6 +66,7 @@ function normalizedErrorMessage(cause: unknown): string | undefined {
6566function 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: {
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
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
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
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
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
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
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
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
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
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
......@@ -95,6 +95,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean =>
9595 return isPendingInitialProbe || didInstalledProviderProbeFail;
9696};
9797
98const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>
99 provider.driver === ProviderDriverKind.make("opencode") &&
100 shouldRetainMissingProviderModels(provider);
101
98102const mergeProviderModels = (
99103 provider: ServerProvider,
100104 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
......@@ -132,6 +136,16 @@ export const mergeProviderSnapshot = (
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
137151export const mergeProviderSnapshots = (
apps/server/src/provider/Layers/ProviderService.test.ts+63
......@@ -24,6 +24,8 @@ import {
2424import { createModelSelection } from "@t3tools/shared/model";
2525import { it, assert, describe, vi } from "@effect/vitest";
2626
27import * as Cause from "effect/Cause";
28import * as Deferred from "effect/Deferred";
2729import * as Effect from "effect/Effect";
2830import * as Exit from "effect/Exit";
2931import * as Fiber from "effect/Fiber";
......@@ -1531,6 +1533,67 @@ routing.layer("ProviderServiceLive routing", (it) => {
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 created+285
......@@ -0,0 +1,285 @@
1import { it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
8import { expect } from "vite-plus/test";
9
10import {
11 OpenCodeRuntime,
12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,
14} from "./opencodeRuntime.ts";
15import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
16
17const unusedRuntimeMethod = () =>
18 Effect.fail(
19 new OpenCodeRuntimeError({
20 operation: "unused",
21 detail: "unused test method",
22 }),
23 );
24
25const 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
64it.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
92it.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
115it.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
163it.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
210it.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
262it.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 created+184
......@@ -0,0 +1,184 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Scope from "effect/Scope";
7import * as Semaphore from "effect/Semaphore";
8
9import * as OpenCodeRuntime from "./opencodeRuntime.ts";
10
11export const OPENCODE_SERVER_IDLE_TTL = "30 seconds";
12
13interface OpenCodeServerOwnerState {
14 server: OpenCodeRuntime.OpenCodeServerProcess | null;
15 serverScope: Scope.Closeable | null;
16 borrowers: number;
17 idleCloseFiber: Fiber.Fiber<void, never> | null;
18}
19
20export 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. */
30export 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
179export 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
......@@ -250,6 +250,40 @@ describe("makeManagedServerProvider", () => {
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", () => {
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")(
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")(
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")(
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 @@
1import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2import { it as effectIt } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as TestClock from "effect/testing/TestClock";
16import { describe, expect, it } from "vite-plus/test";
27
3import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts";
8import {
9 OpenCodeRuntimeError,
10 resolveOpenCodeConfigContent,
11 resolveOpenCodeServerPassword,
12 verifyOpenCodeServerVersion,
13} from "./opencodeRuntime.ts";
414
515describe("resolveOpenCodeConfigContent", () => {
616 it("prefers the caller environment over the inherited environment", () => {
......@@ -21,3 +31,122 @@ describe("resolveOpenCodeConfigContent", () => {
2131 expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}");
2232 });
2333});
34
35describe("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
74function 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
84describe("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
......@@ -18,6 +18,34 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";
1818const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
1919
2020it.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
......@@ -39,6 +39,7 @@ describe("buildOpenCodePermissionRules", () => {
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";
3333import { collectStreamAsString } from "./providerSnapshot.ts";
3434import * as NetService from "@t3tools/shared/Net";
3535import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
36import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
3637import { resolveSpawnCommand } from "@t3tools/shared/shell";
3738const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
3940
41export const MINIMUM_OPENCODE_VERSION = "1.14.19";
42const OPENCODE_HEALTH_TIMEOUT = "5 seconds";
43
44const OpenCodeHealthSchema = Schema.Struct({
45 healthy: Schema.Literal(true),
46 version: Schema.String,
47});
48const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema);
49
4050export 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(
4858 );
4959}
5060
61export 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
5180const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
5281const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
5382const DEFAULT_HOSTNAME = "127.0.0.1";
5483const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
5584export 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
6092export 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 {
96130
97131export 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>(
104138 new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }),
105139 }).pipe(Effect.withSpan(`opencode.${operation}`));
106140
141export 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
107179export interface OpenCodeCommandResult {
108180 readonly stdout: string;
109181 readonly stderr: string;
......@@ -145,6 +217,8 @@ export interface OpenCodeRuntimeShape {
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 {
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: {
398474
399475export 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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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
......@@ -57,6 +57,7 @@ import * as Option from "effect/Option";
5757import * as Path from "effect/Path";
5858import * as PubSub from "effect/PubSub";
5959import * as Queue from "effect/Queue";
60import * as Ref from "effect/Ref";
6061import * as Schema from "effect/Schema";
6162import * as Stream from "effect/Stream";
6263import * as TestClock from "effect/testing/TestClock";
......@@ -4918,6 +4919,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
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";
1111
1212import * as ServerConfig from "../config.ts";
1313import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
14import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
1415import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts";
1516import * as TextGeneration from "./TextGeneration.ts";
1617
......@@ -21,6 +22,8 @@ const runtimeMock = {
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 = {
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 = {
4247};
4348
4449const 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 = {
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(
163189const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
164190 binaryPath: "fake-opencode",
165191});
192const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
193 binaryPath: "fake-opencode",
194 serverPassword: "secret-password",
195});
166196const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
167197 binaryPath: "fake-opencode",
168198 serverUrl: "http://127.0.0.1:9999",
169199 serverPassword: "secret-password",
170200});
201const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
202 binaryPath: "fake-opencode",
203 serverUrl: "http://127.0.0.1:9999",
204});
171205
172206function 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) => {
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) => {
460546it.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 @@
11import * as Effect from "effect/Effect";
2import * as Exit from "effect/Exit";
3import * as Fiber from "effect/Fiber";
42import * as Schema from "effect/Schema";
5import * as Scope from "effect/Scope";
6import * as Semaphore from "effect/Semaphore";
73
84import {
95 NonNegativeInt,
......@@ -31,8 +27,7 @@ import {
3127 sanitizeThreadTitle,
3228} from "./TextGenerationUtils.ts";
3329import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
34
35const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
30import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
3631
3732const OpenCodeTextGenerationOperation = Schema.Literals([
3833 "generateCommitMessage",
......@@ -175,188 +170,12 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str
175170 .trim();
176171}
177172
178interface 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
192173export 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"
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"
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)
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
......@@ -9,6 +9,7 @@ import {
99} from "./providerIconUtils";
1010import { ComboboxItem } from "../ui/combobox";
1111import { Button } from "../ui/button";
12import { Badge } from "../ui/badge";
1213import { Kbd } from "../ui/kbd";
1314import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1415import { cn } from "~/lib/utils";
......@@ -34,6 +35,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
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: {
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 created+67
......@@ -0,0 +1,67 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { deriveProviderInstanceEntries } from "../../providerInstances";
5import { shouldIncludeModelPickerOption } from "./ModelPickerContent";
6
7function 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
25describe("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 = {
5353 instanceAccentColor?: string | undefined;
5454 continuationGroupKey?: string | undefined;
5555 isLegacy?: boolean | undefined;
56 isUnavailable?: boolean | undefined;
5657};
5758
59export 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
5875const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
5976
6077function ModelListSeparator() {
......@@ -107,9 +124,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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 created+102
......@@ -0,0 +1,102 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { deriveProviderInstanceEntries } from "../../providerInstances";
6import { ProviderModelPicker } from "./ProviderModelPicker";
7import type { ModelEsque } from "./providerIconUtils";
8
9function 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
26function 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
47describe("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 {
55} from "@t3tools/contracts";
66import { memo, useEffect, useMemo, useState } from "react";
77import type { VariantProps } from "class-variance-authority";
8import { Badge } from "../ui/badge";
89import { buttonVariants } from "../ui/button";
910import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1011import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -58,15 +59,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
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: {
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 @@
11import { describe, expect, it } from "vite-plus/test";
22import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts";
3import { buildTraitsTriggerDisplay } from "./TraitsPicker";
3import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker";
44
55function selectDescriptor(
66 id: string,
......@@ -155,3 +155,36 @@ describe("buildTraitsTriggerDisplay", () => {
155155 ).toEqual({ label: "Ultrathink", showFastModeIcon: true });
156156 });
157157});
158
159describe("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 {
1313 getProviderOptionCurrentValue,
1414 getProviderOptionDescriptors,
1515 isClaudeUltrathinkPrompt,
16 normalizeModelSlug,
1617} from "@t3tools/shared/model";
1718import { memo, useCallback, useState } from "react";
1819import type { VariantProps } from "class-variance-authority";
......@@ -35,6 +36,42 @@ import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./
3536
3637type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
3738
39const SAVED_OPTION_LABELS: Readonly<Record<string, string>> = {
40 agent: "Agent",
41 effort: "Effort",
42 reasoningEffort: "Reasoning effort",
43 variant: "Variant",
44};
45
46function 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. */
54export 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
3875type TraitsPersistence =
3976 | {
4077 threadRef?: ScopedThreadRef;
......@@ -99,10 +136,19 @@ function getSelectedTraits(
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(
158204 ultrathinkInBodyText,
159205 selectedAgent,
160206 selectedAgentLabel,
207 modelIsUnavailable,
161208 };
162209}
163210
......@@ -193,7 +240,13 @@ function getTraitsSectionVisibility(input: {
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({
262315 ultrathinkPromptControlled,
263316 ultrathinkInBodyText,
264317 hasAnyControls,
318 modelIsUnavailable,
265319 } = getTraitsSectionVisibility({
266320 provider,
267321 models,
......@@ -300,6 +354,28 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
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
......@@ -238,6 +238,94 @@ describe("getComposerProviderState", () => {
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
......@@ -10,6 +10,7 @@ import {
1010 getProviderOptionCurrentValue,
1111 getProviderOptionDescriptors,
1212 isClaudeUltrathinkPrompt,
13 normalizeModelSlug,
1314} from "@t3tools/shared/model";
1415import type { ReactNode } from "react";
1516
......@@ -63,6 +64,21 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
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
......@@ -27,6 +27,7 @@ export type ModelEsque = {
2727 shortName?: string | undefined;
2828 subProvider?: string | undefined;
2929 isLegacy?: boolean | undefined;
30 isUnavailable?: boolean | undefined;
3031};
3132
3233function escapeRegExp(value: string): string {
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6-5
......@@ -417,6 +417,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
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 }) {
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
......@@ -1025,6 +1025,10 @@ export function deriveEffectiveComposerModelState(input: {
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: {
10321036 input.settings,
10331037 input.providers,
10341038 baseModelCandidate,
1039 { preserveUnavailableSelection: preserveThreadModel },
10351040 )
10361041 : null) ??
10371042 resolveAppModelSelection(
......@@ -1061,6 +1066,7 @@ export function deriveEffectiveComposerModelState(input: {
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
......@@ -2,8 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
22import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
33import { describe, expect, it } from "vite-plus/test";
44import { createModelSelection } from "@t3tools/shared/model";
5import { deriveEffectiveComposerModelState } from "./composerDraftStore";
6import { getComposerProviderState } from "./components/chat/composerProviderState";
57import { deriveProviderInstanceEntries } from "./providerInstances";
68import {
9 getCustomModelOptionsByInstance,
710 getAppModelOptionsForInstance,
811 resolveAppModelSelectionForInstance,
912 resolveAppModelSelectionState,
......@@ -275,6 +278,15 @@ describe("instance-scoped model selection", () => {
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", () => {
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 {
7878 isCustom: boolean;
7979 isDefault?: boolean;
8080 isLegacy?: boolean;
81 isUnavailable?: boolean;
82}
83
84function 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
83104function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption {
......@@ -151,9 +172,10 @@ export function getAppModelOptions(
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(
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(
200226export 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(
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(
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(
268314export 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 @@
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
1818Mobile app: [apps/mobile/README.md](../apps/mobile/README.md)
1919
docs/internals/providers.md+45
......@@ -39,6 +39,50 @@ directory to route session and turn operations for a thread, so callers name a t
3939Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No
4040orchestration, contract, or client change is required for the common case.
4141
42## OpenCode server ownership and catalog
43
44Each OpenCode provider instance owns one lazy local server for catalog discovery and
45text-generation helpers through [`OpenCodeServerOwner.ts`][opencode-server-owner]. Concurrent
46borrowers share startup. The server closes 30 seconds after the last borrower releases it, or
47when the provider instance closes. A failed or exited process can be started again on the next
48use. An externally configured OpenCode server remains externally owned.
49
50The local server and its SDK clients use one resolved password. An explicit provider password
51overrides `OPENCODE_SERVER_PASSWORD` in the spawned environment. Without an explicit password,
52the client uses the password from the environment that the process inherits. External servers use
53only their explicit provider password and never inherit the host's local password.
54
55Every server connection must pass the authenticated `/global/health` check before inventory or
56session operations start. The response must contain a valid version at or above 1.14.19. Local
57owners cache this result for the lifetime of the spawned process. External actions check once when
58they create their server connection, not for each model or SDK request.
59
60Chat adapters keep their own server per thread. They register a thread-specific `t3-code` MCP
61connection, while OpenCode stores MCP connections by directory. Sharing these chat servers
62without changing MCP routing would let two threads in one directory replace each other's
63connection.
64
65OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The
66provider registry keeps the snapshot in memory and persists it in the existing per-instance cache.
67Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the
68OpenCode catalog from the current helper. The `serverRefreshProviders` request also refreshes it.
69Periodic OpenCode probes remain disabled. OpenCode reads credentials for each inventory request,
70but its native configuration files can remain cached for the lifetime of the helper process. The
71helper closes 30 seconds after its last inventory or text-generation borrower releases it. A
72refresh after that idle period starts a new helper and reads file changes. Repeated refreshes and
73active text-generation work can extend process reuse. Changes to the provider configuration or
74environment replace the instance and start a new discovery. Changes to unrelated settings only
75update snapshot enrichment. Other providers retain their existing refresh policy.
76
77T3 Code does not own an external OpenCode process. Native configuration changes there can require
78an external reload or restart before T3 Code's next refresh sees them.
79
80The shared server's idle shutdown does not clear the catalog. Failed discovery keeps the last
81known models, slash commands, and skills through the registry's existing merge rules. A successful
82empty inventory is authoritative. Existing threads keep their explicit model identifier and
83options when catalog metadata is missing; the catalog is not permission to choose a different
84model for a thread.
85
4286## Model manifest
4387
4488The 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
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 created+53
......@@ -0,0 +1,53 @@
1# OpenCode
2
3T3 Code uses the OpenCode setup on the connected environment. With a remote environment, its
4OpenCode login and configuration apply, not the setup on your desktop or phone.
5
6T3 Code requires OpenCode 1.14.19 or newer. It checks the server version before it loads models or
7starts work. If the check fails, update OpenCode or fix the server URL and password, then refresh
8the provider status. Reconnecting the client also runs the check again.
9
10## Server authentication
11
12Without 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
14that environment value for both the local process and T3 Code.
15
16With a server URL, T3 Code connects to that external server and uses only the password in the
17provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
18OpenCode uses this password for HTTP Basic authentication.
19
20## Refresh the model list
21
22T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its
23cache. Reconnecting a client or using a refresh control asks OpenCode for the list again. The
24periodic provider health setting does not refresh OpenCode's catalog.
25
26After changing an OpenCode login or configuration outside T3 Code, open **Settings > Providers**,
27select the environment, and choose **Refresh provider status**. Changing the provider's
28configuration in T3 Code also replaces that provider connection.
29
30On mobile, open the thread settings and select **Refresh models**. The control stays disabled while
31the refresh runs and shows an error if the refresh fails.
32
33OpenCode reads credential changes on each model-list request. Native OpenCode configuration files
34can stay cached while the local helper is running. The helper closes after 30 seconds with no
35model-list or text-generation work. Refresh after that idle period to start a new helper and read
36the file changes. Repeated refreshes or active helper work can extend this wait.
37
38T3 Code does not own an external OpenCode server. Native configuration changes on that server can
39require its own reload or restart before a refresh returns the new list.
40
41If a refresh fails, T3 Code keeps the last known models, slash commands, and skills. Fix the
42connection, then refresh again. A successful refresh can remove entries that OpenCode no longer
43offers.
44
45## Continue an existing thread
46
47An existing thread keeps its selected model and options when that model is temporarily absent
48from the catalog. The web picker shows an **Unavailable** row and keeps saved option values visible
49until the model metadata returns. T3 Code does not switch the thread to the first model in the
50list.
51
52The stored selection does not guarantee that OpenCode can still run the model. If the provider
53rejects it, select an available model before trying again.