| author | |
| committer | |
| log | cb49e5d7201ef6bcb288bf447785bbd374e8644d |
| tree | 9602dacfbf8c8bf1688f49cdf5c2791ccca024dd |
| parent | 0009aacdf146e0532327fa3d9d0109d5adca68b9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 621 | 621 | const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>( |
| 622 | 622 | () => ({ |
| 623 | 623 | ownerId: settingsOwnerId, |
| 624 | environmentId: props.environmentId, | |
| 624 | 625 | providerGroups: threadProviderGroups, |
| 625 | 626 | selectedModel: currentModelSelection, |
| 626 | 627 | onSelectModel: (option) => props.onUpdateModelSelection(option.selection), |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+43-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { |
| 2 | EnvironmentId, | |
| 2 | 3 | ModelSelection, |
| 3 | 4 | ProviderOptionDescriptor, |
| 4 | 5 | ProviderOptionSelection, |
| ... | ... | @@ -27,7 +28,7 @@ import { |
| 27 | 28 | useState, |
| 28 | 29 | type ReactNode, |
| 29 | 30 | } from "react"; |
| 30 | import { Platform, Pressable, ScrollView, TextInput, View } from "react-native"; | |
| 31 | import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; | |
| 31 | 32 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 32 | 33 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 33 | 34 | |
| ... | ... | @@ -47,7 +48,13 @@ import { |
| 47 | 48 | nativeHeaderScrollEdgeEffects, |
| 48 | 49 | } from "../../native/StackHeader"; |
| 49 | 50 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 51 | import { serverEnvironment } from "../../state/server"; | |
| 52 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 50 | 53 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 54 | import { | |
| 55 | createProviderCatalogRefreshRunner, | |
| 56 | providerCatalogRefreshError, | |
| 57 | } from "./provider-catalog-refresh"; | |
| 51 | 58 | import { |
| 52 | 59 | createNativeMailSearchToolbarItem, |
| 53 | 60 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| ... | ... | @@ -293,6 +300,7 @@ type ThreadSettingsSubmenuPage = |
| 293 | 300 | | { readonly kind: "runtime" }; |
| 294 | 301 | |
| 295 | 302 | type ThreadSettingsSessionProps = { |
| 303 | readonly environmentId: EnvironmentId | null; | |
| 296 | 304 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 297 | 305 | readonly selectedModel: ModelSelection | null; |
| 298 | 306 | readonly onSelectModel: (option: ModelOption) => void; |
| ... | ... | @@ -344,6 +352,7 @@ export function useExistingThreadSettingsRoutePresentation() { |
| 344 | 352 | } |
| 345 | 353 | |
| 346 | 354 | type ThreadSettingsSessionValue = { |
| 355 | readonly environmentId: EnvironmentId | null; | |
| 347 | 356 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 348 | 357 | readonly runtimeMode: RuntimeMode; |
| 349 | 358 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; |
| ... | ... | @@ -462,6 +471,7 @@ function ThreadSettingsSessionProvider( |
| 462 | 471 | |
| 463 | 472 | const value = useMemo<ThreadSettingsSessionValue>( |
| 464 | 473 | () => ({ |
| 474 | environmentId: props.environmentId, | |
| 465 | 475 | providerGroups: props.providerGroups, |
| 466 | 476 | runtimeMode: props.runtimeMode, |
| 467 | 477 | onUpdateRuntimeMode: props.onUpdateRuntimeMode, |
| ... | ... | @@ -490,6 +500,7 @@ function ThreadSettingsSessionProvider( |
| 490 | 500 | hasLegacyModels, |
| 491 | 501 | isApplied, |
| 492 | 502 | isDisplayed, |
| 503 | props.environmentId, | |
| 493 | 504 | pendingModel, |
| 494 | 505 | pressModel, |
| 495 | 506 | providerFilter, |
| ... | ... | @@ -958,6 +969,23 @@ function ThreadSettingsModelsScreen() { |
| 958 | 969 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 959 | 970 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; |
| 960 | 971 | 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]); | |
| 961 | 989 | const commitAndClose = useCallback(() => { |
| 962 | 990 | session.commitPendingModel(); |
| 963 | 991 | presentation.onClose(); |
| ... | ... | @@ -1005,6 +1033,12 @@ function ThreadSettingsModelsScreen() { |
| 1005 | 1033 | {Platform.OS === "android" ? ( |
| 1006 | 1034 | <AndroidScreenHeader |
| 1007 | 1035 | actions={[ |
| 1036 | { | |
| 1037 | accessibilityLabel: "Refresh models", | |
| 1038 | disabled: isRefreshingProviders || session.environmentId === null, | |
| 1039 | icon: "arrow.clockwise", | |
| 1040 | onPress: refreshProviders, | |
| 1041 | }, | |
| 1008 | 1042 | { |
| 1009 | 1043 | accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done", |
| 1010 | 1044 | icon: "checkmark", |
| ... | ... | @@ -1070,6 +1104,13 @@ function ThreadSettingsModelsScreen() { |
| 1070 | 1104 | /> |
| 1071 | 1105 | </NativeHeaderToolbar> |
| 1072 | 1106 | <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 | /> | |
| 1073 | 1114 | <NativeHeaderToolbar.Button |
| 1074 | 1115 | accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"} |
| 1075 | 1116 | label={session.pendingModel ? "Save" : "Done"} |
| ... | ... | @@ -1230,6 +1271,7 @@ export function NewTaskThreadSettingsRouteScreen() { |
| 1230 | 1271 | |
| 1231 | 1272 | return ( |
| 1232 | 1273 | <ThreadSettingsSessionProvider |
| 1274 | environmentId={flow.selectedEnvironmentId} | |
| 1233 | 1275 | providerGroups={flow.providerGroups} |
| 1234 | 1276 | selectedModel={flow.selectedModel} |
| 1235 | 1277 | 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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { | |
| 7 | createProviderCatalogRefreshRunner, | |
| 8 | providerCatalogRefreshError, | |
| 9 | } from "./provider-catalog-refresh"; | |
| 10 | ||
| 11 | describe("mobile provider catalog refresh", () => { | |
| 12 | it("calls server discovery for the selected environment and deduplicates pending taps", async () => { | |
| 13 | let resolveRefresh: ((value: "refreshed") => void) | undefined; | |
| 14 | const refreshProviders = vi.fn( | |
| 15 | () => | |
| 16 | new Promise<"refreshed">((resolve) => { | |
| 17 | resolveRefresh = resolve; | |
| 18 | }), | |
| 19 | ); | |
| 20 | const refresh = createProviderCatalogRefreshRunner(refreshProviders); | |
| 21 | const environmentId = EnvironmentId.make("environment-mobile"); | |
| 22 | ||
| 23 | const first = refresh(environmentId); | |
| 24 | const second = refresh(environmentId); | |
| 25 | ||
| 26 | expect(second).toBe(first); | |
| 27 | expect(refreshProviders).toHaveBeenCalledOnce(); | |
| 28 | expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} }); | |
| 29 | ||
| 30 | resolveRefresh?.("refreshed"); | |
| 31 | await expect(first).resolves.toBe("refreshed"); | |
| 32 | }); | |
| 33 | ||
| 34 | it("reports a discovery error and allows retry after the failed command settles", async () => { | |
| 35 | const failure = AsyncResult.failure<never, Error>(Cause.fail(new Error("discovery failed"))); | |
| 36 | const success = AsyncResult.success("refreshed"); | |
| 37 | let callCount = 0; | |
| 38 | const refreshProviders = vi.fn(async () => (callCount++ === 0 ? failure : success)); | |
| 39 | const refresh = createProviderCatalogRefreshRunner(refreshProviders); | |
| 40 | const environmentId = EnvironmentId.make("environment-mobile"); | |
| 41 | ||
| 42 | expect(providerCatalogRefreshError(await refresh(environmentId))).toBe("discovery failed"); | |
| 43 | expect(providerCatalogRefreshError(await refresh(environmentId))).toBeNull(); | |
| 44 | expect(refreshProviders).toHaveBeenCalledTimes(2); | |
| 45 | }); | |
| 46 | }); |
apps/mobile/src/features/threads/provider-catalog-refresh.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | import { | |
| 4 | isAtomCommandInterrupted, | |
| 5 | squashAtomCommandFailure, | |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | |
| 7 | ||
| 8 | type RefreshProvidersTarget = { | |
| 9 | readonly environmentId: EnvironmentId; | |
| 10 | readonly input: Record<never, never>; | |
| 11 | }; | |
| 12 | ||
| 13 | /** Deduplicates taps while the server refresh command is still running. */ | |
| 14 | export function createProviderCatalogRefreshRunner<Result>( | |
| 15 | refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>, | |
| 16 | ) { | |
| 17 | let pending: Promise<Result> | null = null; | |
| 18 | ||
| 19 | return (environmentId: EnvironmentId): Promise<Result> => { | |
| 20 | if (pending) return pending; | |
| 21 | pending = refreshProviders({ environmentId, input: {} }).finally(() => { | |
| 22 | pending = null; | |
| 23 | }); | |
| 24 | return pending; | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | export function providerCatalogRefreshError( | |
| 29 | result: AtomCommandResult<unknown, unknown>, | |
| 30 | ): string | null { | |
| 31 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null; | |
| 32 | const error = squashAtomCommandFailure(result); | |
| 33 | return error instanceof Error ? error.message : "Provider discovery failed."; | |
| 34 | } |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+19-2| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 35 | 35 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 36 | 36 | import { OpenCodeRuntime } from "../opencodeRuntime.ts"; |
| 37 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | |
| 37 | 38 | import { |
| 38 | 39 | defaultProviderContinuationIdentity, |
| 39 | 40 | type ProviderDriver, |
| ... | ... | @@ -141,13 +142,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 141 | 142 | environment: processEnv, |
| 142 | 143 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), |
| 143 | 144 | }); |
| 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 | ); | |
| 145 | 156 | |
| 146 | 157 | const checkProvider = checkOpenCodeProviderStatus( |
| 147 | 158 | effectiveConfig, |
| 148 | 159 | serverConfig.cwd, |
| 149 | 160 | 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 | ); | |
| 151 | 166 | |
| 152 | 167 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 153 | 168 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( |
| ... | ... | @@ -156,6 +171,8 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 156 | 171 | getSettings: snapshotSettings.getSettings, |
| 157 | 172 | streamSettings: snapshotSettings.streamSettings, |
| 158 | 173 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 174 | checkProviderOnSettingsChange: () => false, | |
| 175 | refreshOnInterval: false, | |
| 159 | 176 | initialSnapshot: (settings) => |
| 160 | 177 | makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), |
| 161 | 178 | checkProvider, |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+3667-87| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeAssert from "node:assert/strict"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { it } from "@effect/vitest"; |
| 4 | import * as Cause from "effect/Cause"; | |
| 4 | 5 | import * as Context from "effect/Context"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Exit from "effect/Exit"; |
| ... | ... | @@ -14,8 +15,10 @@ import * as Scope from "effect/Scope"; |
| 14 | 15 | import * as Stream from "effect/Stream"; |
| 15 | 16 | import * as TestClock from "effect/testing/TestClock"; |
| 16 | 17 | import { beforeEach } from "vite-plus/test"; |
| 18 | import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; | |
| 17 | 19 | |
| 18 | 20 | import { |
| 21 | ApprovalRequestId, | |
| 19 | 22 | OpenCodeSettings, |
| 20 | 23 | ProviderDriverKind, |
| 21 | 24 | ProviderInstanceId, |
| ... | ... | @@ -59,19 +62,48 @@ const runtimeMock = { |
| 59 | 62 | startCalls: [] as string[], |
| 60 | 63 | sessionCreateUrls: [] as string[], |
| 61 | 64 | sessionCreateInputs: [] as Array<Record<string, unknown>>, |
| 65 | createdSessionIds: [] as string[], | |
| 62 | 66 | authHeaders: [] as Array<string | null>, |
| 63 | 67 | abortCalls: [] as string[], |
| 68 | abortSignals: [] as AbortSignal[], | |
| 69 | abortImplementation: null as | |
| 70 | | ((sessionID: string, signal?: AbortSignal) => Promise<void>) | |
| 71 | | null, | |
| 64 | 72 | closeCalls: [] as string[], |
| 65 | 73 | revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, |
| 74 | messageCalls: [] as Array<{ sessionID: string; messageID: string }>, | |
| 75 | messageFailures: 0, | |
| 66 | 76 | promptCalls: [] as Array<unknown>, |
| 67 | 77 | promptAsyncError: null as Error | null, |
| 78 | promptAsyncImplementation: null as (() => Promise<void>) | null, | |
| 79 | autoPromptEcho: true, | |
| 80 | autoConnect: true, | |
| 81 | promptEchoEvents: [] as Array<unknown>, | |
| 68 | 82 | closeError: null as Error | null, |
| 69 | 83 | 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, | |
| 71 | 95 | sessionGetIds: [] as string[], |
| 96 | sessionGetObserved: null as ((sessionID: string) => void) | null, | |
| 72 | 97 | missingSessionIds: new Set<string>(), |
| 73 | 98 | transientErrorSessionIds: new Set<string>(), |
| 74 | 99 | 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, | |
| 75 | 107 | sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, |
| 76 | 108 | forkCalls: [] as Array<{ sessionID: string; directory?: string }>, |
| 77 | 109 | }, |
| ... | ... | @@ -79,26 +111,50 @@ const runtimeMock = { |
| 79 | 111 | this.state.startCalls.length = 0; |
| 80 | 112 | this.state.sessionCreateUrls.length = 0; |
| 81 | 113 | this.state.sessionCreateInputs.length = 0; |
| 114 | this.state.createdSessionIds.length = 0; | |
| 82 | 115 | this.state.authHeaders.length = 0; |
| 83 | 116 | this.state.abortCalls.length = 0; |
| 117 | this.state.abortSignals.length = 0; | |
| 118 | this.state.abortImplementation = null; | |
| 84 | 119 | this.state.closeCalls.length = 0; |
| 85 | 120 | this.state.revertCalls.length = 0; |
| 121 | this.state.messageCalls.length = 0; | |
| 122 | this.state.messageFailures = 0; | |
| 86 | 123 | this.state.promptCalls.length = 0; |
| 87 | 124 | 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; | |
| 88 | 129 | this.state.closeError = null; |
| 89 | 130 | this.state.messages = []; |
| 90 | 131 | 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; | |
| 91 | 139 | this.state.sessionGetIds.length = 0; |
| 140 | this.state.sessionGetObserved = null; | |
| 92 | 141 | this.state.missingSessionIds.clear(); |
| 93 | 142 | this.state.transientErrorSessionIds.clear(); |
| 94 | 143 | 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; | |
| 95 | 151 | this.state.sessionUpdateCalls.length = 0; |
| 96 | 152 | this.state.forkCalls.length = 0; |
| 97 | 153 | }, |
| 98 | 154 | }; |
| 99 | 155 | |
| 100 | 156 | const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 101 | startOpenCodeServerProcess: ({ binaryPath }) => | |
| 157 | startOpenCodeServerProcess: ({ binaryPath, serverPassword }) => | |
| 102 | 158 | Effect.gen(function* () { |
| 103 | 159 | runtimeMock.state.startCalls.push(binaryPath); |
| 104 | 160 | const url = "http://127.0.0.1:4301"; |
| ... | ... | @@ -112,10 +168,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 112 | 168 | ); |
| 113 | 169 | return { |
| 114 | 170 | url, |
| 171 | version: "1.15.13", | |
| 172 | ...(serverPassword ? { serverPassword } : {}), | |
| 115 | 173 | exitCode: Effect.never, |
| 174 | isRunning: Effect.succeed(true), | |
| 116 | 175 | }; |
| 117 | 176 | }), |
| 118 | connectToOpenCodeServer: ({ serverUrl }) => | |
| 177 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | |
| 119 | 178 | Effect.gen(function* () { |
| 120 | 179 | const url = serverUrl ?? "http://127.0.0.1:4301"; |
| 121 | 180 | // Always register a finalizer so the closeCalls/closeError probes fire; |
| ... | ... | @@ -130,6 +189,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 130 | 189 | ); |
| 131 | 190 | return { |
| 132 | 191 | url, |
| 192 | version: "1.15.13", | |
| 193 | ...(serverPassword ? { serverPassword } : {}), | |
| 133 | 194 | exitCode: null, |
| 134 | 195 | external: Boolean(serverUrl), |
| 135 | 196 | }; |
| ... | ... | @@ -144,10 +205,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 144 | 205 | runtimeMock.state.authHeaders.push( |
| 145 | 206 | serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, |
| 146 | 207 | ); |
| 147 | return { data: { id: `${baseUrl}/session` } }; | |
| 208 | return { | |
| 209 | data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` }, | |
| 210 | }; | |
| 148 | 211 | }, |
| 149 | 212 | get: async ({ sessionID }: { sessionID: string }) => { |
| 150 | 213 | runtimeMock.state.sessionGetIds.push(sessionID); |
| 214 | runtimeMock.state.sessionGetObserved?.(sessionID); | |
| 151 | 215 | // The real client is `throwOnError: true`: non-2xx rejects rather |
| 152 | 216 | // than resolving, so missing → 404 throw, transient → 500 throw. |
| 153 | 217 | if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { |
| ... | ... | @@ -159,7 +223,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 159 | 223 | }); |
| 160 | 224 | } |
| 161 | 225 | 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 | }; | |
| 163 | 234 | }, |
| 164 | 235 | update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { |
| 165 | 236 | runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); |
| ... | ... | @@ -174,16 +245,73 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 174 | 245 | } |
| 175 | 246 | return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; |
| 176 | 247 | }, |
| 177 | abort: async ({ sessionID }: { sessionID: string }) => { | |
| 248 | abort: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { | |
| 178 | 249 | 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 | }; | |
| 179 | 270 | }, |
| 180 | 271 | promptAsync: async (input: unknown) => { |
| 181 | 272 | runtimeMock.state.promptCalls.push(input); |
| 273 | await runtimeMock.state.promptAsyncImplementation?.(); | |
| 182 | 274 | if (runtimeMock.state.promptAsyncError) { |
| 183 | 275 | throw runtimeMock.state.promptAsyncError; |
| 184 | 276 | } |
| 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 | } | |
| 185 | 299 | }, |
| 186 | 300 | 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 | }, | |
| 187 | 315 | revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { |
| 188 | 316 | runtimeMock.state.revertCalls.push({ |
| 189 | 317 | sessionID, |
| ... | ... | @@ -204,13 +332,55 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 204 | 332 | }, |
| 205 | 333 | }, |
| 206 | 334 | 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 | }, | |
| 214 | 384 | }, |
| 215 | 385 | }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, |
| 216 | 386 | loadOpenCodeInventory: () => |
| ... | ... | @@ -280,6 +450,37 @@ beforeEach(() => { |
| 280 | 450 | const advanceTestClock = (ms: number) => |
| 281 | 451 | TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); |
| 282 | 452 | |
| 453 | function promiseWithResolvers<T>() { | |
| 454 | let resolve!: (value: T | PromiseLike<T>) => void; | |
| 455 | let reject!: (reason?: unknown) => void; | |
| 456 | const promise = new Promise<T>((resolvePromise, rejectPromise) => { | |
| 457 | resolve = resolvePromise; | |
| 458 | reject = rejectPromise; | |
| 459 | }); | |
| 460 | return { promise, resolve, reject }; | |
| 461 | } | |
| 462 | ||
| 463 | const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({ | |
| 464 | id, | |
| 465 | sessionID, | |
| 466 | permission: "bash", | |
| 467 | patterns: ["pwd"], | |
| 468 | metadata: {}, | |
| 469 | always: [], | |
| 470 | }); | |
| 471 | ||
| 472 | const questionRequest = (id: string, sessionID: string): QuestionRequest => ({ | |
| 473 | id, | |
| 474 | sessionID, | |
| 475 | questions: [ | |
| 476 | { | |
| 477 | header: "Scope", | |
| 478 | question: "Which scope should OpenCode use?", | |
| 479 | options: [{ label: "Workspace", description: "Use this workspace." }], | |
| 480 | }, | |
| 481 | ], | |
| 482 | }); | |
| 483 | ||
| 283 | 484 | it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 284 | 485 | it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => |
| 285 | 486 | Effect.gen(function* () { |
| ... | ... | @@ -301,6 +502,348 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 301 | 502 | }), |
| 302 | 503 | ); |
| 303 | 504 | |
| 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 | ||
| 304 | 847 | it.effect("returns a durable resume cursor for a freshly created session", () => |
| 305 | 848 | Effect.gen(function* () { |
| 306 | 849 | const adapter = yield* OpenCodeAdapter; |
| ... | ... | @@ -809,95 +1352,3099 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 809 | 1352 | }), |
| 810 | 1353 | ); |
| 811 | 1354 | |
| 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* () { | |
| 826 | 1357 | 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 | ); | |
| 827 | 1388 | yield* adapter.startSession({ |
| 828 | 1389 | provider: ProviderDriverKind.make("opencode"), |
| 829 | threadId: asThreadId("thread-custom-instance"), | |
| 1390 | threadId, | |
| 830 | 1391 | runtimeMode: "full-access", |
| 831 | 1392 | }); |
| 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", | |
| 836 | 1396 | 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", | |
| 843 | 1399 | ), |
| 844 | 1400 | }); |
| 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({ | |
| 877 | 1403 | threadId, |
| 878 | runtimeMode: "full-access", | |
| 1404 | input: "Start the next turn", | |
| 879 | 1405 | modelSelection: createModelSelection( |
| 880 | ProviderInstanceId.make("opencode_zen"), | |
| 881 | "anthropic/claude-sonnet-4-5", | |
| 1406 | ProviderInstanceId.make("opencode"), | |
| 1407 | "opencode/kimi-k3", | |
| 882 | 1408 | ), |
| 883 | 1409 | }); |
| 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 | }, | |
| 888 | 1417 | }); |
| 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" }, | |
| 895 | 1424 | }, |
| 896 | parts: [{ type: "text", text: "Fix it" }], | |
| 897 | 1425 | }); |
| 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 | ||
| 901 | 4448 | it.effect("rejects sendTurn model selections for another instance id", () => { |
| 902 | 4449 | const instanceId = ProviderInstanceId.make("opencode_zen"); |
| 903 | 4450 | const adapterLayer = Layer.effect( |
| ... | ... | @@ -1289,6 +4836,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 1289 | 4836 | }, |
| 1290 | 4837 | }, |
| 1291 | 4838 | }, |
| 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 | }, | |
| 1292 | 4872 | { |
| 1293 | 4873 | type: "message.updated", |
| 1294 | 4874 | properties: { |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+1665-248| ... | ... | @@ -15,13 +15,17 @@ import { |
| 15 | 15 | import * as Cause from "effect/Cause"; |
| 16 | 16 | import * as Crypto from "effect/Crypto"; |
| 17 | 17 | import * as DateTime from "effect/DateTime"; |
| 18 | import * as Deferred from "effect/Deferred"; | |
| 18 | 19 | import * as Effect from "effect/Effect"; |
| 19 | 20 | import * as Exit from "effect/Exit"; |
| 20 | 21 | import * as FileSystem from "effect/FileSystem"; |
| 22 | import * as Fiber from "effect/Fiber"; | |
| 21 | 23 | import * as Path from "effect/Path"; |
| 22 | 24 | import * as Queue from "effect/Queue"; |
| 23 | 25 | import * as Ref from "effect/Ref"; |
| 26 | import * as Schema from "effect/Schema"; | |
| 24 | 27 | import * as Scope from "effect/Scope"; |
| 28 | import * as Semaphore from "effect/Semaphore"; | |
| 25 | 29 | import * as Stream from "effect/Stream"; |
| 26 | 30 | import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; |
| 27 | 31 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| ... | ... | @@ -175,6 +179,77 @@ type OpenCodeSubscribedEvent = |
| 175 | 179 | ? TEvent |
| 176 | 180 | : never; |
| 177 | 181 | |
| 182 | type OpenCodeSessionStatusEvent = Extract< | |
| 183 | OpenCodeSubscribedEvent, | |
| 184 | { readonly type: "session.status" } | |
| 185 | >; | |
| 186 | ||
| 187 | const OpenCodeSessionStatusMap = Schema.Record( | |
| 188 | Schema.String, | |
| 189 | Schema.Struct({ type: Schema.String }), | |
| 190 | ); | |
| 191 | const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap); | |
| 192 | ||
| 193 | interface OpenCodeCancellation { | |
| 194 | readonly turnId: TurnId | undefined; | |
| 195 | readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>; | |
| 196 | acknowledged?: boolean; | |
| 197 | deferredIdleEvent?: OpenCodeSessionStatusEvent; | |
| 198 | } | |
| 199 | ||
| 200 | interface OpenCodeIdleReconciliation { | |
| 201 | readonly turnId: TurnId; | |
| 202 | readonly promptGeneration: number; | |
| 203 | raw: unknown; | |
| 204 | warned: boolean; | |
| 205 | dirty: boolean; | |
| 206 | fiber?: Fiber.Fiber<void, never>; | |
| 207 | } | |
| 208 | ||
| 209 | interface OpenCodePromptAdmission { | |
| 210 | readonly generation: number; | |
| 211 | readonly turnId: TurnId; | |
| 212 | readonly messageId: string; | |
| 213 | readonly priorAwaitingBusy: boolean; | |
| 214 | readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined; | |
| 215 | idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined; | |
| 216 | idleObservedAfterMessage: boolean; | |
| 217 | messageObserved: boolean; | |
| 218 | busyObserved: boolean; | |
| 219 | idleStatusConfirmations: number; | |
| 220 | accepted: boolean; | |
| 221 | cancelled: boolean; | |
| 222 | readonly acceptance: Deferred.Deferred<void>; | |
| 223 | readonly submissionSettled: Deferred.Deferred<void>; | |
| 224 | promptFiber?: Fiber.Fiber<void, ProviderAdapterRequestError>; | |
| 225 | recoveryFiber?: Fiber.Fiber<void, never>; | |
| 226 | recoveryRaw: unknown; | |
| 227 | } | |
| 228 | ||
| 229 | type OpenCodeTerminalRequestEvent = Extract< | |
| 230 | OpenCodeSubscribedEvent, | |
| 231 | { | |
| 232 | readonly type: "permission.replied" | "question.replied" | "question.rejected"; | |
| 233 | } | |
| 234 | >; | |
| 235 | ||
| 236 | type OpenCodeAskedRequestEvent = Extract< | |
| 237 | OpenCodeSubscribedEvent, | |
| 238 | { readonly type: "permission.asked" | "question.asked" } | |
| 239 | >; | |
| 240 | ||
| 241 | type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent; | |
| 242 | ||
| 243 | interface OpenCodeRequestRelationRetry { | |
| 244 | warned: boolean; | |
| 245 | fiber?: Fiber.Fiber<void, never>; | |
| 246 | } | |
| 247 | ||
| 248 | interface OpenCodePendingRequestRecovery { | |
| 249 | warned: boolean; | |
| 250 | rerun: boolean; | |
| 251 | } | |
| 252 | ||
| 178 | 253 | function trimText(value: string | undefined | null): string | undefined { |
| 179 | 254 | const trimmed = value?.trim(); |
| 180 | 255 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| ... | ... | @@ -214,6 +289,28 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und |
| 214 | 289 | return title; |
| 215 | 290 | } |
| 216 | 291 | |
| 292 | function isOpenCodeAbortError(error: unknown): boolean { | |
| 293 | return ( | |
| 294 | typeof error === "object" && | |
| 295 | error !== null && | |
| 296 | "name" in error && | |
| 297 | error.name === "MessageAbortedError" | |
| 298 | ); | |
| 299 | } | |
| 300 | ||
| 301 | function isOpenCodeChildRequestEvent(event: OpenCodeSubscribedEvent): boolean { | |
| 302 | switch (event.type) { | |
| 303 | case "permission.asked": | |
| 304 | case "permission.replied": | |
| 305 | case "question.asked": | |
| 306 | case "question.replied": | |
| 307 | case "question.rejected": | |
| 308 | return true; | |
| 309 | default: | |
| 310 | return false; | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 217 | 314 | const OPENCODE_DEFAULT_TITLE_PATTERN = |
| 218 | 315 | /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; |
| 219 | 316 | |
| ... | ... | @@ -227,6 +324,10 @@ interface OpenCodeSessionContext { |
| 227 | 324 | readonly server: OpenCodeServerConnection; |
| 228 | 325 | readonly directory: string; |
| 229 | 326 | readonly openCodeSessionId: string; |
| 327 | readonly relatedSessionIds: Set<string>; | |
| 328 | readonly resolvedRequestIds: Set<string>; | |
| 329 | readonly emittedTerminalRequestIds: Set<string>; | |
| 330 | readonly requestRelationRetries: Map<string, OpenCodeRequestRelationRetry>; | |
| 230 | 331 | readonly pendingPermissions: Map<string, PermissionRequest>; |
| 231 | 332 | readonly pendingQuestions: Map<string, QuestionRequest>; |
| 232 | 333 | readonly messageRoleById: Map<string, "user" | "assistant">; |
| ... | ... | @@ -237,6 +338,16 @@ interface OpenCodeSessionContext { |
| 237 | 338 | activeTurnId: TurnId | undefined; |
| 238 | 339 | activeAgent: string | undefined; |
| 239 | 340 | 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>; | |
| 240 | 351 | /** |
| 241 | 352 | * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. |
| 242 | 353 | * The session lifecycle is owned by `sessionScope`; this Ref exists only |
| ... | ... | @@ -537,24 +648,99 @@ function updateProviderSession( |
| 537 | 648 | }, |
| 538 | 649 | ): Effect.Effect<ProviderSession> { |
| 539 | 650 | 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); | |
| 555 | 652 | }); |
| 556 | 653 | } |
| 557 | 654 | |
| 655 | function applyProviderSessionUpdate( | |
| 656 | context: OpenCodeSessionContext, | |
| 657 | patch: Partial<ProviderSession>, | |
| 658 | options: | |
| 659 | | { | |
| 660 | readonly clearActiveTurnId?: boolean; | |
| 661 | readonly clearLastError?: boolean; | |
| 662 | } | |
| 663 | | undefined, | |
| 664 | updatedAt: string, | |
| 665 | ): ProviderSession { | |
| 666 | const nextSession = { | |
| 667 | ...context.session, | |
| 668 | ...patch, | |
| 669 | updatedAt, | |
| 670 | } as ProviderSession & Record<string, unknown>; | |
| 671 | const mutableSession = nextSession as Record<string, unknown>; | |
| 672 | if (options?.clearActiveTurnId) { | |
| 673 | delete mutableSession.activeTurnId; | |
| 674 | } | |
| 675 | if (options?.clearLastError) { | |
| 676 | delete mutableSession.lastError; | |
| 677 | } | |
| 678 | context.session = nextSession; | |
| 679 | return nextSession; | |
| 680 | } | |
| 681 | ||
| 682 | const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellation")(function* ( | |
| 683 | context: OpenCodeSessionContext, | |
| 684 | detail: string, | |
| 685 | ) { | |
| 686 | const cancellation = context.cancellation; | |
| 687 | if (!cancellation) { | |
| 688 | return; | |
| 689 | } | |
| 690 | context.cancellation = undefined; | |
| 691 | yield* Deferred.fail( | |
| 692 | cancellation.completion, | |
| 693 | new ProviderAdapterRequestError({ | |
| 694 | provider: PROVIDER, | |
| 695 | method: "session.abort", | |
| 696 | detail, | |
| 697 | }), | |
| 698 | ).pipe(Effect.ignore); | |
| 699 | }); | |
| 700 | ||
| 701 | const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) => | |
| 702 | runOpenCodeSdk("session.abort", (signal) => | |
| 703 | context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), | |
| 704 | ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); | |
| 705 | ||
| 706 | const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* ( | |
| 707 | context: OpenCodeSessionContext, | |
| 708 | ) { | |
| 709 | const admission = context.promptAdmission; | |
| 710 | if (!admission) { | |
| 711 | return; | |
| 712 | } | |
| 713 | admission.cancelled = true; | |
| 714 | if (admission.promptFiber) { | |
| 715 | yield* Fiber.interrupt(admission.promptFiber); | |
| 716 | } | |
| 717 | yield* Deferred.await(admission.submissionSettled); | |
| 718 | }); | |
| 719 | ||
| 720 | const closeStartingOpenCodeContext = Effect.fn("closeStartingOpenCodeContext")(function* ( | |
| 721 | context: OpenCodeSessionContext, | |
| 722 | abortRemote: boolean, | |
| 723 | ) { | |
| 724 | if (yield* Ref.getAndSet(context.stopped, true)) { | |
| 725 | return; | |
| 726 | } | |
| 727 | yield* Deferred.fail( | |
| 728 | context.firstConnection, | |
| 729 | new ProviderAdapterRequestError({ | |
| 730 | provider: PROVIDER, | |
| 731 | method: "event.subscribe", | |
| 732 | detail: "OpenCode session startup ended before the event stream connected.", | |
| 733 | }), | |
| 734 | ).pipe(Effect.ignore); | |
| 735 | yield* cancelPendingOpenCodePrompt(context); | |
| 736 | yield* failPendingOpenCodeCancellation(context, "OpenCode session startup was cancelled."); | |
| 737 | context.promptAdmission = undefined; | |
| 738 | if (abortRemote) { | |
| 739 | yield* abortOpenCodeSessionForTeardown(context); | |
| 740 | } | |
| 741 | yield* Scope.close(context.sessionScope, Exit.void).pipe(Effect.ignore); | |
| 742 | }); | |
| 743 | ||
| 558 | 744 | const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( |
| 559 | 745 | context: OpenCodeSessionContext, |
| 560 | 746 | ) { |
| ... | ... | @@ -562,13 +748,26 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( |
| 562 | 748 | if (yield* Ref.getAndSet(context.stopped, true)) { |
| 563 | 749 | return false; |
| 564 | 750 | } |
| 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; | |
| 565 | 766 | |
| 566 | 767 | // Best-effort remote abort. The scope close below tears down the local |
| 567 | 768 | // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), |
| 568 | 769 | // 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); | |
| 572 | 771 | |
| 573 | 772 | // Closing the session scope interrupts every fiber forked into it and |
| 574 | 773 | // runs each finalizer we registered — the `AbortController.abort()` call, |
| ... | ... | @@ -603,6 +802,24 @@ export function makeOpenCodeAdapter( |
| 603 | 802 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; |
| 604 | 803 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 605 | 804 | 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 | }); | |
| 606 | 823 | const randomUUIDv4 = crypto.randomUUIDv4.pipe( |
| 607 | 824 | Effect.mapError( |
| 608 | 825 | (cause) => |
| ... | ... | @@ -614,6 +831,37 @@ export function makeOpenCodeAdapter( |
| 614 | 831 | }), |
| 615 | 832 | ), |
| 616 | 833 | ); |
| 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 | }); | |
| 617 | 865 | const buildEventBase = (input: EventBaseInput) => |
| 618 | 866 | Effect.all({ |
| 619 | 867 | eventId: randomUUIDv4.pipe(Effect.map(EventId.make)), |
| ... | ... | @@ -683,6 +931,424 @@ export function makeOpenCodeAdapter( |
| 683 | 931 | }, |
| 684 | 932 | ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); |
| 685 | 933 | |
| 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 | ||
| 686 | 1352 | const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( |
| 687 | 1353 | context: OpenCodeSessionContext, |
| 688 | 1354 | message: string, |
| ... | ... | @@ -694,8 +1360,21 @@ export function makeOpenCodeAdapter( |
| 694 | 1360 | if (yield* Ref.getAndSet(context.stopped, true)) { |
| 695 | 1361 | return; |
| 696 | 1362 | } |
| 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; | |
| 697 | 1376 | const turnId = context.activeTurnId; |
| 698 | sessions.delete(context.session.threadId); | |
| 1377 | deleteContextIfCurrent(context); | |
| 699 | 1378 | // Emit lifecycle events BEFORE tearing down the scope. Both call sites |
| 700 | 1379 | // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; |
| 701 | 1380 | // closing that scope triggers the fiber-interrupt finalizer, so any |
| ... | ... | @@ -726,9 +1405,7 @@ export function makeOpenCodeAdapter( |
| 726 | 1405 | // Inline the teardown that `stopOpenCodeContext` would do; we can't |
| 727 | 1406 | // delegate to it because our `getAndSet` above already flipped the |
| 728 | 1407 | // 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); | |
| 732 | 1409 | yield* Scope.close(context.sessionScope, Exit.void); |
| 733 | 1410 | }); |
| 734 | 1411 | |
| ... | ... | @@ -799,35 +1476,447 @@ export function makeOpenCodeAdapter( |
| 799 | 1476 | } |
| 800 | 1477 | }); |
| 801 | 1478 | |
| 802 | const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( | |
| 1479 | const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* ( | |
| 803 | 1480 | context: OpenCodeSessionContext, |
| 804 | event: OpenCodeSubscribedEvent, | |
| 1481 | candidateSessionId: string, | |
| 805 | 1482 | ) { |
| 806 | const payloadSessionId = openCodeEventSessionId(event); | |
| 807 | if (payloadSessionId !== context.openCodeSessionId) { | |
| 808 | return; | |
| 1483 | if (context.relatedSessionIds.has(candidateSessionId)) { | |
| 1484 | return true; | |
| 809 | 1485 | } |
| 810 | 1486 | |
| 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 | }); | |
| 823 | 1520 | |
| 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, | |
| 831 | 1920 | raw: event, |
| 832 | 1921 | })), |
| 833 | 1922 | type: "thread.metadata.updated", |
| ... | ... | @@ -843,6 +1932,24 @@ export function makeOpenCodeAdapter( |
| 843 | 1932 | } |
| 844 | 1933 | |
| 845 | 1934 | 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 | } | |
| 846 | 1953 | context.messageRoleById.set(event.properties.info.id, event.properties.info.role); |
| 847 | 1954 | if (event.properties.info.role === "assistant") { |
| 848 | 1955 | for (const part of context.partById.values()) { |
| ... | ... | @@ -956,101 +2063,44 @@ export function makeOpenCodeAdapter( |
| 956 | 2063 | } |
| 957 | 2064 | |
| 958 | 2065 | 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); | |
| 977 | 2067 | break; |
| 978 | 2068 | } |
| 979 | 2069 | |
| 980 | 2070 | case "permission.replied": { |
| 981 | 2071 | 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); | |
| 995 | 2073 | break; |
| 996 | 2074 | } |
| 997 | 2075 | |
| 998 | 2076 | 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); | |
| 1012 | 2078 | break; |
| 1013 | 2079 | } |
| 1014 | 2080 | |
| 1015 | 2081 | case "question.replied": { |
| 1016 | const request = context.pendingQuestions.get(event.properties.requestID); | |
| 2082 | yield* emitTerminalOpenCodeRequest(context, event); | |
| 1017 | 2083 | 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 | }); | |
| 1034 | 2084 | break; |
| 1035 | 2085 | } |
| 1036 | 2086 | |
| 1037 | 2087 | case "question.rejected": { |
| 1038 | 2088 | 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); | |
| 1049 | 2090 | break; |
| 1050 | 2091 | } |
| 1051 | 2092 | |
| 1052 | 2093 | case "session.status": { |
| 1053 | 2094 | 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 | } | |
| 1054 | 2104 | yield* updateProviderSession(context, { |
| 1055 | 2105 | status: "running", |
| 1056 | 2106 | activeTurnId: turnId, |
| ... | ... | @@ -1074,19 +2124,25 @@ export function makeOpenCodeAdapter( |
| 1074 | 2124 | } |
| 1075 | 2125 | |
| 1076 | 2126 | 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); | |
| 1090 | 2146 | } |
| 1091 | 2147 | break; |
| 1092 | 2148 | } |
| ... | ... | @@ -1094,7 +2150,32 @@ export function makeOpenCodeAdapter( |
| 1094 | 2150 | case "session.error": { |
| 1095 | 2151 | const message = sessionErrorMessage(event.properties.error); |
| 1096 | 2152 | 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 | } | |
| 1097 | 2175 | context.activeTurnId = undefined; |
| 2176 | context.activeAgent = undefined; | |
| 2177 | context.activeVariant = undefined; | |
| 2178 | context.reconcileIdleStatus = false; | |
| 1098 | 2179 | yield* updateProviderSession( |
| 1099 | 2180 | context, |
| 1100 | 2181 | { |
| ... | ... | @@ -1210,8 +2291,11 @@ export function makeOpenCodeAdapter( |
| 1210 | 2291 | const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; |
| 1211 | 2292 | const existing = sessions.get(input.threadId); |
| 1212 | 2293 | if (existing) { |
| 2294 | if (existing.session.status === "connecting" && !(yield* Ref.get(existing.stopped))) { | |
| 2295 | return (yield* awaitOpenCodeContextReady(existing)).session; | |
| 2296 | } | |
| 1213 | 2297 | yield* stopOpenCodeContext(existing); |
| 1214 | sessions.delete(input.threadId); | |
| 2298 | deleteContextIfCurrent(existing); | |
| 1215 | 2299 | } |
| 1216 | 2300 | |
| 1217 | 2301 | const started = yield* Effect.gen(function* () { |
| ... | ... | @@ -1223,13 +2307,15 @@ export function makeOpenCodeAdapter( |
| 1223 | 2307 | // process automatically. No manual `server.close()` needed. |
| 1224 | 2308 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ |
| 1225 | 2309 | binaryPath, |
| 2310 | directory, | |
| 1226 | 2311 | serverUrl, |
| 2312 | ...(serverPassword ? { serverPassword } : {}), | |
| 1227 | 2313 | ...(options?.environment ? { environment: options.environment } : {}), |
| 1228 | 2314 | }); |
| 1229 | 2315 | const client = openCodeRuntime.createOpenCodeSdkClient({ |
| 1230 | 2316 | baseUrl: server.url, |
| 1231 | 2317 | directory, |
| 1232 | ...(server.external && serverPassword ? { serverPassword } : {}), | |
| 2318 | ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), | |
| 1233 | 2319 | }); |
| 1234 | 2320 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 1235 | 2321 | if (mcpSession && !server.external) { |
| ... | ... | @@ -1348,29 +2434,11 @@ export function makeOpenCodeAdapter( |
| 1348 | 2434 | return startedExit.value; |
| 1349 | 2435 | }); |
| 1350 | 2436 | |
| 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 | ||
| 1369 | 2437 | const createdAt = yield* nowIso; |
| 1370 | 2438 | const session: ProviderSession = { |
| 1371 | 2439 | provider: PROVIDER, |
| 1372 | 2440 | providerInstanceId: boundInstanceId, |
| 1373 | status: "ready", | |
| 2441 | status: "connecting", | |
| 1374 | 2442 | runtimeMode: input.runtimeMode, |
| 1375 | 2443 | cwd: directory, |
| 1376 | 2444 | ...(input.modelSelection ? { model: input.modelSelection.model } : {}), |
| ... | ... | @@ -1392,6 +2460,10 @@ export function makeOpenCodeAdapter( |
| 1392 | 2460 | server: started.server, |
| 1393 | 2461 | directory, |
| 1394 | 2462 | openCodeSessionId: started.openCodeSession.id, |
| 2463 | relatedSessionIds: new Set([started.openCodeSession.id]), | |
| 2464 | resolvedRequestIds: new Set(), | |
| 2465 | emittedTerminalRequestIds: new Set(), | |
| 2466 | requestRelationRetries: new Map(), | |
| 1395 | 2467 | pendingPermissions: new Map(), |
| 1396 | 2468 | pendingQuestions: new Map(), |
| 1397 | 2469 | partById: new Map(), |
| ... | ... | @@ -1402,11 +2474,56 @@ export function makeOpenCodeAdapter( |
| 1402 | 2474 | activeTurnId: undefined, |
| 1403 | 2475 | activeAgent: undefined, |
| 1404 | 2476 | 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>(), | |
| 1405 | 2487 | stopped: yield* Ref.make(false), |
| 1406 | 2488 | sessionScope: started.sessionScope, |
| 1407 | 2489 | }; |
| 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 | } | |
| 1408 | 2497 | 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 | } | |
| 1410 | 2527 | |
| 1411 | 2528 | yield* emit({ |
| 1412 | 2529 | ...(yield* buildEventBase({ threadId: input.threadId })), |
| ... | ... | @@ -1423,17 +2540,13 @@ export function makeOpenCodeAdapter( |
| 1423 | 2540 | }, |
| 1424 | 2541 | }); |
| 1425 | 2542 | |
| 1426 | return session; | |
| 2543 | return context.session; | |
| 1427 | 2544 | }, |
| 1428 | 2545 | ); |
| 1429 | 2546 | |
| 1430 | 2547 | const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { |
| 1431 | 2548 | 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); | |
| 1437 | 2550 | const modelSelection = |
| 1438 | 2551 | input.modelSelection ?? |
| 1439 | 2552 | (context.session.model |
| ... | ... | @@ -1474,107 +2587,411 @@ export function makeOpenCodeAdapter( |
| 1474 | 2587 | }); |
| 1475 | 2588 | } |
| 1476 | 2589 | |
| 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 | ); | |
| 1479 | 2663 | |
| 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 | } | |
| 1492 | 2674 | |
| 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 | } | |
| 1503 | 2685 | |
| 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 | }), | |
| 1534 | 2712 | ); |
| 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 | } | |
| 1545 | 2811 | }), |
| 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 | } | |
| 1548 | 2862 | |
| 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 | ); | |
| 1558 | 2893 | }); |
| 1559 | 2894 | |
| 1560 | 2895 | const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 1561 | 2896 | function* (threadId, turnId) { |
| 1562 | 2897 | 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 | } | |
| 1577 | 2993 | } |
| 2994 | yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); | |
| 1578 | 2995 | }, |
| 1579 | 2996 | ); |
| 1580 | 2997 | |
| ... | ... | @@ -1629,7 +3046,7 @@ export function makeOpenCodeAdapter( |
| 1629 | 3046 | }); |
| 1630 | 3047 | } |
| 1631 | 3048 | const stopped = yield* stopOpenCodeContext(context); |
| 1632 | sessions.delete(threadId); | |
| 3049 | deleteContextIfCurrent(context); | |
| 1633 | 3050 | if (!stopped) { |
| 1634 | 3051 | return; |
| 1635 | 3052 | } |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+148-23| ... | ... | @@ -12,8 +12,10 @@ import { ServerConfig } from "../../config.ts"; |
| 12 | 12 | import { |
| 13 | 13 | OpenCodeRuntime, |
| 14 | 14 | OpenCodeRuntimeError, |
| 15 | resolveOpenCodeServerPassword, | |
| 15 | 16 | type OpenCodeRuntimeShape, |
| 16 | 17 | } from "../opencodeRuntime.ts"; |
| 18 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | |
| 17 | 19 | import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; |
| 18 | 20 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; |
| 19 | 21 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); |
| ... | ... | @@ -34,8 +36,14 @@ const runtimeMock = { |
| 34 | 36 | runVersionError: null as Error | null, |
| 35 | 37 | versionStdout: DEFAULT_VERSION_STDOUT, |
| 36 | 38 | inventoryError: null as Error | null, |
| 39 | connectionError: null as Error | null, | |
| 37 | 40 | inventoryCwd: null as string | null, |
| 38 | 41 | closeCalls: 0, |
| 42 | sdkClientInputs: [] as Array<{ | |
| 43 | baseUrl: string; | |
| 44 | directory: string; | |
| 45 | serverPassword?: string; | |
| 46 | }>, | |
| 39 | 47 | inventory: { |
| 40 | 48 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, |
| 41 | 49 | agents: [] as unknown[], |
| ... | ... | @@ -46,8 +54,10 @@ const runtimeMock = { |
| 46 | 54 | this.state.runVersionError = null; |
| 47 | 55 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; |
| 48 | 56 | this.state.inventoryError = null; |
| 57 | this.state.connectionError = null; | |
| 49 | 58 | this.state.inventoryCwd = null; |
| 50 | 59 | this.state.closeCalls = 0; |
| 60 | this.state.sdkClientInputs.length = 0; | |
| 51 | 61 | this.state.inventory = { |
| 52 | 62 | providerList: { connected: [], all: [] as unknown[], default: {} }, |
| 53 | 63 | agents: [] as unknown[], |
| ... | ... | @@ -57,13 +67,37 @@ const runtimeMock = { |
| 57 | 67 | }; |
| 58 | 68 | |
| 59 | 69 | const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 60 | startOpenCodeServerProcess: () => | |
| 61 | Effect.succeed({ | |
| 62 | url: "http://127.0.0.1:4301", | |
| 63 | exitCode: Effect.never, | |
| 70 | startOpenCodeServerProcess: ({ serverPassword, environment }) => | |
| 71 | Effect.gen(function* () { | |
| 72 | yield* Effect.addFinalizer(() => | |
| 73 | Effect.sync(() => { | |
| 74 | runtimeMock.state.closeCalls += 1; | |
| 75 | }), | |
| 76 | ); | |
| 77 | const effectiveServerPassword = resolveOpenCodeServerPassword({ | |
| 78 | external: false, | |
| 79 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 80 | ...(environment !== undefined ? { environment } : {}), | |
| 81 | }); | |
| 82 | return { | |
| 83 | url: "http://127.0.0.1:4301", | |
| 84 | ...(effectiveServerPassword !== undefined | |
| 85 | ? { serverPassword: effectiveServerPassword } | |
| 86 | : {}), | |
| 87 | version: "1.14.19", | |
| 88 | isRunning: Effect.succeed(true), | |
| 89 | exitCode: Effect.never, | |
| 90 | }; | |
| 64 | 91 | }), |
| 65 | connectToOpenCodeServer: ({ serverUrl }) => | |
| 92 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | |
| 66 | 93 | 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 | } | |
| 67 | 101 | if (!serverUrl) { |
| 68 | 102 | yield* Effect.addFinalizer(() => |
| 69 | 103 | Effect.sync(() => { |
| ... | ... | @@ -73,6 +107,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 73 | 107 | } |
| 74 | 108 | return { |
| 75 | 109 | url: serverUrl ?? "http://127.0.0.1:4301", |
| 110 | ...(serverPassword ? { serverPassword } : {}), | |
| 111 | version: "1.14.19", | |
| 76 | 112 | exitCode: null, |
| 77 | 113 | external: Boolean(serverUrl), |
| 78 | 114 | }; |
| ... | ... | @@ -87,8 +123,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 87 | 123 | }), |
| 88 | 124 | ) |
| 89 | 125 | : 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 | }, | |
| 92 | 130 | loadOpenCodeInventory: () => |
| 93 | 131 | runtimeMock.state.inventoryError |
| 94 | 132 | ? Effect.fail( |
| ... | ... | @@ -132,11 +170,31 @@ const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSe |
| 132 | 170 | ...overrides, |
| 133 | 171 | }); |
| 134 | 172 | |
| 173 | const checkProvider = Effect.fn("checkProvider")(function* ( | |
| 174 | settings: OpenCodeSettings, | |
| 175 | cwd = process.cwd(), | |
| 176 | environment?: NodeJS.ProcessEnv, | |
| 177 | ) { | |
| 178 | return yield* Effect.scoped( | |
| 179 | Effect.gen(function* () { | |
| 180 | const serverOwner = yield* OpenCodeServerOwner.make({ | |
| 181 | binaryPath: settings.binaryPath, | |
| 182 | directory: cwd, | |
| 183 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | |
| 184 | ...(environment ? { environment } : {}), | |
| 185 | }); | |
| 186 | return yield* checkOpenCodeProviderStatus(settings, cwd, environment).pipe( | |
| 187 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | |
| 188 | ); | |
| 189 | }), | |
| 190 | ); | |
| 191 | }); | |
| 192 | ||
| 135 | 193 | it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 136 | 194 | it.effect("shows a codex-style missing binary message", () => |
| 137 | 195 | Effect.gen(function* () { |
| 138 | 196 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); |
| 139 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | |
| 197 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 140 | 198 | |
| 141 | 199 | NodeAssert.equal(snapshot.status, "error"); |
| 142 | 200 | NodeAssert.equal(snapshot.installed, false); |
| ... | ... | @@ -150,7 +208,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 150 | 208 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => |
| 151 | 209 | Effect.gen(function* () { |
| 152 | 210 | 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()); | |
| 154 | 212 | |
| 155 | 213 | NodeAssert.equal(snapshot.status, "error"); |
| 156 | 214 | NodeAssert.equal(snapshot.installed, true); |
| ... | ... | @@ -190,7 +248,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 190 | 248 | ], |
| 191 | 249 | }; |
| 192 | 250 | |
| 193 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | |
| 251 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 194 | 252 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); |
| 195 | 253 | |
| 196 | 254 | NodeAssert.ok(model); |
| ... | ... | @@ -253,7 +311,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 253 | 311 | ], |
| 254 | 312 | }; |
| 255 | 313 | |
| 256 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | |
| 314 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 257 | 315 | |
| 258 | 316 | NodeAssert.deepEqual( |
| 259 | 317 | snapshot.skills.map((skill) => ({ |
| ... | ... | @@ -280,41 +338,109 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { |
| 280 | 338 | }), |
| 281 | 339 | ); |
| 282 | 340 | |
| 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", () => | |
| 284 | 358 | Effect.gen(function* () { |
| 285 | yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | |
| 359 | yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { | |
| 360 | OPENCODE_SERVER_PASSWORD: "environment-password", | |
| 361 | }); | |
| 286 | 362 | |
| 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"); | |
| 289 | 382 | }), |
| 290 | 383 | ); |
| 291 | 384 | |
| 292 | 385 | it.effect("reports local model inventory failures without treating them as empty", () => |
| 293 | 386 | Effect.gen(function* () { |
| 294 | 387 | runtimeMock.state.inventoryError = new Error("opencode models failed"); |
| 295 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); | |
| 388 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | |
| 296 | 389 | |
| 297 | 390 | NodeAssert.equal(snapshot.status, "error"); |
| 298 | 391 | NodeAssert.equal(snapshot.installed, true); |
| 299 | 392 | NodeAssert.equal(snapshot.models.length, 0); |
| 300 | 393 | NodeAssert.equal( |
| 301 | 394 | snapshot.message, |
| 302 | "Failed to execute OpenCode CLI health check: opencode models failed", | |
| 395 | "Failed to load OpenCode provider inventory: opencode models failed", | |
| 303 | 396 | ); |
| 304 | 397 | }), |
| 305 | 398 | ); |
| 306 | 399 | }); |
| 307 | 400 | |
| 308 | 401 | it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { |
| 402 | it.effect("does not send a local environment password to a configured server", () => | |
| 403 | Effect.gen(function* () { | |
| 404 | const snapshot = yield* checkProvider( | |
| 405 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | |
| 406 | process.cwd(), | |
| 407 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | |
| 408 | ); | |
| 409 | ||
| 410 | NodeAssert.equal(snapshot.version, "1.14.19"); | |
| 411 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | |
| 412 | { | |
| 413 | baseUrl: "http://127.0.0.1:9999", | |
| 414 | directory: process.cwd(), | |
| 415 | }, | |
| 416 | ]); | |
| 417 | }), | |
| 418 | ); | |
| 419 | ||
| 420 | it.effect("rejects an unsupported server before loading inventory", () => | |
| 421 | Effect.gen(function* () { | |
| 422 | runtimeMock.state.connectionError = new Error( | |
| 423 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | |
| 424 | ); | |
| 425 | const snapshot = yield* checkProvider( | |
| 426 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | |
| 427 | ); | |
| 428 | ||
| 429 | NodeAssert.equal(snapshot.status, "error"); | |
| 430 | NodeAssert.equal(snapshot.models.length, 0); | |
| 431 | NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); | |
| 432 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | |
| 433 | }), | |
| 434 | ); | |
| 435 | ||
| 309 | 436 | it.effect("surfaces a friendly auth error for configured servers", () => |
| 310 | 437 | 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( | |
| 313 | 440 | makeOpenCodeSettings({ |
| 314 | 441 | serverUrl: "http://127.0.0.1:9999", |
| 315 | 442 | serverPassword: "secret-password", |
| 316 | 443 | }), |
| 317 | process.cwd(), | |
| 318 | 444 | ); |
| 319 | 445 | |
| 320 | 446 | NodeAssert.equal(snapshot.status, "error"); |
| ... | ... | @@ -328,15 +454,14 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i |
| 328 | 454 | |
| 329 | 455 | it.effect("surfaces a friendly connection error for configured servers", () => |
| 330 | 456 | Effect.gen(function* () { |
| 331 | runtimeMock.state.inventoryError = new Error( | |
| 457 | runtimeMock.state.connectionError = new Error( | |
| 332 | 458 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", |
| 333 | 459 | ); |
| 334 | const snapshot = yield* checkOpenCodeProviderStatus( | |
| 460 | const snapshot = yield* checkProvider( | |
| 335 | 461 | makeOpenCodeSettings({ |
| 336 | 462 | serverUrl: "http://127.0.0.1:9999", |
| 337 | 463 | serverPassword: "secret-password", |
| 338 | 464 | }), |
| 339 | process.cwd(), | |
| 340 | 465 | ); |
| 341 | 466 | |
| 342 | 467 | NodeAssert.equal(snapshot.status, "error"); |
apps/server/src/provider/Layers/OpenCodeProvider.ts+52-34| ... | ... | @@ -19,17 +19,18 @@ import { |
| 19 | 19 | type ServerProviderDraft, |
| 20 | 20 | } from "../providerSnapshot.ts"; |
| 21 | 21 | import { |
| 22 | MINIMUM_OPENCODE_VERSION, | |
| 22 | 23 | OpenCodeRuntime, |
| 23 | 24 | openCodeRuntimeErrorDetail, |
| 24 | 25 | type OpenCodeInventory, |
| 25 | 26 | } from "../opencodeRuntime.ts"; |
| 26 | 27 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; |
| 28 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | |
| 27 | 29 | |
| 28 | 30 | const OPENCODE_PRESENTATION = { |
| 29 | 31 | displayName: "OpenCode", |
| 30 | 32 | showInteractionModeToggle: false, |
| 31 | 33 | } as const; |
| 32 | const MINIMUM_OPENCODE_VERSION = "1.14.19"; | |
| 33 | 34 | |
| 34 | 35 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ |
| 35 | 36 | readonly cause: unknown; |
| ... | ... | @@ -65,6 +66,7 @@ function normalizedErrorMessage(cause: unknown): string | undefined { |
| 65 | 66 | function formatOpenCodeProbeError(input: { |
| 66 | 67 | readonly cause: unknown; |
| 67 | 68 | readonly isExternalServer: boolean; |
| 69 | readonly phase: "version" | "inventory"; | |
| 68 | 70 | readonly serverUrl: string; |
| 69 | 71 | }): { readonly installed: boolean; readonly message: string } { |
| 70 | 72 | const detail = normalizedErrorMessage(input.cause); |
| ... | ... | @@ -127,11 +129,13 @@ function formatOpenCodeProbeError(input: { |
| 127 | 129 | }; |
| 128 | 130 | } |
| 129 | 131 | |
| 132 | const failureLabel = | |
| 133 | input.phase === "inventory" | |
| 134 | ? "Failed to load OpenCode provider inventory" | |
| 135 | : "Failed to execute OpenCode CLI health check"; | |
| 130 | 136 | return { |
| 131 | 137 | 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}.`, | |
| 135 | 139 | }; |
| 136 | 140 | } |
| 137 | 141 | |
| ... | ... | @@ -326,17 +330,27 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 326 | 330 | openCodeSettings: OpenCodeSettings, |
| 327 | 331 | cwd: string, |
| 328 | 332 | environment?: NodeJS.ProcessEnv, |
| 329 | ): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> { | |
| 333 | ): Effect.fn.Return< | |
| 334 | ServerProviderDraft, | |
| 335 | never, | |
| 336 | OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner | |
| 337 | > { | |
| 330 | 338 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 339 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | |
| 331 | 340 | const resolvedEnvironment = environment ?? process.env; |
| 332 | 341 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 333 | 342 | const customModels = openCodeSettings.customModels; |
| 334 | 343 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; |
| 335 | 344 | |
| 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 | ) => { | |
| 337 | 350 | const failure = formatOpenCodeProbeError({ |
| 338 | 351 | cause, |
| 339 | 352 | isExternalServer, |
| 353 | phase, | |
| 340 | 354 | serverUrl: openCodeSettings.serverUrl, |
| 341 | 355 | }); |
| 342 | 356 | return buildServerProvider({ |
| ... | ... | @@ -417,48 +431,52 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 417 | 431 | } |
| 418 | 432 | } |
| 419 | 433 | |
| 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({ | |
| 441 | 451 | binaryPath: openCodeSettings.binaryPath, |
| 442 | cwd, | |
| 443 | environment: resolvedEnvironment, | |
| 452 | directory: cwd, | |
| 453 | serverUrl: openCodeSettings.serverUrl, | |
| 454 | ...(openCodeSettings.serverPassword | |
| 455 | ? { serverPassword: openCodeSettings.serverPassword } | |
| 456 | : {}), | |
| 444 | 457 | }) |
| 445 | ).pipe( | |
| 458 | .pipe(Effect.flatMap(loadInventory), Effect.scoped) | |
| 459 | : serverOwner.withServer(loadInventory); | |
| 460 | const inventoryExit = yield* Effect.exit( | |
| 461 | inventoryEffect.pipe( | |
| 446 | 462 | Effect.mapError( |
| 447 | 463 | (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), |
| 448 | 464 | ), |
| 449 | 465 | ), |
| 450 | 466 | ); |
| 451 | 467 | if (inventoryExit._tag === "Failure") { |
| 452 | return fallback(Cause.squash(inventoryExit.cause), version); | |
| 468 | return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); | |
| 453 | 469 | } |
| 454 | 470 | |
| 471 | version = inventoryExit.value.version; | |
| 472 | ||
| 455 | 473 | const models = providerModelsFromSettings( |
| 456 | flattenOpenCodeModels(inventoryExit.value), | |
| 474 | flattenOpenCodeModels(inventoryExit.value.inventory), | |
| 457 | 475 | customModels, |
| 458 | 476 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 459 | 477 | ); |
| 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; | |
| 462 | 480 | return buildServerProvider({ |
| 463 | 481 | presentation: OPENCODE_PRESENTATION, |
| 464 | 482 | 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 |
| 589 | 589 | }), |
| 590 | 590 | }, |
| 591 | 591 | ], |
| 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 | ], | |
| 594 | 601 | } as const satisfies ServerProvider; |
| 595 | 602 | const refreshedProvider = { |
| 596 | 603 | ...previousProvider, |
| 597 | 604 | checkedAt: "2026-04-14T00:01:00.000Z", |
| 598 | 605 | models: [], |
| 606 | slashCommands: [], | |
| 607 | skills: [], | |
| 599 | 608 | } satisfies ServerProvider; |
| 600 | 609 | |
| 601 | 610 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ |
| 602 | 611 | ...previousProvider.models, |
| 603 | 612 | ]); |
| 613 | assert.deepStrictEqual( | |
| 614 | mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, | |
| 615 | [], | |
| 616 | ); | |
| 617 | assert.deepStrictEqual( | |
| 618 | mergeProviderSnapshot(previousProvider, refreshedProvider).skills, | |
| 619 | [], | |
| 620 | ); | |
| 604 | 621 | }); |
| 605 | 622 | |
| 606 | 623 | it("drops stale OpenCode models missing from a successful refresh", () => { |
| ... | ... | @@ -670,8 +687,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 670 | 687 | capabilities: null, |
| 671 | 688 | }, |
| 672 | 689 | ], |
| 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 | ], | |
| 675 | 699 | } as const satisfies ServerProvider; |
| 676 | 700 | const refreshedProvider = { |
| 677 | 701 | ...previousProvider, |
| ... | ... | @@ -685,6 +709,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 685 | 709 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ |
| 686 | 710 | ...previousProvider.models, |
| 687 | 711 | ]); |
| 712 | assert.deepStrictEqual( | |
| 713 | mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, | |
| 714 | previousProvider.slashCommands, | |
| 715 | ); | |
| 716 | assert.deepStrictEqual( | |
| 717 | mergeProviderSnapshot(previousProvider, refreshedProvider).skills, | |
| 718 | previousProvider.skills, | |
| 719 | ); | |
| 688 | 720 | }); |
| 689 | 721 | |
| 690 | 722 | it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { |
| ... | ... | @@ -713,8 +745,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 713 | 745 | capabilities: null, |
| 714 | 746 | }, |
| 715 | 747 | ], |
| 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 | ], | |
| 718 | 757 | } as const satisfies ServerProvider; |
| 719 | 758 | const pendingProvider = { |
| 720 | 759 | ...previousProvider, |
| ... | ... | @@ -732,6 +771,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 732 | 771 | auth: { status: "unknown" }, |
| 733 | 772 | checkedAt: "2026-07-17T00:02:00.000Z", |
| 734 | 773 | models: [], |
| 774 | slashCommands: [], | |
| 775 | skills: [], | |
| 735 | 776 | message: "OpenCode is available, but it did not report any connected upstream providers.", |
| 736 | 777 | } satisfies ServerProvider; |
| 737 | 778 | const missingProvider = { |
| ... | ... | @@ -765,6 +806,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 765 | 806 | mergeProviderSnapshot(previousProvider, loggedOutProvider).models, |
| 766 | 807 | [], |
| 767 | 808 | ); |
| 809 | assert.deepStrictEqual( | |
| 810 | mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, | |
| 811 | [], | |
| 812 | ); | |
| 813 | assert.deepStrictEqual( | |
| 814 | mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, | |
| 815 | [], | |
| 816 | ); | |
| 768 | 817 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); |
| 769 | 818 | |
| 770 | 819 | const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); |
| ... | ... | @@ -896,6 +945,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 896 | 945 | }), |
| 897 | 946 | ); |
| 898 | 947 | |
| 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 | ||
| 899 | 1124 | it.effect("persists the merged snapshot when a live update has empty models", () => |
| 900 | 1125 | Effect.gen(function* () { |
| 901 | 1126 | const cursorDriver = ProviderDriverKind.make("cursor"); |
apps/server/src/provider/Layers/ProviderRegistry.ts+14| ... | ... | @@ -95,6 +95,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => |
| 95 | 95 | return isPendingInitialProbe || didInstalledProviderProbeFail; |
| 96 | 96 | }; |
| 97 | 97 | |
| 98 | const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => | |
| 99 | provider.driver === ProviderDriverKind.make("opencode") && | |
| 100 | shouldRetainMissingProviderModels(provider); | |
| 101 | ||
| 98 | 102 | const mergeProviderModels = ( |
| 99 | 103 | provider: ServerProvider, |
| 100 | 104 | previousModels: ReadonlyArray<ServerProvider["models"][number]>, |
| ... | ... | @@ -132,6 +136,16 @@ export const mergeProviderSnapshot = ( |
| 132 | 136 | : { |
| 133 | 137 | ...nextProvider, |
| 134 | 138 | 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 | : {}), | |
| 135 | 149 | }; |
| 136 | 150 | |
| 137 | 151 | export const mergeProviderSnapshots = ( |
apps/server/src/provider/Layers/ProviderService.test.ts+63| ... | ... | @@ -24,6 +24,8 @@ import { |
| 24 | 24 | import { createModelSelection } from "@t3tools/shared/model"; |
| 25 | 25 | import { it, assert, describe, vi } from "@effect/vitest"; |
| 26 | 26 | |
| 27 | import * as Cause from "effect/Cause"; | |
| 28 | import * as Deferred from "effect/Deferred"; | |
| 27 | 29 | import * as Effect from "effect/Effect"; |
| 28 | 30 | import * as Exit from "effect/Exit"; |
| 29 | 31 | import * as Fiber from "effect/Fiber"; |
| ... | ... | @@ -1531,6 +1533,67 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1531 | 1533 | }), |
| 1532 | 1534 | ); |
| 1533 | 1535 | |
| 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 | ||
| 1534 | 1597 | it.effect("reuses persisted resume cursor when startSession is called after a restart", () => |
| 1535 | 1598 | Effect.gen(function* () { |
| 1536 | 1599 | const tempDir = NodeFS.mkdtempSync( |
apps/server/src/provider/OpenCodeServerOwner.test.ts created+285| ... | ... | @@ -0,0 +1,285 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import * as Deferred from "effect/Deferred"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Ref from "effect/Ref"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | import { expect } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { | |
| 11 | OpenCodeRuntime, | |
| 12 | OpenCodeRuntimeError, | |
| 13 | type OpenCodeRuntimeShape, | |
| 14 | } from "./opencodeRuntime.ts"; | |
| 15 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | |
| 16 | ||
| 17 | const unusedRuntimeMethod = () => | |
| 18 | Effect.fail( | |
| 19 | new OpenCodeRuntimeError({ | |
| 20 | operation: "unused", | |
| 21 | detail: "unused test method", | |
| 22 | }), | |
| 23 | ); | |
| 24 | ||
| 25 | const makeRuntime = Effect.gen(function* () { | |
| 26 | const starts = yield* Ref.make(0); | |
| 27 | const closes = yield* Ref.make(0); | |
| 28 | const failNextStart = yield* Ref.make(false); | |
| 29 | const started = yield* Deferred.make<void>(); | |
| 30 | const closed = yield* Deferred.make<void>(); | |
| 31 | const runtime: OpenCodeRuntimeShape = { | |
| 32 | startOpenCodeServerProcess: () => | |
| 33 | Effect.gen(function* () { | |
| 34 | if (yield* Ref.getAndSet(failNextStart, false)) { | |
| 35 | return yield* new OpenCodeRuntimeError({ | |
| 36 | operation: "startOpenCodeServerProcess", | |
| 37 | detail: "start failed", | |
| 38 | }); | |
| 39 | } | |
| 40 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 41 | yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); | |
| 42 | yield* Effect.addFinalizer(() => | |
| 43 | Ref.update(closes, (count) => count + 1).pipe( | |
| 44 | Effect.andThen(Deferred.succeed(closed, undefined)), | |
| 45 | Effect.ignore, | |
| 46 | ), | |
| 47 | ); | |
| 48 | return { | |
| 49 | url: `http://127.0.0.1:${index}`, | |
| 50 | version: "1.14.19", | |
| 51 | isRunning: Effect.succeed(true), | |
| 52 | exitCode: Effect.never, | |
| 53 | }; | |
| 54 | }), | |
| 55 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 56 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 57 | createOpenCodeSdkClient: () => ({}) as never, | |
| 58 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 59 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 60 | }; | |
| 61 | return { runtime, starts, closes, failNextStart, started, closed }; | |
| 62 | }); | |
| 63 | ||
| 64 | it.effect("shares concurrent borrowers and closes after the idle TTL", () => | |
| 65 | Effect.gen(function* () { | |
| 66 | const testRuntime = yield* makeRuntime; | |
| 67 | const release = yield* Deferred.make<void>(); | |
| 68 | yield* Effect.scoped( | |
| 69 | Effect.gen(function* () { | |
| 70 | const owner = yield* OpenCodeServerOwner.make({ | |
| 71 | binaryPath: "opencode", | |
| 72 | directory: "/project", | |
| 73 | }); | |
| 74 | const useServer = owner.withServer((server) => | |
| 75 | Deferred.await(release).pipe(Effect.as(server.url)), | |
| 76 | ); | |
| 77 | const fibers = yield* Effect.all([useServer, useServer], { | |
| 78 | concurrency: "unbounded", | |
| 79 | }).pipe(Effect.forkChild); | |
| 80 | yield* Deferred.await(testRuntime.started); | |
| 81 | expect(yield* Ref.get(testRuntime.starts)).toBe(1); | |
| 82 | yield* Deferred.succeed(release, undefined); | |
| 83 | expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); | |
| 84 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 85 | yield* Deferred.await(testRuntime.closed); | |
| 86 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 87 | }), | |
| 88 | ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); | |
| 89 | }).pipe(Effect.provide(TestClock.layer())), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("retries a failed start and closes on owner scope shutdown", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | const testRuntime = yield* makeRuntime; | |
| 95 | yield* Ref.set(testRuntime.failNextStart, true); | |
| 96 | yield* Effect.scoped( | |
| 97 | Effect.gen(function* () { | |
| 98 | const owner = yield* OpenCodeServerOwner.make({ | |
| 99 | binaryPath: "opencode", | |
| 100 | directory: "/project", | |
| 101 | }); | |
| 102 | expect( | |
| 103 | (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, | |
| 104 | ).toBe("Failure"); | |
| 105 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 106 | "http://127.0.0.1:1", | |
| 107 | ); | |
| 108 | }), | |
| 109 | ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); | |
| 110 | expect(yield* Ref.get(testRuntime.starts)).toBe(1); | |
| 111 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 112 | }), | |
| 113 | ); | |
| 114 | ||
| 115 | it.effect("invalidates an exited process so the next borrower starts a new one", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | const starts = yield* Ref.make(0); | |
| 118 | const processExits: Array<Deferred.Deferred<number>> = []; | |
| 119 | const processClosed = yield* Deferred.make<void>(); | |
| 120 | const runtime: OpenCodeRuntimeShape = { | |
| 121 | startOpenCodeServerProcess: () => | |
| 122 | Effect.gen(function* () { | |
| 123 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 124 | const exitCode = yield* Deferred.make<number>(); | |
| 125 | processExits.push(exitCode); | |
| 126 | yield* Effect.addFinalizer(() => | |
| 127 | Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), | |
| 128 | ); | |
| 129 | return { | |
| 130 | url: `http://127.0.0.1:${index}`, | |
| 131 | version: "1.14.19", | |
| 132 | isRunning: Effect.succeed(true), | |
| 133 | exitCode: Deferred.await(exitCode), | |
| 134 | }; | |
| 135 | }), | |
| 136 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 137 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 138 | createOpenCodeSdkClient: () => ({}) as never, | |
| 139 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 140 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 141 | }; | |
| 142 | ||
| 143 | yield* Effect.scoped( | |
| 144 | Effect.gen(function* () { | |
| 145 | const owner = yield* OpenCodeServerOwner.make({ | |
| 146 | binaryPath: "opencode", | |
| 147 | directory: "/project", | |
| 148 | }); | |
| 149 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 150 | "http://127.0.0.1:1", | |
| 151 | ); | |
| 152 | yield* Deferred.succeed(processExits[0]!, 1); | |
| 153 | yield* Deferred.await(processClosed); | |
| 154 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 155 | "http://127.0.0.1:2", | |
| 156 | ); | |
| 157 | }), | |
| 158 | ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); | |
| 159 | expect(yield* Ref.get(starts)).toBe(2); | |
| 160 | }), | |
| 161 | ); | |
| 162 | ||
| 163 | it.effect("replaces a dead cached process before its exit watcher runs", () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const starts = yield* Ref.make(0); | |
| 166 | const closes = yield* Ref.make(0); | |
| 167 | const processRunning: Array<Ref.Ref<boolean>> = []; | |
| 168 | const runtime: OpenCodeRuntimeShape = { | |
| 169 | startOpenCodeServerProcess: () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 172 | const isRunning = yield* Ref.make(true); | |
| 173 | processRunning.push(isRunning); | |
| 174 | yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); | |
| 175 | return { | |
| 176 | url: `http://127.0.0.1:${index}`, | |
| 177 | version: "1.14.19", | |
| 178 | isRunning: Ref.get(isRunning), | |
| 179 | exitCode: Effect.never, | |
| 180 | }; | |
| 181 | }), | |
| 182 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 183 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 184 | createOpenCodeSdkClient: () => ({}) as never, | |
| 185 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 186 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 187 | }; | |
| 188 | ||
| 189 | yield* Effect.scoped( | |
| 190 | Effect.gen(function* () { | |
| 191 | const owner = yield* OpenCodeServerOwner.make({ | |
| 192 | binaryPath: "opencode", | |
| 193 | directory: "/project", | |
| 194 | }); | |
| 195 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 196 | "http://127.0.0.1:1", | |
| 197 | ); | |
| 198 | yield* Ref.set(processRunning[0]!, false); | |
| 199 | ||
| 200 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 201 | "http://127.0.0.1:2", | |
| 202 | ); | |
| 203 | expect(yield* Ref.get(starts)).toBe(2); | |
| 204 | expect(yield* Ref.get(closes)).toBe(1); | |
| 205 | }), | |
| 206 | ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); | |
| 207 | }), | |
| 208 | ); | |
| 209 | ||
| 210 | it.effect("cleans up an interrupted startup and allows a retry", () => | |
| 211 | Effect.gen(function* () { | |
| 212 | const starts = yield* Ref.make(0); | |
| 213 | const firstStartEntered = yield* Deferred.make<void>(); | |
| 214 | const firstStartClosed = yield* Deferred.make<void>(); | |
| 215 | const runtime: OpenCodeRuntimeShape = { | |
| 216 | startOpenCodeServerProcess: () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const index = yield* Ref.updateAndGet(starts, (count) => count + 1); | |
| 219 | yield* Effect.addFinalizer(() => | |
| 220 | index === 1 | |
| 221 | ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) | |
| 222 | : Effect.void, | |
| 223 | ); | |
| 224 | if (index === 1) { | |
| 225 | yield* Deferred.succeed(firstStartEntered, undefined); | |
| 226 | return yield* Effect.never; | |
| 227 | } | |
| 228 | return { | |
| 229 | url: `http://127.0.0.1:${index}`, | |
| 230 | version: "1.14.19", | |
| 231 | isRunning: Effect.succeed(true), | |
| 232 | exitCode: Effect.never, | |
| 233 | }; | |
| 234 | }), | |
| 235 | connectToOpenCodeServer: unusedRuntimeMethod, | |
| 236 | runOpenCodeCommand: unusedRuntimeMethod, | |
| 237 | createOpenCodeSdkClient: () => ({}) as never, | |
| 238 | loadOpenCodeInventory: unusedRuntimeMethod, | |
| 239 | loadInventoryFromCli: unusedRuntimeMethod, | |
| 240 | }; | |
| 241 | ||
| 242 | yield* Effect.scoped( | |
| 243 | Effect.gen(function* () { | |
| 244 | const owner = yield* OpenCodeServerOwner.make({ | |
| 245 | binaryPath: "opencode", | |
| 246 | directory: "/project", | |
| 247 | }); | |
| 248 | const firstBorrower = yield* owner | |
| 249 | .withServer((server) => Effect.succeed(server.url)) | |
| 250 | .pipe(Effect.forkChild); | |
| 251 | yield* Deferred.await(firstStartEntered); | |
| 252 | yield* Fiber.interrupt(firstBorrower); | |
| 253 | yield* Deferred.await(firstStartClosed); | |
| 254 | expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( | |
| 255 | "http://127.0.0.1:2", | |
| 256 | ); | |
| 257 | }), | |
| 258 | ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); | |
| 259 | }), | |
| 260 | ); | |
| 261 | ||
| 262 | it.effect("releases an interrupted borrower and closes after the idle TTL", () => | |
| 263 | Effect.gen(function* () { | |
| 264 | const testRuntime = yield* makeRuntime; | |
| 265 | const borrowerEntered = yield* Deferred.make<void>(); | |
| 266 | yield* Effect.scoped( | |
| 267 | Effect.gen(function* () { | |
| 268 | const owner = yield* OpenCodeServerOwner.make({ | |
| 269 | binaryPath: "opencode", | |
| 270 | directory: "/project", | |
| 271 | }); | |
| 272 | const borrower = yield* owner | |
| 273 | .withServer(() => | |
| 274 | Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), | |
| 275 | ) | |
| 276 | .pipe(Effect.forkChild); | |
| 277 | yield* Deferred.await(borrowerEntered); | |
| 278 | yield* Fiber.interrupt(borrower); | |
| 279 | yield* TestClock.adjust(Duration.seconds(31)); | |
| 280 | yield* Deferred.await(testRuntime.closed); | |
| 281 | expect(yield* Ref.get(testRuntime.closes)).toBe(1); | |
| 282 | }), | |
| 283 | ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); | |
| 284 | }).pipe(Effect.provide(TestClock.layer())), | |
| 285 | ); |
apps/server/src/provider/OpenCodeServerOwner.ts created+184| ... | ... | @@ -0,0 +1,184 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Scope from "effect/Scope"; | |
| 7 | import * as Semaphore from "effect/Semaphore"; | |
| 8 | ||
| 9 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | |
| 10 | ||
| 11 | export const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; | |
| 12 | ||
| 13 | interface OpenCodeServerOwnerState { | |
| 14 | server: OpenCodeRuntime.OpenCodeServerProcess | null; | |
| 15 | serverScope: Scope.Closeable | null; | |
| 16 | borrowers: number; | |
| 17 | idleCloseFiber: Fiber.Fiber<void, never> | null; | |
| 18 | } | |
| 19 | ||
| 20 | export class OpenCodeServerOwner extends Context.Service< | |
| 21 | OpenCodeServerOwner, | |
| 22 | { | |
| 23 | readonly withServer: <A, E, R>( | |
| 24 | use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>, | |
| 25 | ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>; | |
| 26 | } | |
| 27 | >()("t3/provider/OpenCodeServerOwner") {} | |
| 28 | ||
| 29 | /** Owns the lazy local OpenCode server shared by one provider instance. */ | |
| 30 | export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { | |
| 31 | readonly binaryPath: string; | |
| 32 | readonly directory: string; | |
| 33 | readonly serverPassword?: string; | |
| 34 | readonly environment?: NodeJS.ProcessEnv; | |
| 35 | }) { | |
| 36 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; | |
| 37 | const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 38 | Scope.close(scope, Exit.void), | |
| 39 | ); | |
| 40 | const mutex = yield* Semaphore.make(1); | |
| 41 | const state: OpenCodeServerOwnerState = { | |
| 42 | server: null, | |
| 43 | serverScope: null, | |
| 44 | borrowers: 0, | |
| 45 | idleCloseFiber: null, | |
| 46 | }; | |
| 47 | ||
| 48 | const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { | |
| 49 | const fiber = state.idleCloseFiber; | |
| 50 | state.idleCloseFiber = null; | |
| 51 | if (fiber !== null) { | |
| 52 | yield* Fiber.interrupt(fiber).pipe(Effect.ignore); | |
| 53 | } | |
| 54 | }); | |
| 55 | ||
| 56 | const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( | |
| 57 | expected?: OpenCodeRuntime.OpenCodeServerProcess, | |
| 58 | ) { | |
| 59 | if (expected !== undefined && state.server !== expected) { | |
| 60 | return; | |
| 61 | } | |
| 62 | const scope = state.serverScope; | |
| 63 | state.server = null; | |
| 64 | state.serverScope = null; | |
| 65 | if (scope !== null) { | |
| 66 | yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); | |
| 67 | } | |
| 68 | }); | |
| 69 | ||
| 70 | const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( | |
| 71 | server: OpenCodeRuntime.OpenCodeServerProcess, | |
| 72 | ) { | |
| 73 | yield* server.exitCode; | |
| 74 | yield* mutex.withPermit( | |
| 75 | Effect.gen(function* () { | |
| 76 | if (state.server !== server) { | |
| 77 | return; | |
| 78 | } | |
| 79 | yield* cancelIdleClose(); | |
| 80 | yield* closeServer(server); | |
| 81 | }), | |
| 82 | ); | |
| 83 | }); | |
| 84 | ||
| 85 | const acquireServer = mutex.withPermit( | |
| 86 | Effect.gen(function* () { | |
| 87 | yield* cancelIdleClose(); | |
| 88 | if (state.server !== null) { | |
| 89 | if (yield* state.server.isRunning) { | |
| 90 | state.borrowers += 1; | |
| 91 | return state.server; | |
| 92 | } | |
| 93 | yield* closeServer(state.server); | |
| 94 | } | |
| 95 | ||
| 96 | return yield* Effect.uninterruptibleMask((restore) => | |
| 97 | Effect.gen(function* () { | |
| 98 | const serverScope = yield* Scope.make(); | |
| 99 | const started = yield* Effect.exit( | |
| 100 | restore( | |
| 101 | runtime | |
| 102 | .startOpenCodeServerProcess({ | |
| 103 | binaryPath: input.binaryPath, | |
| 104 | directory: input.directory, | |
| 105 | ...(input.serverPassword !== undefined | |
| 106 | ? { serverPassword: input.serverPassword } | |
| 107 | : {}), | |
| 108 | ...(input.environment ? { environment: input.environment } : {}), | |
| 109 | }) | |
| 110 | .pipe(Effect.provideService(Scope.Scope, serverScope)), | |
| 111 | ), | |
| 112 | ); | |
| 113 | if (Exit.isFailure(started)) { | |
| 114 | yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); | |
| 115 | return yield* Effect.failCause(started.cause); | |
| 116 | } | |
| 117 | ||
| 118 | const server = started.value; | |
| 119 | state.server = server; | |
| 120 | state.serverScope = serverScope; | |
| 121 | state.borrowers = 1; | |
| 122 | yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); | |
| 123 | return server; | |
| 124 | }), | |
| 125 | ); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => | |
| 130 | mutex.withPermit( | |
| 131 | Effect.gen(function* () { | |
| 132 | if (state.server !== server) { | |
| 133 | return; | |
| 134 | } | |
| 135 | state.borrowers = Math.max(0, state.borrowers - 1); | |
| 136 | if (state.borrowers > 0) { | |
| 137 | return; | |
| 138 | } | |
| 139 | yield* cancelIdleClose(); | |
| 140 | state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( | |
| 141 | Effect.andThen( | |
| 142 | mutex.withPermit( | |
| 143 | Effect.gen(function* () { | |
| 144 | if (state.server !== server || state.borrowers > 0) { | |
| 145 | return; | |
| 146 | } | |
| 147 | state.idleCloseFiber = null; | |
| 148 | yield* closeServer(server); | |
| 149 | }), | |
| 150 | ), | |
| 151 | ), | |
| 152 | Effect.forkIn(ownerScope), | |
| 153 | ); | |
| 154 | }), | |
| 155 | ); | |
| 156 | ||
| 157 | yield* Effect.addFinalizer(() => | |
| 158 | mutex.withPermit( | |
| 159 | Effect.gen(function* () { | |
| 160 | yield* cancelIdleClose(); | |
| 161 | state.borrowers = 0; | |
| 162 | yield* closeServer(); | |
| 163 | }), | |
| 164 | ), | |
| 165 | ); | |
| 166 | ||
| 167 | return OpenCodeServerOwner.of({ | |
| 168 | withServer: (use) => | |
| 169 | Effect.uninterruptibleMask((restore) => | |
| 170 | restore(acquireServer).pipe( | |
| 171 | Effect.flatMap((server) => | |
| 172 | restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), | |
| 173 | ), | |
| 174 | ), | |
| 175 | ), | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | export const layer = (input: { | |
| 180 | readonly binaryPath: string; | |
| 181 | readonly directory: string; | |
| 182 | readonly serverPassword?: string; | |
| 183 | readonly environment?: NodeJS.ProcessEnv; | |
| 184 | }) => Layer.effect(OpenCodeServerOwner, make(input)); |
apps/server/src/provider/makeManagedServerProvider.test.ts+69| ... | ... | @@ -250,6 +250,40 @@ describe("makeManagedServerProvider", () => { |
| 250 | 250 | ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), |
| 251 | 251 | ); |
| 252 | 252 | |
| 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 | ||
| 253 | 287 | it.effect("wakes a sleeping provider refresh loop when its interval changes", () => |
| 254 | 288 | Effect.scoped( |
| 255 | 289 | Effect.gen(function* () { |
| ... | ... | @@ -355,6 +389,41 @@ describe("makeManagedServerProvider", () => { |
| 355 | 389 | ).pipe(Effect.provide(AlwaysRunTestLayer)), |
| 356 | 390 | ); |
| 357 | 391 | |
| 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 | ||
| 358 | 427 | it.effect("streams supplemental snapshot updates after the base provider check completes", () => |
| 359 | 428 | Effect.scoped( |
| 360 | 429 | Effect.gen(function* () { |
apps/server/src/provider/makeManagedServerProvider.ts+20-1| ... | ... | @@ -40,6 +40,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 40 | 40 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; |
| 41 | 41 | }) => Effect.Effect<void>; |
| 42 | 42 | readonly refreshInterval?: Duration.Input; |
| 43 | readonly refreshOnInterval?: boolean; | |
| 44 | readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; | |
| 43 | 45 | }): Effect.fn.Return< |
| 44 | 46 | ServerProviderShape, |
| 45 | 47 | ServerSettingsError, |
| ... | ... | @@ -121,6 +123,21 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 121 | 123 | return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); |
| 122 | 124 | } |
| 123 | 125 | |
| 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 | ||
| 124 | 141 | const nextSnapshot = yield* input.checkProvider; |
| 125 | 142 | const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { |
| 126 | 143 | const generation = input.enrichSnapshot |
| ... | ... | @@ -199,7 +216,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 199 | 216 | Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), |
| 200 | 217 | ).pipe( |
| 201 | 218 | Effect.flatMap((intervalElapsed) => |
| 202 | intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 | |
| 219 | input.refreshOnInterval !== false && | |
| 220 | intervalElapsed && | |
| 221 | Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 | |
| 203 | 222 | ? hasProviderStatusDemand.pipe( |
| 204 | 223 | Effect.flatMap((shouldRefresh) => |
| 205 | 224 | shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, |
apps/server/src/provider/opencodeRuntime.environment.test.ts+130-1| ... | ... | @@ -1,6 +1,16 @@ |
| 1 | import type { OpencodeClient } from "@opencode-ai/sdk/v2"; | |
| 2 | import { it as effectIt } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as TestClock from "effect/testing/TestClock"; | |
| 1 | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 7 | |
| 3 | import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; | |
| 8 | import { | |
| 9 | OpenCodeRuntimeError, | |
| 10 | resolveOpenCodeConfigContent, | |
| 11 | resolveOpenCodeServerPassword, | |
| 12 | verifyOpenCodeServerVersion, | |
| 13 | } from "./opencodeRuntime.ts"; | |
| 4 | 14 | |
| 5 | 15 | describe("resolveOpenCodeConfigContent", () => { |
| 6 | 16 | it("prefers the caller environment over the inherited environment", () => { |
| ... | ... | @@ -21,3 +31,122 @@ describe("resolveOpenCodeConfigContent", () => { |
| 21 | 31 | expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); |
| 22 | 32 | }); |
| 23 | 33 | }); |
| 34 | ||
| 35 | describe("resolveOpenCodeServerPassword", () => { | |
| 36 | it("uses the local environment password when settings do not provide one", () => { | |
| 37 | expect( | |
| 38 | resolveOpenCodeServerPassword( | |
| 39 | { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, | |
| 40 | {}, | |
| 41 | ), | |
| 42 | ).toBe(" env password "); | |
| 43 | }); | |
| 44 | ||
| 45 | it("uses the settings password for a local server", () => { | |
| 46 | expect( | |
| 47 | resolveOpenCodeServerPassword({ external: false, serverPassword: " settings password " }, {}), | |
| 48 | ).toBe(" settings password "); | |
| 49 | }); | |
| 50 | ||
| 51 | it("uses the settings password when local settings and environment differ", () => { | |
| 52 | expect( | |
| 53 | resolveOpenCodeServerPassword( | |
| 54 | { | |
| 55 | external: false, | |
| 56 | serverPassword: "settings-password", | |
| 57 | environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, | |
| 58 | }, | |
| 59 | {}, | |
| 60 | ), | |
| 61 | ).toBe("settings-password"); | |
| 62 | }); | |
| 63 | ||
| 64 | it("does not send an inherited local password to an external server", () => { | |
| 65 | expect( | |
| 66 | resolveOpenCodeServerPassword( | |
| 67 | { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, | |
| 68 | { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, | |
| 69 | ), | |
| 70 | ).toBeUndefined(); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | function makeHealthClient( | |
| 75 | result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>, | |
| 76 | ): OpencodeClient { | |
| 77 | return { | |
| 78 | global: { | |
| 79 | health: result, | |
| 80 | }, | |
| 81 | } as unknown as OpencodeClient; | |
| 82 | } | |
| 83 | ||
| 84 | describe("verifyOpenCodeServerVersion", () => { | |
| 85 | effectIt.effect("accepts a supported server version", () => | |
| 86 | Effect.gen(function* () { | |
| 87 | const version = yield* verifyOpenCodeServerVersion( | |
| 88 | makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), | |
| 89 | ); | |
| 90 | expect(version).toBe("1.14.19"); | |
| 91 | }), | |
| 92 | ); | |
| 93 | ||
| 94 | effectIt.effect("rejects a server below the supported version", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const error = yield* verifyOpenCodeServerVersion( | |
| 97 | makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), | |
| 98 | ).pipe(Effect.flip); | |
| 99 | expect(error).toBeInstanceOf(OpenCodeRuntimeError); | |
| 100 | expect(error.detail).toContain("v1.14.18 is too old"); | |
| 101 | }), | |
| 102 | ); | |
| 103 | ||
| 104 | for (const data of [ | |
| 105 | { healthy: true }, | |
| 106 | { healthy: true, version: "not-a-version" }, | |
| 107 | { healthy: false, version: "1.14.19" }, | |
| 108 | ]) { | |
| 109 | effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const error = yield* verifyOpenCodeServerVersion( | |
| 112 | makeHealthClient(() => Promise.resolve({ data })), | |
| 113 | ).pipe(Effect.flip); | |
| 114 | expect(error).toBeInstanceOf(OpenCodeRuntimeError); | |
| 115 | expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); | |
| 116 | }), | |
| 117 | ); | |
| 118 | } | |
| 119 | ||
| 120 | effectIt.effect("preserves an unauthorized health error", () => | |
| 121 | Effect.gen(function* () { | |
| 122 | const error = yield* verifyOpenCodeServerVersion( | |
| 123 | makeHealthClient(() => | |
| 124 | Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), | |
| 125 | ), | |
| 126 | ).pipe(Effect.flip); | |
| 127 | expect(error).toBeInstanceOf(OpenCodeRuntimeError); | |
| 128 | expect(error.detail).toContain("status=401"); | |
| 129 | expect(error.detail).toContain("Unauthorized"); | |
| 130 | }), | |
| 131 | ); | |
| 132 | ||
| 133 | effectIt.effect("aborts a health request when the version check times out", () => | |
| 134 | Effect.gen(function* () { | |
| 135 | let requestSignal: AbortSignal | undefined; | |
| 136 | const checkFiber = yield* verifyOpenCodeServerVersion( | |
| 137 | makeHealthClient((options) => { | |
| 138 | requestSignal = options?.signal; | |
| 139 | return new Promise(() => undefined); | |
| 140 | }), | |
| 141 | ).pipe(Effect.flip, Effect.forkChild); | |
| 142 | ||
| 143 | yield* Effect.yieldNow; | |
| 144 | expect(requestSignal).toBeDefined(); | |
| 145 | yield* TestClock.adjust("6 seconds"); | |
| 146 | ||
| 147 | const error = yield* Fiber.join(checkFiber); | |
| 148 | expect(error.detail).toBe("Timed out while checking the OpenCode server version."); | |
| 149 | expect(requestSignal?.aborted).toBe(true); | |
| 150 | }).pipe(Effect.provide(TestClock.layer())), | |
| 151 | ); | |
| 152 | }); |
apps/server/src/provider/opencodeRuntime.inventory.test.ts+28| ... | ... | @@ -18,6 +18,34 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; |
| 18 | 18 | const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); |
| 19 | 19 | |
| 20 | 20 | it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { |
| 21 | it.effect("keeps provider inventory when agent discovery fails", () => | |
| 22 | Effect.gen(function* () { | |
| 23 | const runtime = yield* OpenCodeRuntime; | |
| 24 | const client = { | |
| 25 | provider: { | |
| 26 | list: () => | |
| 27 | Promise.resolve({ | |
| 28 | data: { | |
| 29 | connected: ["openai"], | |
| 30 | all: [], | |
| 31 | default: {}, | |
| 32 | }, | |
| 33 | }), | |
| 34 | }, | |
| 35 | app: { | |
| 36 | agents: () => Promise.reject(new Error("agents endpoint unavailable")), | |
| 37 | skills: () => Promise.resolve({ data: [] }), | |
| 38 | }, | |
| 39 | } as unknown as OpencodeClient; | |
| 40 | ||
| 41 | const inventory = yield* runtime.loadOpenCodeInventory(client); | |
| 42 | ||
| 43 | NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); | |
| 44 | NodeAssert.deepEqual(inventory.agents, []); | |
| 45 | NodeAssert.deepEqual(inventory.skills, []); | |
| 46 | }), | |
| 47 | ); | |
| 48 | ||
| 21 | 49 | it.effect("keeps provider inventory when skill discovery fails", () => |
| 22 | 50 | Effect.gen(function* () { |
| 23 | 51 | const runtime = yield* OpenCodeRuntime; |
apps/server/src/provider/opencodeRuntime.permissions.test.ts+1| ... | ... | @@ -39,6 +39,7 @@ describe("buildOpenCodePermissionRules", () => { |
| 39 | 39 | it("allows everything only under full access", () => { |
| 40 | 40 | NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ |
| 41 | 41 | { permission: "*", pattern: "*", action: "allow" }, |
| 42 | { permission: "external_directory", pattern: "*", action: "allow" }, | |
| 42 | 43 | ]); |
| 43 | 44 | }); |
| 44 | 45 | }); |
apps/server/src/provider/opencodeRuntime.ts+136-21| ... | ... | @@ -33,10 +33,20 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 33 | 33 | import { collectStreamAsString } from "./providerSnapshot.ts"; |
| 34 | 34 | import * as NetService from "@t3tools/shared/Net"; |
| 35 | 35 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 36 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | |
| 36 | 37 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 37 | 38 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); |
| 38 | 39 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; |
| 39 | 40 | |
| 41 | export const MINIMUM_OPENCODE_VERSION = "1.14.19"; | |
| 42 | const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; | |
| 43 | ||
| 44 | const OpenCodeHealthSchema = Schema.Struct({ | |
| 45 | healthy: Schema.Literal(true), | |
| 46 | version: Schema.String, | |
| 47 | }); | |
| 48 | const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); | |
| 49 | ||
| 40 | 50 | export function resolveOpenCodeConfigContent( |
| 41 | 51 | inputEnvironment: Readonly<Record<string, string | undefined>> | undefined, |
| 42 | 52 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, |
| ... | ... | @@ -48,17 +58,41 @@ export function resolveOpenCodeConfigContent( |
| 48 | 58 | ); |
| 49 | 59 | } |
| 50 | 60 | |
| 61 | export function resolveOpenCodeServerPassword( | |
| 62 | input: { | |
| 63 | readonly external: boolean; | |
| 64 | readonly serverPassword?: string; | |
| 65 | readonly environment?: Readonly<Record<string, string | undefined>>; | |
| 66 | }, | |
| 67 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, | |
| 68 | ): string | undefined { | |
| 69 | if (input.serverPassword !== undefined) { | |
| 70 | return input.serverPassword; | |
| 71 | } | |
| 72 | if (input.external) { | |
| 73 | return undefined; | |
| 74 | } | |
| 75 | return input.environment === undefined | |
| 76 | ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD | |
| 77 | : input.environment.OPENCODE_SERVER_PASSWORD; | |
| 78 | } | |
| 79 | ||
| 51 | 80 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; |
| 52 | 81 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; |
| 53 | 82 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 54 | 83 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| 55 | 84 | export interface OpenCodeServerProcess { |
| 56 | 85 | readonly url: string; |
| 86 | readonly serverPassword?: string; | |
| 87 | readonly version: string; | |
| 88 | readonly isRunning: Effect.Effect<boolean>; | |
| 57 | 89 | readonly exitCode: Effect.Effect<number, never>; |
| 58 | 90 | } |
| 59 | 91 | |
| 60 | 92 | export interface OpenCodeServerConnection { |
| 61 | 93 | readonly url: string; |
| 94 | readonly serverPassword?: string; | |
| 95 | readonly version: string; | |
| 62 | 96 | readonly exitCode: Effect.Effect<number, never> | null; |
| 63 | 97 | readonly external: boolean; |
| 64 | 98 | } |
| ... | ... | @@ -96,7 +130,7 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { |
| 96 | 130 | |
| 97 | 131 | export const runOpenCodeSdk = <A>( |
| 98 | 132 | operation: string, |
| 99 | fn: () => Promise<A>, | |
| 133 | fn: (signal: AbortSignal) => Promise<A>, | |
| 100 | 134 | ): Effect.Effect<A, OpenCodeRuntimeError> => |
| 101 | 135 | Effect.tryPromise({ |
| 102 | 136 | try: fn, |
| ... | ... | @@ -104,6 +138,44 @@ export const runOpenCodeSdk = <A>( |
| 104 | 138 | new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), |
| 105 | 139 | }).pipe(Effect.withSpan(`opencode.${operation}`)); |
| 106 | 140 | |
| 141 | export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( | |
| 142 | client: OpencodeClient, | |
| 143 | ) { | |
| 144 | const healthOption = yield* runOpenCodeSdk("global.health", (signal) => | |
| 145 | client.global.health({ signal }), | |
| 146 | ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); | |
| 147 | if (Option.isNone(healthOption)) { | |
| 148 | return yield* new OpenCodeRuntimeError({ | |
| 149 | operation: "global.health", | |
| 150 | detail: "Timed out while checking the OpenCode server version.", | |
| 151 | }); | |
| 152 | } | |
| 153 | ||
| 154 | const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( | |
| 155 | Effect.mapError( | |
| 156 | (cause) => | |
| 157 | new OpenCodeRuntimeError({ | |
| 158 | operation: "global.health", | |
| 159 | detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 160 | cause, | |
| 161 | }), | |
| 162 | ), | |
| 163 | ); | |
| 164 | if (parseSemver(health.version) === null) { | |
| 165 | return yield* new OpenCodeRuntimeError({ | |
| 166 | operation: "global.health", | |
| 167 | detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 168 | }); | |
| 169 | } | |
| 170 | if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { | |
| 171 | return yield* new OpenCodeRuntimeError({ | |
| 172 | operation: "global.health", | |
| 173 | detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, | |
| 174 | }); | |
| 175 | } | |
| 176 | return health.version; | |
| 177 | }); | |
| 178 | ||
| 107 | 179 | export interface OpenCodeCommandResult { |
| 108 | 180 | readonly stdout: string; |
| 109 | 181 | readonly stderr: string; |
| ... | ... | @@ -145,6 +217,8 @@ export interface OpenCodeRuntimeShape { |
| 145 | 217 | */ |
| 146 | 218 | readonly startOpenCodeServerProcess: (input: { |
| 147 | 219 | readonly binaryPath: string; |
| 220 | readonly directory: string; | |
| 221 | readonly serverPassword?: string; | |
| 148 | 222 | readonly environment?: NodeJS.ProcessEnv; |
| 149 | 223 | readonly port?: number; |
| 150 | 224 | readonly hostname?: string; |
| ... | ... | @@ -157,7 +231,9 @@ export interface OpenCodeRuntimeShape { |
| 157 | 231 | */ |
| 158 | 232 | readonly connectToOpenCodeServer: (input: { |
| 159 | 233 | readonly binaryPath: string; |
| 234 | readonly directory: string; | |
| 160 | 235 | readonly serverUrl?: string | null; |
| 236 | readonly serverPassword?: string; | |
| 161 | 237 | readonly environment?: NodeJS.ProcessEnv; |
| 162 | 238 | readonly port?: number; |
| 163 | 239 | readonly hostname?: string; |
| ... | ... | @@ -398,7 +474,10 @@ export function toOpenCodeFileParts(input: { |
| 398 | 474 | |
| 399 | 475 | export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { |
| 400 | 476 | 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 | ]; | |
| 402 | 481 | } |
| 403 | 482 | |
| 404 | 483 | // "Auto-accept edits" is documented as "auto-approve edits, ask before other |
| ... | ... | @@ -514,6 +593,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 514 | 593 | ), |
| 515 | 594 | ); |
| 516 | 595 | |
| 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 | ||
| 517 | 610 | const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => |
| 518 | 611 | Effect.gen(function* () { |
| 519 | 612 | // Bind this server's lifetime to the caller's scope. When the caller's |
| ... | ... | @@ -537,6 +630,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 537 | 630 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; |
| 538 | 631 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; |
| 539 | 632 | 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 | }); | |
| 540 | 638 | |
| 541 | 639 | const child = yield* spawner |
| 542 | 640 | .spawn( |
| ... | ... | @@ -545,6 +643,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 545 | 643 | shell: spawnCommand.shell, |
| 546 | 644 | env: { |
| 547 | 645 | ...input.environment, |
| 646 | ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), | |
| 548 | 647 | // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or |
| 549 | 648 | // the inherited process environment, only falling back to the |
| 550 | 649 | // empty config when neither is set. Setting it unconditionally |
| ... | ... | @@ -670,8 +769,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 670 | 769 | }); |
| 671 | 770 | } |
| 672 | 771 | |
| 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 | ||
| 673 | 781 | return { |
| 674 | url: readyOption.value, | |
| 782 | url, | |
| 783 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 784 | version, | |
| 785 | isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), | |
| 675 | 786 | exitCode: child.exitCode.pipe( |
| 676 | 787 | Effect.map(Number), |
| 677 | 788 | Effect.orElseSucceed(() => 0), |
| ... | ... | @@ -682,16 +793,31 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 682 | 793 | const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { |
| 683 | 794 | const serverUrl = input.serverUrl?.trim(); |
| 684 | 795 | 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({ | |
| 689 | 797 | external: true, |
| 798 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 690 | 799 | }); |
| 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 | ); | |
| 691 | 815 | } |
| 692 | 816 | |
| 693 | 817 | return startOpenCodeServerProcess({ |
| 694 | 818 | binaryPath: input.binaryPath, |
| 819 | directory: input.directory, | |
| 820 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), | |
| 695 | 821 | ...(input.environment !== undefined ? { environment: input.environment } : {}), |
| 696 | 822 | ...(input.port !== undefined ? { port: input.port } : {}), |
| 697 | 823 | ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), |
| ... | ... | @@ -699,26 +825,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 699 | 825 | }).pipe( |
| 700 | 826 | Effect.map((server) => ({ |
| 701 | 827 | url: server.url, |
| 828 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 829 | version: server.version, | |
| 702 | 830 | exitCode: server.exitCode, |
| 703 | 831 | external: false, |
| 704 | 832 | })), |
| 705 | 833 | ); |
| 706 | 834 | }; |
| 707 | 835 | |
| 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 | ||
| 722 | 836 | const loadProviders = (client: OpencodeClient) => |
| 723 | 837 | runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( |
| 724 | 838 | Effect.filterMapOrFail( |
| ... | ... | @@ -738,6 +852,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 738 | 852 | const loadAgents = (client: OpencodeClient) => |
| 739 | 853 | runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( |
| 740 | 854 | Effect.map((result) => result.data ?? []), |
| 855 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), | |
| 741 | 856 | ); |
| 742 | 857 | |
| 743 | 858 | const loadSkills = (client: OpencodeClient) => |
apps/server/src/server.test.ts+46| ... | ... | @@ -57,6 +57,7 @@ import * as Option from "effect/Option"; |
| 57 | 57 | import * as Path from "effect/Path"; |
| 58 | 58 | import * as PubSub from "effect/PubSub"; |
| 59 | 59 | import * as Queue from "effect/Queue"; |
| 60 | import * as Ref from "effect/Ref"; | |
| 60 | 61 | import * as Schema from "effect/Schema"; |
| 61 | 62 | import * as Stream from "effect/Stream"; |
| 62 | 63 | import * as TestClock from "effect/testing/TestClock"; |
| ... | ... | @@ -4918,6 +4919,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4918 | 4919 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4919 | 4920 | ); |
| 4920 | 4921 | |
| 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 | ||
| 4921 | 4967 | it.effect("routes websocket resource telemetry through the subscription", () => |
| 4922 | 4968 | Effect.gen(function* () { |
| 4923 | 4969 | yield* buildAppUnderTest(); |
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+124-8| ... | ... | @@ -11,6 +11,7 @@ import { beforeEach, expect } from "vite-plus/test"; |
| 11 | 11 | |
| 12 | 12 | import * as ServerConfig from "../config.ts"; |
| 13 | 13 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 14 | import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; | |
| 14 | 15 | import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; |
| 15 | 16 | import * as TextGeneration from "./TextGeneration.ts"; |
| 16 | 17 | |
| ... | ... | @@ -21,6 +22,8 @@ const runtimeMock = { |
| 21 | 22 | promptParts: [] as ReadonlyArray<unknown>[], |
| 22 | 23 | authHeaders: [] as Array<string | null>, |
| 23 | 24 | closeCalls: [] as string[], |
| 25 | sessionCreateCalls: 0, | |
| 26 | connectionError: undefined as Error | undefined, | |
| 24 | 27 | sessionCreateError: undefined as unknown, |
| 25 | 28 | sessionResult: undefined as { data?: { id: string } } | undefined, |
| 26 | 29 | promptRequestError: undefined as unknown, |
| ... | ... | @@ -34,6 +37,8 @@ const runtimeMock = { |
| 34 | 37 | this.state.promptParts.length = 0; |
| 35 | 38 | this.state.authHeaders.length = 0; |
| 36 | 39 | this.state.closeCalls.length = 0; |
| 40 | this.state.sessionCreateCalls = 0; | |
| 41 | this.state.connectionError = undefined; | |
| 37 | 42 | this.state.sessionCreateError = undefined; |
| 38 | 43 | this.state.sessionResult = undefined; |
| 39 | 44 | this.state.promptRequestError = undefined; |
| ... | ... | @@ -42,7 +47,7 @@ const runtimeMock = { |
| 42 | 47 | }; |
| 43 | 48 | |
| 44 | 49 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 45 | startOpenCodeServerProcess: ({ binaryPath }) => | |
| 50 | startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => | |
| 46 | 51 | Effect.gen(function* () { |
| 47 | 52 | const index = runtimeMock.state.startCalls.length + 1; |
| 48 | 53 | const url = `http://127.0.0.1:${4_300 + index}`; |
| ... | ... | @@ -54,22 +59,43 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { |
| 54 | 59 | runtimeMock.state.closeCalls.push(url); |
| 55 | 60 | }), |
| 56 | 61 | ); |
| 62 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | |
| 63 | external: false, | |
| 64 | ...(serverPassword !== undefined ? { serverPassword } : {}), | |
| 65 | ...(environment !== undefined ? { environment } : {}), | |
| 66 | }); | |
| 57 | 67 | return { |
| 58 | 68 | url, |
| 69 | ...(effectiveServerPassword !== undefined | |
| 70 | ? { serverPassword: effectiveServerPassword } | |
| 71 | : {}), | |
| 72 | version: "1.14.19", | |
| 73 | isRunning: Effect.succeed(true), | |
| 59 | 74 | exitCode: Effect.never, |
| 60 | 75 | }; |
| 61 | 76 | }), |
| 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 | }), | |
| 68 | 93 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), |
| 69 | 94 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => |
| 70 | 95 | ({ |
| 71 | 96 | session: { |
| 72 | 97 | create: async () => { |
| 98 | runtimeMock.state.sessionCreateCalls += 1; | |
| 73 | 99 | if (runtimeMock.state.sessionCreateError !== undefined) { |
| 74 | 100 | throw runtimeMock.state.sessionCreateError; |
| 75 | 101 | } |
| ... | ... | @@ -163,18 +189,35 @@ const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( |
| 163 | 189 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 164 | 190 | binaryPath: "fake-opencode", |
| 165 | 191 | }); |
| 192 | const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 193 | binaryPath: "fake-opencode", | |
| 194 | serverPassword: "secret-password", | |
| 195 | }); | |
| 166 | 196 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ |
| 167 | 197 | binaryPath: "fake-opencode", |
| 168 | 198 | serverUrl: "http://127.0.0.1:9999", |
| 169 | 199 | serverPassword: "secret-password", |
| 170 | 200 | }); |
| 201 | const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | |
| 202 | binaryPath: "fake-opencode", | |
| 203 | serverUrl: "http://127.0.0.1:9999", | |
| 204 | }); | |
| 171 | 205 | |
| 172 | 206 | function withOpenCodeTextGeneration<A, E, R>( |
| 173 | 207 | settings: OpenCodeSettings, |
| 174 | 208 | effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>, |
| 209 | environment?: NodeJS.ProcessEnv, | |
| 175 | 210 | ) { |
| 176 | 211 | 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 | ); | |
| 178 | 221 | return yield* effectFn(textGeneration); |
| 179 | 222 | }).pipe(Effect.scoped); |
| 180 | 223 | } |
| ... | ... | @@ -229,6 +272,49 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { |
| 229 | 272 | ), |
| 230 | 273 | ); |
| 231 | 274 | |
| 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 | ||
| 232 | 318 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => |
| 233 | 319 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => |
| 234 | 320 | Effect.gen(function* () { |
| ... | ... | @@ -460,6 +546,36 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { |
| 460 | 546 | it.layer(OpenCodeTextGenerationExistingServerTestLayer)( |
| 461 | 547 | "OpenCodeTextGeneration with configured server URL", |
| 462 | 548 | (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 | ||
| 463 | 579 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => |
| 464 | 580 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => |
| 465 | 581 | Effect.gen(function* () { |
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+30-194| ... | ... | @@ -1,9 +1,5 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Exit from "effect/Exit"; | |
| 3 | import * as Fiber from "effect/Fiber"; | |
| 4 | 2 | import * as Schema from "effect/Schema"; |
| 5 | import * as Scope from "effect/Scope"; | |
| 6 | import * as Semaphore from "effect/Semaphore"; | |
| 7 | 3 | |
| 8 | 4 | import { |
| 9 | 5 | NonNegativeInt, |
| ... | ... | @@ -31,8 +27,7 @@ import { |
| 31 | 27 | sanitizeThreadTitle, |
| 32 | 28 | } from "./TextGenerationUtils.ts"; |
| 33 | 29 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 34 | ||
| 35 | const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; | |
| 30 | import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; | |
| 36 | 31 | |
| 37 | 32 | const OpenCodeTextGenerationOperation = Schema.Literals([ |
| 38 | 33 | "generateCommitMessage", |
| ... | ... | @@ -175,188 +170,12 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str |
| 175 | 170 | .trim(); |
| 176 | 171 | } |
| 177 | 172 | |
| 178 | interface SharedOpenCodeTextGenerationServerState { | |
| 179 | server: OpenCodeRuntime.OpenCodeServerProcess | null; | |
| 180 | /** | |
| 181 | * The scope that owns the shared server's lifetime. Closing this scope | |
| 182 | * terminates the OpenCode child process and interrupts any fibers the | |
| 183 | * runtime forked during startup. We don't hold a `close()` function on | |
| 184 | * the server handle anymore — the scope is the only lifecycle handle. | |
| 185 | */ | |
| 186 | serverScope: Scope.Closeable | null; | |
| 187 | binaryPath: string | null; | |
| 188 | activeRequests: number; | |
| 189 | idleCloseFiber: Fiber.Fiber<void, never> | null; | |
| 190 | } | |
| 191 | ||
| 192 | 173 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( |
| 193 | 174 | openCodeSettings: OpenCodeSettings, |
| 194 | environment?: NodeJS.ProcessEnv, | |
| 195 | 175 | ) { |
| 196 | 176 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 197 | 177 | 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; | |
| 360 | 179 | |
| 361 | 180 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { |
| 362 | 181 | readonly operation: OpenCodeTextGenerationOperation; |
| ... | ... | @@ -381,13 +200,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 381 | 200 | }); |
| 382 | 201 | |
| 383 | 202 | 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 | ) { | |
| 385 | 209 | const client = openCodeRuntime.createOpenCodeSdkClient({ |
| 386 | 210 | baseUrl: server.url, |
| 387 | 211 | directory: input.cwd, |
| 388 | ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword | |
| 389 | ? { serverPassword: openCodeSettings.serverPassword } | |
| 390 | : {}), | |
| 212 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | |
| 391 | 213 | }); |
| 392 | 214 | const session = yield* Effect.tryPromise({ |
| 393 | 215 | try: () => |
| ... | ... | @@ -496,17 +318,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 496 | 318 | }), |
| 497 | 319 | ); |
| 498 | 320 | |
| 499 | const rawOutput = | |
| 321 | const serverOutput = | |
| 500 | 322 | openCodeSettings.serverUrl.length > 0 |
| 501 | ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) | |
| 502 | : yield* Effect.acquireUseRelease( | |
| 503 | acquireSharedServer({ | |
| 323 | ? openCodeRuntime | |
| 324 | .connectToOpenCodeServer({ | |
| 504 | 325 | 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({ | |
| 505 | 339 | operation: input.operation, |
| 340 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | |
| 341 | cause, | |
| 506 | 342 | }), |
| 507 | runAgainstServer, | |
| 508 | releaseSharedServer, | |
| 509 | ); | |
| 343 | ), | |
| 344 | }), | |
| 345 | ); | |
| 510 | 346 | |
| 511 | 347 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); |
| 512 | 348 | 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) |
| 997 | 997 | // Instance-keyed option list so the picker can show each configured |
| 998 | 998 | // instance (built-in + custom) as a first-class sidebar entry. The |
| 999 | 999 | // 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. | |
| 1001 | 1002 | const modelOptionsByInstance = useMemo< |
| 1002 | 1003 | ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>> |
| 1003 | 1004 | >(() => { |
| 1004 | 1005 | const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); |
| 1005 | 1006 | 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 | ); | |
| 1007 | 1015 | } |
| 1008 | 1016 | return out; |
| 1009 | }, [providerInstanceEntries, settings]); | |
| 1017 | }, [providerInstanceEntries, selectedInstanceId, selectedModelForPicker, settings]); | |
| 1010 | 1018 | const selectedModelForPickerWithCustomFallback = useMemo(() => { |
| 1011 | 1019 | const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; |
| 1012 | 1020 | return currentOptions.some((option) => option.slug === selectedModelForPicker) |
apps/web/src/components/chat/ModelListRow.tsx+7| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | } from "./providerIconUtils"; |
| 10 | 10 | import { ComboboxItem } from "../ui/combobox"; |
| 11 | 11 | import { Button } from "../ui/button"; |
| 12 | import { Badge } from "../ui/badge"; | |
| 12 | 13 | import { Kbd } from "../ui/kbd"; |
| 13 | 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | 15 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -34,6 +35,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 34 | 35 | preferShortName?: boolean; |
| 35 | 36 | useTriggerLabel?: boolean; |
| 36 | 37 | showNewBadge?: boolean; |
| 38 | unavailable?: boolean; | |
| 37 | 39 | jumpLabel?: string | null; |
| 38 | 40 | disabledReason?: string | null; |
| 39 | 41 | onToggleFavorite: () => void; |
| ... | ... | @@ -75,6 +77,11 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 75 | 77 | New |
| 76 | 78 | </span> |
| 77 | 79 | ) : null} |
| 80 | {props.unavailable ? ( | |
| 81 | <Badge variant="outline" size="sm"> | |
| 82 | Unavailable | |
| 83 | </Badge> | |
| 84 | ) : null} | |
| 78 | 85 | </div> |
| 79 | 86 | {props.showProvider && ( |
| 80 | 87 | <div className="mt-1 flex items-center gap-1.5"> |
apps/web/src/components/chat/ModelPickerContent.test.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { deriveProviderInstanceEntries } from "../../providerInstances"; | |
| 5 | import { shouldIncludeModelPickerOption } from "./ModelPickerContent"; | |
| 6 | ||
| 7 | function entry(status: ServerProvider["status"]) { | |
| 8 | return deriveProviderInstanceEntries([ | |
| 9 | { | |
| 10 | instanceId: ProviderInstanceId.make("opencode_work"), | |
| 11 | driver: ProviderDriverKind.make("opencode"), | |
| 12 | enabled: true, | |
| 13 | installed: true, | |
| 14 | version: null, | |
| 15 | status, | |
| 16 | auth: { status: "authenticated" }, | |
| 17 | checkedAt: "2026-08-28T00:00:00.000Z", | |
| 18 | models: [], | |
| 19 | slashCommands: [], | |
| 20 | skills: [], | |
| 21 | }, | |
| 22 | ])[0]!; | |
| 23 | } | |
| 24 | ||
| 25 | describe("shouldIncludeModelPickerOption", () => { | |
| 26 | it.each(["error", "warning"] as const)( | |
| 27 | "keeps only the active synthetic OpenCode row when the provider status is %s", | |
| 28 | (status) => { | |
| 29 | const providerEntry = entry(status); | |
| 30 | const activeInstanceId = ProviderInstanceId.make("opencode_work"); | |
| 31 | const activeModel = "openrouter/kimi-k3"; | |
| 32 | ||
| 33 | expect( | |
| 34 | shouldIncludeModelPickerOption({ | |
| 35 | entry: providerEntry, | |
| 36 | option: { | |
| 37 | slug: activeModel, | |
| 38 | name: activeModel, | |
| 39 | isUnavailable: true, | |
| 40 | }, | |
| 41 | activeInstanceId, | |
| 42 | activeModel, | |
| 43 | }), | |
| 44 | ).toBe(true); | |
| 45 | expect( | |
| 46 | shouldIncludeModelPickerOption({ | |
| 47 | entry: providerEntry, | |
| 48 | option: { slug: "stale/model", name: "Stale model" }, | |
| 49 | activeInstanceId, | |
| 50 | activeModel, | |
| 51 | }), | |
| 52 | ).toBe(false); | |
| 53 | expect( | |
| 54 | shouldIncludeModelPickerOption({ | |
| 55 | entry: providerEntry, | |
| 56 | option: { | |
| 57 | slug: "other/missing", | |
| 58 | name: "Other missing", | |
| 59 | isUnavailable: true, | |
| 60 | }, | |
| 61 | activeInstanceId, | |
| 62 | activeModel, | |
| 63 | }), | |
| 64 | ).toBe(false); | |
| 65 | }, | |
| 66 | ); | |
| 67 | }); |
apps/web/src/components/chat/ModelPickerContent.tsx+51-13| ... | ... | @@ -53,8 +53,25 @@ type ModelPickerItem = { |
| 53 | 53 | instanceAccentColor?: string | undefined; |
| 54 | 54 | continuationGroupKey?: string | undefined; |
| 55 | 55 | isLegacy?: boolean | undefined; |
| 56 | isUnavailable?: boolean | undefined; | |
| 56 | 57 | }; |
| 57 | 58 | |
| 59 | export function shouldIncludeModelPickerOption(input: { | |
| 60 | readonly entry: ProviderInstanceEntry; | |
| 61 | readonly option: ModelEsque; | |
| 62 | readonly activeInstanceId: ProviderInstanceId; | |
| 63 | readonly activeModel: string; | |
| 64 | }): boolean { | |
| 65 | if (isProviderInstancePickerReady(input.entry)) return true; | |
| 66 | return ( | |
| 67 | input.entry.enabled && | |
| 68 | input.entry.driverKind === "opencode" && | |
| 69 | input.entry.instanceId === input.activeInstanceId && | |
| 70 | input.option.slug === input.activeModel && | |
| 71 | input.option.isUnavailable === true | |
| 72 | ); | |
| 73 | } | |
| 74 | ||
| 58 | 75 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 59 | 76 | |
| 60 | 77 | function ModelListSeparator() { |
| ... | ... | @@ -107,9 +124,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 107 | 124 | const modelListRef = useRef<LegendListRef | null>(null); |
| 108 | 125 | const highlightedModelKeyRef = useRef<string | null>(null); |
| 109 | 126 | 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); | |
| 110 | 141 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">( |
| 111 | 142 | () => { |
| 112 | if (props.lockedProvider !== null) { | |
| 143 | if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) { | |
| 113 | 144 | // When locked, prime the sidebar to the currently-active instance |
| 114 | 145 | // so jumping into the picker keeps the focused instance visible. |
| 115 | 146 | return props.activeInstanceId; |
| ... | ... | @@ -189,15 +220,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 189 | 220 | [props.lockedContinuationGroupKey, props.lockedProvider], |
| 190 | 221 | ); |
| 191 | 222 | |
| 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; | |
| 198 | 226 | } |
| 199 | return ready; | |
| 200 | }, [instanceEntries]); | |
| 227 | return new Set([props.activeInstanceId]); | |
| 228 | }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]); | |
| 201 | 229 | |
| 202 | 230 | // Flatten models into a searchable array. One pass over the |
| 203 | 231 | // instance-keyed map; each model carries its instance id + driver kind |
| ... | ... | @@ -212,16 +240,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 212 | 240 | // its models — stale options shouldn't appear in the picker. |
| 213 | 241 | continue; |
| 214 | 242 | } |
| 215 | if (!readyInstanceSet.has(instanceId)) { | |
| 216 | continue; | |
| 217 | } | |
| 218 | 243 | 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 | } | |
| 219 | 254 | out.push({ |
| 220 | 255 | slug: model.slug, |
| 221 | 256 | name: model.name, |
| 222 | 257 | ...(model.shortName ? { shortName: model.shortName } : {}), |
| 223 | 258 | ...(model.subProvider ? { subProvider: model.subProvider } : {}), |
| 224 | 259 | ...(model.isLegacy ? { isLegacy: true } : {}), |
| 260 | ...(model.isUnavailable ? { isUnavailable: true } : {}), | |
| 225 | 261 | instanceId, |
| 226 | 262 | driverKind: entry.driverKind, |
| 227 | 263 | instanceDisplayName: entry.displayName, |
| ... | ... | @@ -233,7 +269,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 233 | 269 | } |
| 234 | 270 | } |
| 235 | 271 | return out; |
| 236 | }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); | |
| 272 | }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]); | |
| 237 | 273 | |
| 238 | 274 | const isLocked = props.lockedProvider !== null; |
| 239 | 275 | const isSearching = searchQuery.trim().length > 0; |
| ... | ... | @@ -609,6 +645,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 609 | 645 | onSelectInstance={handleSelectInstance} |
| 610 | 646 | instanceEntries={sidebarInstanceEntries} |
| 611 | 647 | showFavorites |
| 648 | {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})} | |
| 612 | 649 | {...(lockedDisabledInstanceIds |
| 613 | 650 | ? { |
| 614 | 651 | disabledInstanceIds: lockedDisabledInstanceIds, |
| ... | ... | @@ -768,6 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 768 | 805 | preferShortName={!isLocked} |
| 769 | 806 | useTriggerLabel={false} |
| 770 | 807 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} |
| 808 | unavailable={model.isUnavailable === true} | |
| 771 | 809 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} |
| 772 | 810 | disabledReason={disabledReason} |
| 773 | 811 | 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: { |
| 54 | 54 | showFavorites?: boolean; |
| 55 | 55 | /** Instance ids shown in the rail but unavailable for the current picker context. */ |
| 56 | 56 | disabledInstanceIds?: ReadonlySet<ProviderInstanceId>; |
| 57 | /** Non-ready instances whose selected unavailable model remains reachable. */ | |
| 58 | selectableUnavailableInstanceIds?: ReadonlySet<ProviderInstanceId>; | |
| 57 | 59 | getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; |
| 58 | 60 | /** |
| 59 | 61 | * Instance id values that should render the "new" sparkle badge. Callers |
| ... | ... | @@ -135,7 +137,10 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 135 | 137 | {props.instanceEntries.map((entry) => { |
| 136 | 138 | const isUnavailable = !isProviderInstancePickerReady(entry); |
| 137 | 139 | 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; | |
| 139 | 144 | const isSelected = props.selectedInstanceId === entry.instanceId; |
| 140 | 145 | const isHovered = hoveredInstanceId === entry.instanceId; |
| 141 | 146 | const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; |
| ... | ... | @@ -168,7 +173,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 168 | 173 | disabled={isDisabled} |
| 169 | 174 | type="button" |
| 170 | 175 | aria-label={ |
| 171 | isDisabled | |
| 176 | isUnavailable || isContextDisabled | |
| 172 | 177 | ? tooltip |
| 173 | 178 | : showNewBadge |
| 174 | 179 | ? `${entry.displayName}, new` |
apps/web/src/components/chat/ProviderModelPicker.test.tsx created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { deriveProviderInstanceEntries } from "../../providerInstances"; | |
| 6 | import { ProviderModelPicker } from "./ProviderModelPicker"; | |
| 7 | import type { ModelEsque } from "./providerIconUtils"; | |
| 8 | ||
| 9 | function providerEntry(instanceId: string, driver: string) { | |
| 10 | const provider: ServerProvider = { | |
| 11 | instanceId: ProviderInstanceId.make(instanceId), | |
| 12 | driver: ProviderDriverKind.make(driver), | |
| 13 | enabled: true, | |
| 14 | installed: true, | |
| 15 | version: null, | |
| 16 | status: "ready", | |
| 17 | auth: { status: "authenticated" }, | |
| 18 | checkedAt: "2026-08-28T00:00:00.000Z", | |
| 19 | models: [], | |
| 20 | slashCommands: [], | |
| 21 | skills: [], | |
| 22 | }; | |
| 23 | return deriveProviderInstanceEntries([provider])[0]!; | |
| 24 | } | |
| 25 | ||
| 26 | function renderPicker(input: { | |
| 27 | instanceId: string; | |
| 28 | driver: string; | |
| 29 | model: string; | |
| 30 | options: ReadonlyArray<ModelEsque>; | |
| 31 | includeEntry?: boolean; | |
| 32 | }) { | |
| 33 | const instanceId = ProviderInstanceId.make(input.instanceId); | |
| 34 | const entry = providerEntry(input.instanceId, input.driver); | |
| 35 | return renderToStaticMarkup( | |
| 36 | <ProviderModelPicker | |
| 37 | activeInstanceId={instanceId} | |
| 38 | model={input.model} | |
| 39 | lockedProvider={null} | |
| 40 | instanceEntries={input.includeEntry === false ? [] : [entry]} | |
| 41 | modelOptionsByInstance={new Map([[instanceId, input.options]])} | |
| 42 | onInstanceModelChange={() => {}} | |
| 43 | />, | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | describe("ProviderModelPicker", () => { | |
| 48 | it("shows a missing model slug for a custom OpenCode instance", () => { | |
| 49 | const markup = renderPicker({ | |
| 50 | instanceId: "team_runtime", | |
| 51 | driver: "opencode", | |
| 52 | model: "openrouter/missing-model", | |
| 53 | options: [{ slug: "openrouter/fallback", name: "Fallback model" }], | |
| 54 | }); | |
| 55 | ||
| 56 | expect(markup).toContain("openrouter/missing-model"); | |
| 57 | expect(markup).not.toContain("Fallback model"); | |
| 58 | }); | |
| 59 | ||
| 60 | it.each(["codex", "claudeAgent", "cursor", "grok"])( | |
| 61 | "uses the first option label for a missing %s model", | |
| 62 | (driver) => { | |
| 63 | const markup = renderPicker({ | |
| 64 | instanceId: `${driver}_work`, | |
| 65 | driver, | |
| 66 | model: "missing-model", | |
| 67 | options: [{ slug: "fallback-model", name: "Fallback model" }], | |
| 68 | }); | |
| 69 | ||
| 70 | expect(markup).toContain("Fallback model"); | |
| 71 | expect(markup).not.toContain(">missing-model<"); | |
| 72 | }, | |
| 73 | ); | |
| 74 | ||
| 75 | it("prefers a matching model for OpenCode", () => { | |
| 76 | const markup = renderPicker({ | |
| 77 | instanceId: "custom_runtime", | |
| 78 | driver: "opencode", | |
| 79 | model: "openrouter/selected", | |
| 80 | options: [ | |
| 81 | { slug: "openrouter/fallback", name: "Fallback model" }, | |
| 82 | { slug: "openrouter/selected", name: "Selected model" }, | |
| 83 | ], | |
| 84 | }); | |
| 85 | ||
| 86 | expect(markup).toContain("Selected model"); | |
| 87 | expect(markup).not.toContain("Fallback model"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("uses the first option when the active instance entry is missing", () => { | |
| 91 | const markup = renderPicker({ | |
| 92 | instanceId: "missing_instance", | |
| 93 | driver: "opencode", | |
| 94 | model: "missing-model", | |
| 95 | options: [{ slug: "fallback-model", name: "Fallback model" }], | |
| 96 | includeEntry: false, | |
| 97 | }); | |
| 98 | ||
| 99 | expect(markup).toContain("Fallback model"); | |
| 100 | expect(markup).not.toContain(">missing-model<"); | |
| 101 | }); | |
| 102 | }); |
apps/web/src/components/chat/ProviderModelPicker.tsx+12-6| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { memo, useEffect, useMemo, useState } from "react"; |
| 7 | 7 | import type { VariantProps } from "class-variance-authority"; |
| 8 | import { Badge } from "../ui/badge"; | |
| 8 | 9 | import { buttonVariants } from "../ui/button"; |
| 9 | 10 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 10 | 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -58,15 +59,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 58 | 59 | |
| 59 | 60 | const activeInstanceId = props.activeInstanceId; |
| 60 | 61 | 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. | |
| 65 | 64 | const selectedModel = |
| 66 | 65 | selectedInstanceOptions.find((option) => option.slug === props.model) ?? |
| 67 | selectedInstanceOptions[0]; | |
| 66 | (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]); | |
| 68 | 67 | 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; | |
| 70 | 71 | const showInstanceBadge = |
| 71 | 72 | activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); |
| 72 | 73 | |
| ... | ... | @@ -179,6 +180,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 179 | 180 | </TooltipTrigger> |
| 180 | 181 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> |
| 181 | 182 | </Tooltip> |
| 183 | {selectedModel?.isUnavailable ? ( | |
| 184 | <Badge variant="outline" size="sm"> | |
| 185 | Unavailable | |
| 186 | </Badge> | |
| 187 | ) : null} | |
| 182 | 188 | </span> |
| 183 | 189 | <span aria-hidden="true" className="flex items-center"> |
| 184 | 190 | <ComposerControlChevron /> |
apps/web/src/components/chat/TraitsPicker.test.ts+34-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; |
| 3 | import { buildTraitsTriggerDisplay } from "./TraitsPicker"; | |
| 3 | import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; | |
| 4 | 4 | |
| 5 | 5 | function selectDescriptor( |
| 6 | 6 | id: string, |
| ... | ... | @@ -155,3 +155,36 @@ describe("buildTraitsTriggerDisplay", () => { |
| 155 | 155 | ).toEqual({ label: "Ultrathink", showFastModeIcon: true }); |
| 156 | 156 | }); |
| 157 | 157 | }); |
| 158 | ||
| 159 | describe("buildUnavailableModelOptionDescriptors", () => { | |
| 160 | it("shows only saved values without inventing alternatives", () => { | |
| 161 | expect( | |
| 162 | buildUnavailableModelOptionDescriptors([ | |
| 163 | { id: "variant", value: "max" }, | |
| 164 | { id: "agent", value: "build" }, | |
| 165 | { id: "fastMode", value: true }, | |
| 166 | ]), | |
| 167 | ).toEqual([ | |
| 168 | { | |
| 169 | id: "variant", | |
| 170 | label: "Variant", | |
| 171 | type: "select", | |
| 172 | options: [{ id: "max", label: "max" }], | |
| 173 | currentValue: "max", | |
| 174 | }, | |
| 175 | { | |
| 176 | id: "agent", | |
| 177 | label: "Agent", | |
| 178 | type: "select", | |
| 179 | options: [{ id: "build", label: "build" }], | |
| 180 | currentValue: "build", | |
| 181 | }, | |
| 182 | { | |
| 183 | id: "fastMode", | |
| 184 | label: "Fast Mode", | |
| 185 | type: "boolean", | |
| 186 | currentValue: true, | |
| 187 | }, | |
| 188 | ]); | |
| 189 | }); | |
| 190 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+81-5| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | getProviderOptionCurrentValue, |
| 14 | 14 | getProviderOptionDescriptors, |
| 15 | 15 | isClaudeUltrathinkPrompt, |
| 16 | normalizeModelSlug, | |
| 16 | 17 | } from "@t3tools/shared/model"; |
| 17 | 18 | import { memo, useCallback, useState } from "react"; |
| 18 | 19 | import type { VariantProps } from "class-variance-authority"; |
| ... | ... | @@ -35,6 +36,42 @@ import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ |
| 35 | 36 | |
| 36 | 37 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; |
| 37 | 38 | |
| 39 | const SAVED_OPTION_LABELS: Readonly<Record<string, string>> = { | |
| 40 | agent: "Agent", | |
| 41 | effort: "Effort", | |
| 42 | reasoningEffort: "Reasoning effort", | |
| 43 | variant: "Variant", | |
| 44 | }; | |
| 45 | ||
| 46 | function savedOptionLabel(id: string): string { | |
| 47 | return ( | |
| 48 | SAVED_OPTION_LABELS[id] ?? | |
| 49 | id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (character) => character.toUpperCase()) | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| 53 | /** Read-only descriptors for saved values whose OpenCode model metadata is unavailable. */ | |
| 54 | export function buildUnavailableModelOptionDescriptors( | |
| 55 | selections: ProviderOptions | null | undefined, | |
| 56 | ): ReadonlyArray<ProviderOptionDescriptor> { | |
| 57 | return (selections ?? []).map((selection) => | |
| 58 | typeof selection.value === "boolean" | |
| 59 | ? { | |
| 60 | id: selection.id, | |
| 61 | label: savedOptionLabel(selection.id), | |
| 62 | type: "boolean" as const, | |
| 63 | currentValue: selection.value, | |
| 64 | } | |
| 65 | : { | |
| 66 | id: selection.id, | |
| 67 | label: savedOptionLabel(selection.id), | |
| 68 | type: "select" as const, | |
| 69 | options: [{ id: selection.value, label: selection.value }], | |
| 70 | currentValue: selection.value, | |
| 71 | }, | |
| 72 | ); | |
| 73 | } | |
| 74 | ||
| 38 | 75 | type TraitsPersistence = |
| 39 | 76 | | { |
| 40 | 77 | threadRef?: ScopedThreadRef; |
| ... | ... | @@ -99,10 +136,19 @@ function getSelectedTraits( |
| 99 | 136 | planModeEnabled: boolean, |
| 100 | 137 | ) { |
| 101 | 138 | 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 | }); | |
| 106 | 152 | const selectDescriptors = descriptors.filter( |
| 107 | 153 | (descriptor): descriptor is Extract<ProviderOptionDescriptor, { type: "select" }> => |
| 108 | 154 | descriptor.type === "select", |
| ... | ... | @@ -158,6 +204,7 @@ function getSelectedTraits( |
| 158 | 204 | ultrathinkInBodyText, |
| 159 | 205 | selectedAgent, |
| 160 | 206 | selectedAgentLabel, |
| 207 | modelIsUnavailable, | |
| 161 | 208 | }; |
| 162 | 209 | } |
| 163 | 210 | |
| ... | ... | @@ -193,7 +240,13 @@ function getTraitsSectionVisibility(input: { |
| 193 | 240 | showFastMode, |
| 194 | 241 | showContextWindow, |
| 195 | 242 | 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), | |
| 197 | 250 | }; |
| 198 | 251 | } |
| 199 | 252 | |
| ... | ... | @@ -262,6 +315,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 262 | 315 | ultrathinkPromptControlled, |
| 263 | 316 | ultrathinkInBodyText, |
| 264 | 317 | hasAnyControls, |
| 318 | modelIsUnavailable, | |
| 265 | 319 | } = getTraitsSectionVisibility({ |
| 266 | 320 | provider, |
| 267 | 321 | models, |
| ... | ... | @@ -300,6 +354,28 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 300 | 354 | return null; |
| 301 | 355 | } |
| 302 | 356 | |
| 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 | ||
| 303 | 379 | return ( |
| 304 | 380 | <> |
| 305 | 381 | {selectDescriptors.map((descriptor, index) => { |
apps/web/src/components/chat/composerProviderState.test.tsx+88| ... | ... | @@ -238,6 +238,94 @@ describe("getComposerProviderState", () => { |
| 238 | 238 | }); |
| 239 | 239 | }); |
| 240 | 240 | |
| 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 | ||
| 241 | 329 | it("adds ultrathink class names when the prompt triggers a promptInjectedValues descriptor", () => { |
| 242 | 330 | const state = getComposerProviderState({ |
| 243 | 331 | provider: PROVIDER, |
apps/web/src/components/chat/composerProviderState.tsx+16| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | getProviderOptionCurrentValue, |
| 11 | 11 | getProviderOptionDescriptors, |
| 12 | 12 | isClaudeUltrathinkPrompt, |
| 13 | normalizeModelSlug, | |
| 13 | 14 | } from "@t3tools/shared/model"; |
| 14 | 15 | import type { ReactNode } from "react"; |
| 15 | 16 | |
| ... | ... | @@ -63,6 +64,21 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 63 | 64 | promptInjectionState = "none", |
| 64 | 65 | planModeEnabled, |
| 65 | 66 | } = 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 | } | |
| 66 | 82 | const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); |
| 67 | 83 | const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); |
| 68 | 84 | const primarySelectDescriptor = descriptors.find( |
apps/web/src/components/chat/providerIconUtils.ts+1| ... | ... | @@ -27,6 +27,7 @@ export type ModelEsque = { |
| 27 | 27 | shortName?: string | undefined; |
| 28 | 28 | subProvider?: string | undefined; |
| 29 | 29 | isLegacy?: boolean | undefined; |
| 30 | isUnavailable?: boolean | undefined; | |
| 30 | 31 | }; |
| 31 | 32 | |
| 32 | 33 | function escapeRegExp(value: string): string { |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6-5| ... | ... | @@ -417,6 +417,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 417 | 417 | // ----- default model ----- |
| 418 | 418 | const storedSelection = representative.defaultModelSelection; |
| 419 | 419 | const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); |
| 420 | const resolvedInstanceId = resolvedSelection?.instanceId ?? null; | |
| 421 | const resolvedModel = resolvedSelection?.model ?? null; | |
| 420 | 422 | const instanceEntries = useMemo( |
| 421 | 423 | () => |
| 422 | 424 | sortProviderInstanceEntries( |
| ... | ... | @@ -425,12 +427,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 425 | 427 | [serverProviders, settings], |
| 426 | 428 | ); |
| 427 | 429 | 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], | |
| 433 | 433 | ); |
| 434 | const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); | |
| 434 | 435 | const setDefaultModel = useCallback( |
| 435 | 436 | (selection: ModelSelection | null) => |
| 436 | 437 | void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), |
apps/web/src/composerDraftStore.ts+6| ... | ... | @@ -1025,6 +1025,10 @@ export function deriveEffectiveComposerModelState(input: { |
| 1025 | 1025 | }): EffectiveComposerModelState { |
| 1026 | 1026 | const baseModelCandidate = |
| 1027 | 1027 | input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; |
| 1028 | const preserveThreadModel = | |
| 1029 | input.selectedInstanceId !== null && | |
| 1030 | input.selectedInstanceId !== undefined && | |
| 1031 | input.threadModelSelection?.instanceId === input.selectedInstanceId; | |
| 1028 | 1032 | const baseModel = |
| 1029 | 1033 | (input.selectedInstanceId |
| 1030 | 1034 | ? resolveAppModelSelectionForInstance( |
| ... | ... | @@ -1032,6 +1036,7 @@ export function deriveEffectiveComposerModelState(input: { |
| 1032 | 1036 | input.settings, |
| 1033 | 1037 | input.providers, |
| 1034 | 1038 | baseModelCandidate, |
| 1039 | { preserveUnavailableSelection: preserveThreadModel }, | |
| 1035 | 1040 | ) |
| 1036 | 1041 | : null) ?? |
| 1037 | 1042 | resolveAppModelSelection( |
| ... | ... | @@ -1061,6 +1066,7 @@ export function deriveEffectiveComposerModelState(input: { |
| 1061 | 1066 | input.settings, |
| 1062 | 1067 | input.providers, |
| 1063 | 1068 | activeSelection.model, |
| 1069 | { preserveUnavailableSelection: true }, | |
| 1064 | 1070 | ) ?? |
| 1065 | 1071 | resolveAppModelSelection( |
| 1066 | 1072 | input.selectedProvider, |
apps/web/src/modelSelection.test.ts+293| ... | ... | @@ -2,8 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 |
| 2 | 2 | import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | import { createModelSelection } from "@t3tools/shared/model"; |
| 5 | import { deriveEffectiveComposerModelState } from "./composerDraftStore"; | |
| 6 | import { getComposerProviderState } from "./components/chat/composerProviderState"; | |
| 5 | 7 | import { deriveProviderInstanceEntries } from "./providerInstances"; |
| 6 | 8 | import { |
| 9 | getCustomModelOptionsByInstance, | |
| 7 | 10 | getAppModelOptionsForInstance, |
| 8 | 11 | resolveAppModelSelectionForInstance, |
| 9 | 12 | resolveAppModelSelectionState, |
| ... | ... | @@ -275,6 +278,15 @@ describe("instance-scoped model selection", () => { |
| 275 | 278 | "claude-opus-4-6", |
| 276 | 279 | ), |
| 277 | 280 | ).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"); | |
| 278 | 290 | }); |
| 279 | 291 | |
| 280 | 292 | it("falls back instead of resolving a custom slug against the wrong instance", () => { |
| ... | ... | @@ -299,6 +311,287 @@ describe("instance-scoped model selection", () => { |
| 299 | 311 | ).toBe("claude-sonnet-4-6"); |
| 300 | 312 | }); |
| 301 | 313 | |
| 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 | ||
| 302 | 595 | it("preserves custom provider instances in settings model selection", () => { |
| 303 | 596 | const providers = [ |
| 304 | 597 | provider({ |
apps/web/src/modelSelection.ts+66-13| ... | ... | @@ -78,6 +78,27 @@ export interface AppModelOption { |
| 78 | 78 | isCustom: boolean; |
| 79 | 79 | isDefault?: boolean; |
| 80 | 80 | isLegacy?: boolean; |
| 81 | isUnavailable?: boolean; | |
| 82 | } | |
| 83 | ||
| 84 | function appendUnavailableOpenCodeSelection( | |
| 85 | options: AppModelOption[], | |
| 86 | rawModels: ReadonlyArray<ServerProvider["models"][number]>, | |
| 87 | provider: ProviderDriverKind, | |
| 88 | selectedModel: string | null | undefined, | |
| 89 | hiddenModels: ReadonlyArray<string>, | |
| 90 | ): AppModelOption[] { | |
| 91 | if (provider !== "opencode") return options; | |
| 92 | const slug = normalizeCustomModelSlug(selectedModel); | |
| 93 | if (!slug) return options; | |
| 94 | ||
| 95 | // A model that exists in the raw catalog can be absent from `options` | |
| 96 | // because the user hid it. Keep that preference authoritative. | |
| 97 | if (rawModels.some((model) => model.slug === slug)) return options; | |
| 98 | if (hiddenModels.includes(slug)) return options; | |
| 99 | if (options.some((option) => option.slug === slug)) return options; | |
| 100 | ||
| 101 | return [...options, { slug, name: slug, isCustom: false, isUnavailable: true }]; | |
| 81 | 102 | } |
| 82 | 103 | |
| 83 | 104 | function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { |
| ... | ... | @@ -151,9 +172,10 @@ export function getAppModelOptions( |
| 151 | 172 | settings: UnifiedSettings, |
| 152 | 173 | providers: ReadonlyArray<ServerProvider>, |
| 153 | 174 | provider: ProviderDriverKind, |
| 154 | _selectedModel?: string | null, | |
| 175 | selectedModel?: string | null, | |
| 155 | 176 | ): AppModelOption[] { |
| 156 | const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption); | |
| 177 | const rawModels = getProviderModels(providers, provider); | |
| 178 | const options: AppModelOption[] = rawModels.map(toAppModelOption); | |
| 157 | 179 | const seen = new Set(options.map((option) => option.slug)); |
| 158 | 180 | const builtInModelSlugs = new Set( |
| 159 | 181 | Arr.filterMap(getProviderModels(providers, provider), (model) => |
| ... | ... | @@ -180,9 +202,13 @@ export function getAppModelOptions( |
| 180 | 202 | }); |
| 181 | 203 | } |
| 182 | 204 | |
| 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, | |
| 186 | 212 | ); |
| 187 | 213 | } |
| 188 | 214 | |
| ... | ... | @@ -200,6 +226,7 @@ export function getAppModelOptions( |
| 200 | 226 | export function getAppModelOptionsForInstance( |
| 201 | 227 | settings: UnifiedSettings, |
| 202 | 228 | entry: ProviderInstanceEntry, |
| 229 | selectedModel?: string | null, | |
| 203 | 230 | ): AppModelOption[] { |
| 204 | 231 | const options: AppModelOption[] = entry.models.map(toAppModelOption); |
| 205 | 232 | const seen = new Set(options.map((option) => option.slug)); |
| ... | ... | @@ -219,9 +246,13 @@ export function getAppModelOptionsForInstance( |
| 219 | 246 | options.push({ slug, name: slug, isCustom: true }); |
| 220 | 247 | } |
| 221 | 248 | |
| 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, | |
| 225 | 256 | ); |
| 226 | 257 | } |
| 227 | 258 | |
| ... | ... | @@ -244,14 +275,29 @@ export function resolveAppModelSelectionForInstance( |
| 244 | 275 | settings: UnifiedSettings, |
| 245 | 276 | providers: ReadonlyArray<ServerProvider>, |
| 246 | 277 | selectedModel: string | null | undefined, |
| 278 | resolutionOptions?: { readonly preserveUnavailableSelection?: boolean }, | |
| 247 | 279 | ): string | null { |
| 248 | 280 | const entry = deriveProviderInstanceEntries(providers).find( |
| 249 | 281 | (candidate) => candidate.instanceId === instanceId, |
| 250 | 282 | ); |
| 251 | 283 | 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 | } | |
| 253 | 300 | return ( |
| 254 | resolveSelectableModel(entry.driverKind, selectedModel, options) ?? | |
| 255 | 301 | options.find((option) => option.isDefault)?.slug ?? |
| 256 | 302 | options[0]?.slug ?? |
| 257 | 303 | entry.models.find((model) => model.isDefault)?.slug ?? |
| ... | ... | @@ -268,12 +314,19 @@ export function resolveAppModelSelectionForInstance( |
| 268 | 314 | export function getCustomModelOptionsByInstance( |
| 269 | 315 | settings: UnifiedSettings, |
| 270 | 316 | providers: ReadonlyArray<ServerProvider>, |
| 271 | _selectedInstanceId?: ProviderInstanceId | null, | |
| 272 | _selectedModel?: string | null, | |
| 317 | selectedInstanceId?: ProviderInstanceId | null, | |
| 318 | selectedModel?: string | null, | |
| 273 | 319 | ): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> { |
| 274 | 320 | const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>(); |
| 275 | 321 | 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 | ); | |
| 277 | 330 | } |
| 278 | 331 | return out; |
| 279 | 332 | } |
docs/README.md+1-1| ... | ... | @@ -13,7 +13,7 @@ |
| 13 | 13 | - [Keeping app and server in sync](./user/updating.md) |
| 14 | 14 | - [Source control integrations](./user/source-control.md) |
| 15 | 15 | - [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) | |
| 17 | 17 | |
| 18 | 18 | Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) |
| 19 | 19 |
docs/internals/providers.md+45| ... | ... | @@ -39,6 +39,50 @@ directory to route session and turn operations for a thread, so callers name a t |
| 39 | 39 | Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No |
| 40 | 40 | orchestration, contract, or client change is required for the common case. |
| 41 | 41 | |
| 42 | ## OpenCode server ownership and catalog | |
| 43 | ||
| 44 | Each OpenCode provider instance owns one lazy local server for catalog discovery and | |
| 45 | text-generation helpers through [`OpenCodeServerOwner.ts`][opencode-server-owner]. Concurrent | |
| 46 | borrowers share startup. The server closes 30 seconds after the last borrower releases it, or | |
| 47 | when the provider instance closes. A failed or exited process can be started again on the next | |
| 48 | use. An externally configured OpenCode server remains externally owned. | |
| 49 | ||
| 50 | The local server and its SDK clients use one resolved password. An explicit provider password | |
| 51 | overrides `OPENCODE_SERVER_PASSWORD` in the spawned environment. Without an explicit password, | |
| 52 | the client uses the password from the environment that the process inherits. External servers use | |
| 53 | only their explicit provider password and never inherit the host's local password. | |
| 54 | ||
| 55 | Every server connection must pass the authenticated `/global/health` check before inventory or | |
| 56 | session operations start. The response must contain a valid version at or above 1.14.19. Local | |
| 57 | owners cache this result for the lifetime of the spawned process. External actions check once when | |
| 58 | they create their server connection, not for each model or SDK request. | |
| 59 | ||
| 60 | Chat adapters keep their own server per thread. They register a thread-specific `t3-code` MCP | |
| 61 | connection, while OpenCode stores MCP connections by directory. Sharing these chat servers | |
| 62 | without changing MCP routing would let two threads in one directory replace each other's | |
| 63 | connection. | |
| 64 | ||
| 65 | OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The | |
| 66 | provider registry keeps the snapshot in memory and persists it in the existing per-instance cache. | |
| 67 | Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the | |
| 68 | OpenCode catalog from the current helper. The `serverRefreshProviders` request also refreshes it. | |
| 69 | Periodic OpenCode probes remain disabled. OpenCode reads credentials for each inventory request, | |
| 70 | but its native configuration files can remain cached for the lifetime of the helper process. The | |
| 71 | helper closes 30 seconds after its last inventory or text-generation borrower releases it. A | |
| 72 | refresh after that idle period starts a new helper and reads file changes. Repeated refreshes and | |
| 73 | active text-generation work can extend process reuse. Changes to the provider configuration or | |
| 74 | environment replace the instance and start a new discovery. Changes to unrelated settings only | |
| 75 | update snapshot enrichment. Other providers retain their existing refresh policy. | |
| 76 | ||
| 77 | T3 Code does not own an external OpenCode process. Native configuration changes there can require | |
| 78 | an external reload or restart before T3 Code's next refresh sees them. | |
| 79 | ||
| 80 | The shared server's idle shutdown does not clear the catalog. Failed discovery keeps the last | |
| 81 | known models, slash commands, and skills through the registry's existing merge rules. A successful | |
| 82 | empty inventory is authoritative. Existing threads keep their explicit model identifier and | |
| 83 | options when catalog metadata is missing; the catalog is not permission to choose a different | |
| 84 | model for a thread. | |
| 85 | ||
| 42 | 86 | ## Model manifest |
| 43 | 87 | |
| 44 | 88 | The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which |
| ... | ... | @@ -121,6 +165,7 @@ when a request opens (approval) or user input is requested, via |
| 121 | 165 | [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts |
| 122 | 166 | [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts |
| 123 | 167 | [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts |
| 168 | [opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts | |
| 124 | 169 | [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts |
| 125 | 170 | [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts |
| 126 | 171 | [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts |
docs/user/providers-opencode.md created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | # OpenCode | |
| 2 | ||
| 3 | T3 Code uses the OpenCode setup on the connected environment. With a remote environment, its | |
| 4 | OpenCode login and configuration apply, not the setup on your desktop or phone. | |
| 5 | ||
| 6 | T3 Code requires OpenCode 1.14.19 or newer. It checks the server version before it loads models or | |
| 7 | starts work. If the check fails, update OpenCode or fix the server URL and password, then refresh | |
| 8 | the provider status. Reconnecting the client also runs the check again. | |
| 9 | ||
| 10 | ## Server authentication | |
| 11 | ||
| 12 | Without a server URL, T3 Code starts a local OpenCode server. The process inherits | |
| 13 | `OPENCODE_SERVER_PASSWORD` from the environment. A password in the provider settings overrides | |
| 14 | that environment value for both the local process and T3 Code. | |
| 15 | ||
| 16 | With a server URL, T3 Code connects to that external server and uses only the password in the | |
| 17 | provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. | |
| 18 | OpenCode uses this password for HTTP Basic authentication. | |
| 19 | ||
| 20 | ## Refresh the model list | |
| 21 | ||
| 22 | T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its | |
| 23 | cache. Reconnecting a client or using a refresh control asks OpenCode for the list again. The | |
| 24 | periodic provider health setting does not refresh OpenCode's catalog. | |
| 25 | ||
| 26 | After changing an OpenCode login or configuration outside T3 Code, open **Settings > Providers**, | |
| 27 | select the environment, and choose **Refresh provider status**. Changing the provider's | |
| 28 | configuration in T3 Code also replaces that provider connection. | |
| 29 | ||
| 30 | On mobile, open the thread settings and select **Refresh models**. The control stays disabled while | |
| 31 | the refresh runs and shows an error if the refresh fails. | |
| 32 | ||
| 33 | OpenCode reads credential changes on each model-list request. Native OpenCode configuration files | |
| 34 | can stay cached while the local helper is running. The helper closes after 30 seconds with no | |
| 35 | model-list or text-generation work. Refresh after that idle period to start a new helper and read | |
| 36 | the file changes. Repeated refreshes or active helper work can extend this wait. | |
| 37 | ||
| 38 | T3 Code does not own an external OpenCode server. Native configuration changes on that server can | |
| 39 | require its own reload or restart before a refresh returns the new list. | |
| 40 | ||
| 41 | If a refresh fails, T3 Code keeps the last known models, slash commands, and skills. Fix the | |
| 42 | connection, then refresh again. A successful refresh can remove entries that OpenCode no longer | |
| 43 | offers. | |
| 44 | ||
| 45 | ## Continue an existing thread | |
| 46 | ||
| 47 | An existing thread keeps its selected model and options when that model is temporarily absent | |
| 48 | from the catalog. The web picker shows an **Unavailable** row and keeps saved option values visible | |
| 49 | until the model metadata returns. T3 Code does not switch the thread to the first model in the | |
| 50 | list. | |
| 51 | ||
| 52 | The stored selection does not guarantee that OpenCode can still run the model. If the provider | |
| 53 | rejects it, select an available model before trying again. |