Unified · Split
| @@ -621,6 +621,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 620 unmodified lines | ||
| 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), |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | + EnvironmentId, | |
| 2 | 3 | ModelSelection, |
| 3 | 4 | ProviderOptionDescriptor, |
| 4 | 5 | ProviderOptionSelection, |
| @@ -27,7 +28,7 @@ import { · 22 unmodified lines | ||
| 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 { · 13 unmodified lines | ||
| 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 = · 239 unmodified lines | ||
| 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() { · 45 unmodified lines | ||
| 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( · 112 unmodified lines | ||
| 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( · 22 unmodified lines | ||
| 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() { · 462 unmodified lines | ||
| 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() { · 41 unmodified lines | ||
| 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() { · 59 unmodified lines | ||
| 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() { · 154 unmodified lines | ||
| 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)} |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -34,6 +34,7 @@ import { · 33 unmodified lines | ||
| 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> · 101 unmodified lines | ||
| 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> · 2 unmodified lines | ||
| 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, |
| @@ -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"; · 7 unmodified lines | ||
| 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 = { · 37 unmodified lines | ||
| 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 = { · 1 unmodified line | ||
| 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 = { · 7 unmodified lines | ||
| 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 = { · 8 unmodified lines | ||
| 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 = { · 8 unmodified lines | ||
| 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 = { · 5 unmodified lines | ||
| 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 = { · 8 unmodified lines | ||
| 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 = { · 14 unmodified lines | ||
| 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(() => { · 63 unmodified lines | ||
| 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) => { · 15 unmodified lines | ||
| 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) => { · 502 unmodified lines | ||
| 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) => { · 385 unmodified lines | ||
| 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: { |
| @@ -15,13 +15,17 @@ import { · 14 unmodified lines | ||
| 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 = · 147 unmodified lines | ||
| 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 · 33 unmodified lines | ||
| 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 { · 7 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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( · 294 unmodified lines | ||
| 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* ( · 1 unmodified line | ||
| 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( · 28 unmodified lines | ||
| 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( · 5 unmodified lines | ||
| 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( · 63 unmodified lines | ||
| 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( · 5 unmodified lines | ||
| 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( · 24 unmodified lines | ||
| 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( · 64 unmodified lines | ||
| 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( · 9 unmodified lines | ||
| 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( · 107 unmodified lines | ||
| 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( · 17 unmodified lines | ||
| 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( · 1 unmodified line | ||
| 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( · 109 unmodified lines | ||
| 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( · 5 unmodified lines | ||
| 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( · 112 unmodified lines | ||
| 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( · 15 unmodified lines | ||
| 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( · 4 unmodified lines | ||
| 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( · 10 unmodified lines | ||
| 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( · 34 unmodified lines | ||
| 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( · 48 unmodified lines | ||
| 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 | } |
| @@ -12,8 +12,10 @@ import { ServerConfig } from "../../config.ts"; · 11 unmodified lines | ||
| 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 = { · 14 unmodified lines | ||
| 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 = { · 4 unmodified lines | ||
| 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 = { · 3 unmodified lines | ||
| 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 = { · 3 unmodified lines | ||
| 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 = { · 8 unmodified lines | ||
| 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 · 37 unmodified lines | ||
| 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) => { · 7 unmodified lines | ||
| 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) => { · 33 unmodified lines | ||
| 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) => { · 56 unmodified lines | ||
| 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) => { · 20 unmodified lines | ||
| 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 · 7 unmodified lines | ||
| 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"); |
| @@ -19,17 +19,18 @@ import { · 18 unmodified lines | ||
| 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 { · 29 unmodified lines | ||
| 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: { · 56 unmodified lines | ||
| 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 · 188 unmodified lines | ||
| 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 · 74 unmodified lines | ||
| 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, |
| @@ -589,18 +589,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 588 unmodified lines | ||
| 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 · 63 unmodified lines | ||
| 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 · 7 unmodified lines | ||
| 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 · 22 unmodified lines | ||
| 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 · 11 unmodified lines | ||
| 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 · 27 unmodified lines | ||
| 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 · 125 unmodified lines | ||
| 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"); |
| @@ -95,6 +95,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => · 94 unmodified lines | ||
| 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 = ( · 31 unmodified lines | ||
| 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 = ( |
| @@ -24,6 +24,8 @@ import { · 23 unmodified lines | ||
| 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) => { · 1501 unmodified lines | ||
| 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( |
| @@ -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 | +); | |
| @@ -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)); | |
| @@ -250,6 +250,40 @@ describe("makeManagedServerProvider", () => { · 249 unmodified lines | ||
| 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", () => { · 99 unmodified lines | ||
| 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* () { |
| @@ -40,6 +40,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 39 unmodified lines | ||
| 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")( · 75 unmodified lines | ||
| 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")( · 72 unmodified lines | ||
| 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, |
| @@ -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", () => { · 14 unmodified lines | ||
| 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 | +}); | |
| @@ -18,6 +18,34 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; · 17 unmodified lines | ||
| 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; |
| @@ -39,6 +39,7 @@ describe("buildOpenCodePermissionRules", () => { · 38 unmodified lines | ||
| 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 | }); |
| @@ -33,10 +33,20 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; · 32 unmodified lines | ||
| 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( · 5 unmodified lines | ||
| 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 { · 31 unmodified lines | ||
| 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>( · 1 unmodified line | ||
| 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 { · 35 unmodified lines | ||
| 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 { · 6 unmodified lines | ||
| 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: { · 234 unmodified lines | ||
| 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* () { · 109 unmodified lines | ||
| 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* () { · 17 unmodified lines | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 119 unmodified lines | ||
| 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* () { · 4 unmodified lines | ||
| 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* () { · 1 unmodified line | ||
| 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* () { · 13 unmodified lines | ||
| 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) => |
| @@ -57,6 +57,7 @@ import * as Option from "effect/Option"; · 56 unmodified lines | ||
| 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) => { · 4855 unmodified lines | ||
| 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(); |
| @@ -11,6 +11,7 @@ import { beforeEach, expect } from "vite-plus/test"; · 10 unmodified lines | ||
| 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 = { · 4 unmodified lines | ||
| 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 = { · 7 unmodified lines | ||
| 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 = { · 2 unmodified lines | ||
| 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 = { · 5 unmodified lines | ||
| 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( · 87 unmodified lines | ||
| 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) => { · 48 unmodified lines | ||
| 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) => { · 225 unmodified lines | ||
| 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* () { |
| @@ -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 { · 21 unmodified lines | ||
| 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 · 136 unmodified lines | ||
| 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" · 18 unmodified lines | ||
| 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" · 102 unmodified lines | ||
| 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( |
| @@ -997,16 +997,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 996 unmodified lines | ||
| 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) |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 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: { · 19 unmodified lines | ||
| 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: { · 35 unmodified lines | ||
| 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"> |
| @@ -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 | +}); | |
| @@ -53,8 +53,25 @@ type ModelPickerItem = { · 52 unmodified lines | ||
| 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: { · 46 unmodified lines | ||
| 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: { · 73 unmodified lines | ||
| 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: { · 8 unmodified lines | ||
| 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: { · 5 unmodified lines | ||
| 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: { · 369 unmodified lines | ||
| 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: { · 153 unmodified lines | ||
| 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)} |
| @@ -54,6 +54,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 53 unmodified lines | ||
| 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: { · 75 unmodified lines | ||
| 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: { · 26 unmodified lines | ||
| 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` |
| @@ -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 | +}); | |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 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: { · 47 unmodified lines | ||
| 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: { · 106 unmodified lines | ||
| 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 /> |
| @@ -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", () => { · 148 unmodified lines | ||
| 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 | +}); | |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 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 "./ · 16 unmodified lines | ||
| 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( · 58 unmodified lines | ||
| 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( · 49 unmodified lines | ||
| 158 | 204 | ultrathinkInBodyText, |
| 159 | 205 | selectedAgent, |
| 160 | 206 | selectedAgentLabel, |
| 207 | + modelIsUnavailable, | |
| 161 | 208 | }; |
| 162 | 209 | } |
| 163 | 210 | |
| @@ -193,7 +240,13 @@ function getTraitsSectionVisibility(input: { · 29 unmodified lines | ||
| 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({ · 62 unmodified lines | ||
| 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({ · 32 unmodified lines | ||
| 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) => { |
| @@ -238,6 +238,94 @@ describe("getComposerProviderState", () => { · 237 unmodified lines | ||
| 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, |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 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 · 47 unmodified lines | ||
| 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( |
| @@ -27,6 +27,7 @@ export type ModelEsque = { · 26 unmodified lines | ||
| 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 { |
| @@ -417,6 +417,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { · 416 unmodified lines | ||
| 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 }) { · 2 unmodified lines | ||
| 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"), |
| @@ -1025,6 +1025,10 @@ export function deriveEffectiveComposerModelState(input: { · 1024 unmodified lines | ||
| 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: { · 1 unmodified line | ||
| 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: { · 23 unmodified lines | ||
| 1061 | 1066 | input.settings, |
| 1062 | 1067 | input.providers, |
| 1063 | 1068 | activeSelection.model, |
| 1069 | + { preserveUnavailableSelection: true }, | |
| 1064 | 1070 | ) ?? |
| 1065 | 1071 | resolveAppModelSelection( |
| 1066 | 1072 | input.selectedProvider, |
| @@ -2,8 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 · 1 unmodified line | ||
| 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", () => { · 265 unmodified lines | ||
| 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", () => { · 18 unmodified lines | ||
| 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({ |
| @@ -78,6 +78,27 @@ export interface AppModelOption { · 77 unmodified lines | ||
| 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( · 67 unmodified lines | ||
| 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( · 20 unmodified lines | ||
| 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( · 11 unmodified lines | ||
| 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( · 13 unmodified lines | ||
| 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( · 16 unmodified lines | ||
| 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( · 10 unmodified lines | ||
| 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 | } |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | ||
| 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 | |
| @@ -39,6 +39,50 @@ directory to route session and turn operations for a thread, so callers name a t · 38 unmodified lines | ||
| 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 · 76 unmodified lines | ||
| 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 |
| @@ -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. | |