From 5cf83ffe8f9d5eabd1c17721bd1f9597c97d98fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:30:18 -0700 Subject: [PATCH 01/38] fix(release): use configured node for smoke manifest merge (#2364) Co-authored-by: codex --- scripts/release-smoke.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 1736abab378..7aff0a2f655 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -292,7 +292,7 @@ try { fi found_windows_manifest=true - node ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ + ${JSON.stringify(process.execPath)} ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ "$arm64_manifest" \ "$x64_manifest" \ "$output_manifest" From dbebc387dd458dd7062380ccb862a5cdac7aba66 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Apr 2026 09:50:19 -0700 Subject: [PATCH 02/38] Ignore stale WebSocket lifecycle events after reconnect (#2372) --- apps/web/src/rpc/protocol.ts | 16 ++++++++ apps/web/src/rpc/wsTransport.test.ts | 57 ++++++++++++++++++++++++++++ apps/web/src/rpc/wsTransport.ts | 13 ++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/apps/web/src/rpc/protocol.ts b/apps/web/src/rpc/protocol.ts index 5c5c51d8990..0a4288df391 100644 --- a/apps/web/src/rpc/protocol.ts +++ b/apps/web/src/rpc/protocol.ts @@ -18,6 +18,7 @@ import { } from "./wsConnectionState"; export interface WsProtocolLifecycleHandlers { + readonly isActive?: () => boolean; readonly onAttempt?: (socketUrl: string) => void; readonly onOpen?: () => void; readonly onError?: (message: string) => void; @@ -49,6 +50,7 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { function defaultLifecycleHandlers(): Required { return { + isActive: () => true, onAttempt: recordWsConnectionAttempt, onOpen: recordWsConnectionOpened, onError: (message) => { @@ -66,21 +68,35 @@ function composeLifecycleHandlers( handlers?: WsProtocolLifecycleHandlers, ): Required { const defaults = defaultLifecycleHandlers(); + const isActive = handlers?.isActive ?? (() => true); return { + isActive, onAttempt: (socketUrl) => { + if (!isActive()) { + return; + } defaults.onAttempt(socketUrl); handlers?.onAttempt?.(socketUrl); }, onOpen: () => { + if (!isActive()) { + return; + } defaults.onOpen(); handlers?.onOpen?.(); }, onError: (message) => { + if (!isActive()) { + return; + } defaults.onError(message); handlers?.onError?.(message); }, onClose: (details) => { + if (!isActive()) { + return; + } defaults.onClose(details); handlers?.onClose?.(details); }, diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts index a5610a931f6..c9632d3f78d 100644 --- a/apps/web/src/rpc/wsTransport.test.ts +++ b/apps/web/src/rpc/wsTransport.test.ts @@ -324,6 +324,11 @@ describe("WsTransport", () => { const secondSocket = getSocket(); expect(secondSocket).not.toBe(firstSocket); expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); + expect(getWsConnectionStatus()).toMatchObject({ + closeCode: null, + closeReason: null, + phase: "connecting", + }); const requestPromise = transport.request((client) => client[WS_METHODS.serverUpsertKeybinding]({ @@ -361,6 +366,58 @@ describe("WsTransport", () => { await transport.dispose(); }); + it("ignores stale socket lifecycle events after a reconnect starts a new session", async () => { + const onClose = vi.fn(); + const transport = createTransport("ws://localhost:3020", { onClose }); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + + const firstSocket = getSocket(); + firstSocket.open(); + + await waitFor(() => { + expect(getWsConnectionStatus()).toMatchObject({ + hasConnected: true, + phase: "connected", + }); + }); + + await transport.reconnect(); + + await waitFor(() => { + expect(sockets).toHaveLength(2); + }); + + expect(onClose).not.toHaveBeenCalled(); + expect(getWsConnectionStatus()).toMatchObject({ + closeCode: null, + closeReason: null, + phase: "connecting", + }); + + const secondSocket = getSocket(); + secondSocket.open(); + + await waitFor(() => { + expect(getWsConnectionStatus()).toMatchObject({ + phase: "connected", + }); + }); + + firstSocket.close(1006, "stale close"); + + expect(onClose).not.toHaveBeenCalled(); + expect(getWsConnectionStatus()).toMatchObject({ + closeCode: null, + closeReason: null, + phase: "connected", + }); + + await transport.dispose(); + }); + it("marks unary requests as slow until the first server ack arrives", async () => { const slowAckThresholdMs = 25; setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index d9a50a9fad0..0b90b22431f 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -53,6 +53,8 @@ export class WsTransport { private disposed = false; private hasReportedTransportDisconnect = false; private reconnectChain: Promise = Promise.resolve(); + private nextSessionId = 0; + private activeSessionId = 0; private session: TransportSession; constructor( @@ -215,8 +217,17 @@ export class WsTransport { } private createSession(): TransportSession { + const sessionId = this.nextSessionId + 1; + this.nextSessionId = sessionId; + this.activeSessionId = sessionId; const runtime = ManagedRuntime.make( - Layer.mergeAll(createWsRpcProtocolLayer(this.url, this.lifecycleHandlers), ClientTracingLive), + Layer.mergeAll( + createWsRpcProtocolLayer(this.url, { + ...this.lifecycleHandlers, + isActive: () => !this.disposed && this.activeSessionId === sessionId, + }), + ClientTracingLive, + ), ); const clientScope = runtime.runSync(Scope.make()); return { From 35822884d19ba64c6529d7736fb0182b361f3e4c Mon Sep 17 00:00:00 2001 From: Josh Meads <8870827+joshmeads@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:13:04 -0700 Subject: [PATCH 03/38] Stop OpenCode refresh from leaking serve processes OpenCode can leave the long-lived serve child in the spawned process group after the wrapper exits. Provider refresh owns only a scoped inventory probe, so cleanup targets that local process group when the refresh scope closes. Constraint: Keep active OpenCode thread sessions scoped to their own session lifecycle. Rejected: Disable provider refresh | would hide model and auth state changes. Confidence: high Scope-risk: narrow Tested: bun run test src/provider/Layers/OpenCodeProvider.test.ts src/provider/Layers/OpenCodeAdapter.test.ts Tested: bun fmt Tested: bun lint Tested: bun typecheck Tested: bun run build:desktop --- .../provider/Layers/OpenCodeProvider.test.ts | 28 ++++++++++++++++--- apps/server/src/provider/opencodeRuntime.ts | 20 +++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index f32fd6f49e2..75622281c59 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -23,6 +23,7 @@ const runtimeMock = { runVersionError: null as Error | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, + closeCalls: 0, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -32,6 +33,7 @@ const runtimeMock = { this.state.runVersionError = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; + this.state.closeCalls = 0; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -46,10 +48,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { exitCode: Effect.never, }), connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), + Effect.gen(function* () { + if (!serverUrl) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + } + return { + url: serverUrl ?? "http://127.0.0.1:4301", + exitCode: null, + external: Boolean(serverUrl), + }; }), runOpenCodeCommand: () => runtimeMock.state.runVersionError @@ -188,6 +199,15 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); }), ); + + it.effect("closes the local OpenCode server scope after provider refresh", () => + Effect.gen(function* () { + const provider = yield* OpenCodeProvider; + yield* provider.refresh; + + assert.equal(runtimeMock.state.closeCalls, 1); + }), + ); }); it.layer( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 41ec5102c3c..086584893c1 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -330,6 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make(input.binaryPath, args, { + detached: process.platform !== "win32", env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({}), @@ -348,6 +349,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); + const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => + process.platform === "win32" + ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), signal); + } catch { + // The direct child may already have exited after starting the + // server; the process group kill is best-effort cleanup for + // any serve process left in that group. + } + }); + const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), + Effect.ignore, + ); + yield* Scope.addFinalizer(runtimeScope, terminateChild); + const stdoutRef = yield* Ref.make(""); const stderrRef = yield* Ref.make(""); const readyDeferred = yield* Deferred.make(); From 08e6d4cfbfe55af83fe5fb6b9e75364f42f1f22f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 29 Apr 2026 16:13:55 -0700 Subject: [PATCH 04/38] feat: Multi-Provider support (#2277) Co-authored-by: Claude Opus 4.7 Co-authored-by: codex --- apps/desktop/src/clientPersistence.test.ts | 1 + .../OrchestrationEngineHarness.integration.ts | 44 +- .../TestProviderAdapter.integration.ts | 17 +- .../integration/fixtures/providerRuntime.ts | 4 +- .../orchestrationEngine.integration.test.ts | 178 +- .../providerService.integration.test.ts | 37 +- .../git/Layers/ClaudeTextGeneration.test.ts | 311 +-- .../src/git/Layers/ClaudeTextGeneration.ts | 55 +- .../git/Layers/CodexTextGeneration.test.ts | 528 +++-- .../src/git/Layers/CodexTextGeneration.ts | 97 +- .../git/Layers/CursorTextGeneration.test.ts | 311 ++- .../src/git/Layers/CursorTextGeneration.ts | 56 +- .../git/Layers/OpenCodeTextGeneration.test.ts | 354 ++-- .../src/git/Layers/OpenCodeTextGeneration.ts | 77 +- .../src/git/Layers/RoutingTextGeneration.ts | 104 - .../src/git/Layers/TextGenerationLive.test.ts | 117 ++ .../src/git/Layers/TextGenerationLive.ts | 101 + apps/server/src/observability/Metrics.test.ts | 10 +- .../Layers/CheckpointReactor.test.ts | 60 +- .../Layers/OrchestrationEngine.test.ts | 51 +- .../Layers/ProjectionPipeline.test.ts | 33 +- .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 18 +- .../Layers/ProjectionSnapshotQuery.ts | 6 + .../Layers/ProviderCommandReactor.test.ts | 438 +++- .../Layers/ProviderCommandReactor.ts | 183 +- .../Layers/ProviderRuntimeIngestion.test.ts | 229 ++- .../Layers/ProviderRuntimeIngestion.ts | 6 + .../orchestration/commandInvariants.test.ts | 13 +- .../src/orchestration/decider.delete.test.ts | 5 +- .../decider.projectScripts.test.ts | 11 +- .../src/orchestration/projector.test.ts | 21 +- .../Layers/ProjectionRepositories.test.ts | 14 +- .../Layers/ProjectionThreadSessions.ts | 4 + .../Layers/ProviderSessionRuntime.ts | 5 + apps/server/src/persistence/Migrations.ts | 4 + .../027_028_ProviderInstanceIdColumns.test.ts | 74 + .../027_ProviderSessionRuntimeInstanceId.ts | 38 + .../028_ProjectionThreadSessionInstanceId.ts | 21 + .../Services/ProjectionThreadSessions.ts | 2 + .../Services/ProviderSessionRuntime.ts | 9 + .../src/provider/Drivers/ClaudeDriver.ts | 157 ++ .../src/provider/Drivers/ClaudeHome.test.ts | 52 + .../server/src/provider/Drivers/ClaudeHome.ts | 43 + .../src/provider/Drivers/CodexDriver.ts | 171 ++ .../provider/Drivers/CodexHomeLayout.test.ts | 209 ++ .../src/provider/Drivers/CodexHomeLayout.ts | 263 +++ .../src/provider/Drivers/CursorDriver.ts | 148 ++ .../src/provider/Drivers/OpenCodeDriver.ts | 135 ++ apps/server/src/provider/Errors.ts | 41 + .../src/provider/Layers/ClaudeAdapter.test.ts | 339 ++-- .../src/provider/Layers/ClaudeAdapter.ts | 63 +- .../src/provider/Layers/ClaudeProvider.ts | 484 ++--- .../src/provider/Layers/CodexAdapter.test.ts | 186 +- .../src/provider/Layers/CodexAdapter.ts | 65 +- .../src/provider/Layers/CodexProvider.ts | 99 +- .../provider/Layers/CodexSessionRuntime.ts | 61 +- .../src/provider/Layers/CursorAdapter.test.ts | 213 +- .../src/provider/Layers/CursorAdapter.ts | 73 +- .../provider/Layers/CursorProvider.test.ts | 159 +- .../src/provider/Layers/CursorProvider.ts | 665 ++++--- .../provider/Layers/OpenCodeAdapter.test.ts | 286 ++- .../src/provider/Layers/OpenCodeAdapter.ts | 1770 +++++++++-------- .../provider/Layers/OpenCodeProvider.test.ts | 119 +- .../src/provider/Layers/OpenCodeProvider.ts | 378 ++-- .../Layers/ProviderAdapterRegistry.test.ts | 148 +- .../Layers/ProviderAdapterRegistry.ts | 133 +- .../provider/Layers/ProviderEventLoggers.ts | 83 + .../ProviderInstanceRegistryHydration.ts | 176 ++ .../ProviderInstanceRegistryLive.test.ts | 357 ++++ .../Layers/ProviderInstanceRegistryLive.ts | 434 ++++ .../provider/Layers/ProviderRegistry.test.ts | 850 ++++++-- .../src/provider/Layers/ProviderRegistry.ts | 546 +++-- .../provider/Layers/ProviderService.test.ts | 583 ++++-- .../src/provider/Layers/ProviderService.ts | 418 +++- .../Layers/ProviderSessionDirectory.test.ts | 25 +- .../Layers/ProviderSessionDirectory.ts | 24 +- .../Layers/ProviderSessionReaper.test.ts | 31 +- .../Layers/scopedSafeTeardown.test.ts | 94 + .../src/provider/Layers/scopedSafeTeardown.ts | 61 + apps/server/src/provider/ProviderDriver.ts | 167 ++ .../ProviderInstanceEnvironment.test.ts | 21 + .../provider/ProviderInstanceEnvironment.ts | 16 + .../src/provider/Services/ClaudeAdapter.ts | 29 +- .../src/provider/Services/ClaudeProvider.ts | 9 - .../src/provider/Services/CodexAdapter.ts | 29 +- .../src/provider/Services/CodexProvider.ts | 9 - .../src/provider/Services/CursorAdapter.ts | 25 +- .../src/provider/Services/CursorProvider.ts | 9 - .../src/provider/Services/OpenCodeAdapter.ts | 25 +- .../src/provider/Services/OpenCodeProvider.ts | 9 - .../src/provider/Services/ProviderAdapter.ts | 4 +- .../Services/ProviderAdapterRegistry.ts | 81 +- .../Services/ProviderInstanceRegistry.ts | 84 + .../ProviderInstanceRegistryMutator.ts | 52 + .../src/provider/Services/ProviderRegistry.ts | 29 +- .../src/provider/Services/ProviderService.ts | 11 +- .../Services/ProviderSessionDirectory.ts | 13 +- .../provider/acp/AcpAdapterSupport.test.ts | 3 +- .../src/provider/acp/AcpAdapterSupport.ts | 4 +- .../provider/acp/AcpCoreRuntimeEvents.test.ts | 14 +- .../src/provider/acp/AcpCoreRuntimeEvents.ts | 14 +- .../src/provider/acp/AcpNativeLogging.ts | 6 +- .../src/provider/acp/AcpSessionRuntime.ts | 2 +- .../src/provider/acp/CursorAcpSupport.ts | 5 +- apps/server/src/provider/builtInDrivers.ts | 50 + .../src/provider/builtInProviderCatalog.ts | 50 +- .../makeManagedServerProvider.test.ts | 8 +- apps/server/src/provider/opencodeRuntime.ts | 8 +- .../src/provider/providerSnapshot.test.ts | 4 +- apps/server/src/provider/providerSnapshot.ts | 9 +- .../src/provider/providerStatusCache.test.ts | 106 +- .../src/provider/providerStatusCache.ts | 80 +- .../testUtils/providerAdapterRegistryMock.ts | 95 + .../provider/unavailableProviderSnapshot.ts | 72 + apps/server/src/server.test.ts | 12 +- apps/server/src/server.ts | 76 +- apps/server/src/serverRuntimeStartup.test.ts | 6 +- apps/server/src/serverRuntimeStartup.ts | 7 +- apps/server/src/serverSettings.test.ts | 242 ++- apps/server/src/serverSettings.ts | 246 ++- apps/server/src/ws.ts | 42 +- apps/web/src/components/ChatView.browser.tsx | 221 +- .../web/src/components/ChatView.logic.test.ts | 27 +- apps/web/src/components/ChatView.logic.ts | 35 +- apps/web/src/components/ChatView.tsx | 89 +- .../components/CommandPalette.logic.test.ts | 4 +- apps/web/src/components/CommandPalette.tsx | 7 +- apps/web/src/components/Icons.tsx | 23 + .../components/KeybindingsToast.browser.tsx | 30 +- apps/web/src/components/Sidebar.logic.test.ts | 15 +- apps/web/src/components/chat/ChatComposer.tsx | 187 +- .../CompactComposerControlsMenu.browser.tsx | 52 +- .../components/chat/ComposerCommandMenu.tsx | 6 +- apps/web/src/components/chat/ModelListRow.tsx | 33 +- .../components/chat/ModelPickerContent.tsx | 368 ++-- .../components/chat/ModelPickerSidebar.tsx | 373 ++-- .../components/chat/ProviderInstanceIcon.tsx | 73 + .../chat/ProviderModelPicker.browser.tsx | 252 ++- .../components/chat/ProviderModelPicker.tsx | 83 +- .../components/chat/ProviderStatusBanner.tsx | 4 +- apps/web/src/components/chat/TraitsPicker.tsx | 10 +- .../chat/composerProviderState.test.tsx | 12 +- .../components/chat/composerProviderState.tsx | 8 +- .../chat/composerSlashCommandSearch.test.ts | 11 +- .../chat/modelPickerModelHighlights.ts | 4 +- .../components/chat/modelPickerSearch.test.ts | 40 +- .../src/components/chat/modelPickerSearch.ts | 22 +- .../src/components/chat/providerIconUtils.ts | 19 +- .../settings/AddProviderInstanceDialog.tsx | 493 +++++ .../settings/ProviderInstanceCard.test.ts | 36 + .../settings/ProviderInstanceCard.tsx | 814 ++++++++ .../settings/ProviderModelsSection.tsx | 411 ++++ .../settings/SettingsPanels.browser.tsx | 10 +- .../settings/SettingsPanels.logic.test.ts | 63 + .../settings/SettingsPanels.logic.ts | 43 + .../components/settings/SettingsPanels.tsx | 1128 ++++------- .../components/settings/providerDriverMeta.ts | 151 ++ .../src/components/settings/providerStatus.ts | 91 + apps/web/src/components/ui/draft-input.tsx | 21 + apps/web/src/composerDraftStore.test.ts | 243 ++- apps/web/src/composerDraftStore.ts | 382 ++-- apps/web/src/environmentGrouping.test.ts | 4 +- .../service.threadSubscriptions.test.ts | 3 +- apps/web/src/hooks/useCommitOnBlur.ts | 49 + apps/web/src/lib/threadSort.test.ts | 10 +- apps/web/src/localApi.test.ts | 11 +- apps/web/src/modelOrdering.test.ts | 52 + apps/web/src/modelOrdering.ts | 86 + apps/web/src/modelSelection.test.ts | 251 +++ apps/web/src/modelSelection.ts | 271 ++- .../web/src/orchestrationEventEffects.test.ts | 5 +- apps/web/src/providerInstances.test.ts | 132 ++ apps/web/src/providerInstances.ts | 248 +++ apps/web/src/providerModels.ts | 47 +- apps/web/src/rpc/serverState.test.ts | 5 +- apps/web/src/rpc/wsRpcClient.ts | 12 +- apps/web/src/session-logic.ts | 22 +- apps/web/src/store.test.ts | 27 +- apps/web/src/store.ts | 21 +- apps/web/src/types.ts | 6 +- apps/web/src/worktreeCleanup.test.ts | 4 +- apps/web/vite.config.ts | 8 +- docs/providers/claude.md | 224 +++ docs/providers/codex.md | 140 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 10 +- packages/contracts/src/model.ts | 56 +- packages/contracts/src/orchestration.test.ts | 111 +- packages/contracts/src/orchestration.ts | 99 +- packages/contracts/src/provider.test.ts | 145 +- packages/contracts/src/provider.ts | 16 +- .../contracts/src/providerInstance.test.ts | 192 ++ packages/contracts/src/providerInstance.ts | 148 ++ .../contracts/src/providerRuntime.test.ts | 17 + packages/contracts/src/providerRuntime.ts | 8 +- packages/contracts/src/rpc.ts | 11 +- packages/contracts/src/server.test.ts | 24 +- packages/contracts/src/server.ts | 55 +- packages/contracts/src/settings.test.ts | 94 + packages/contracts/src/settings.ts | 99 +- packages/shared/src/model.test.ts | 42 +- packages/shared/src/model.ts | 28 +- packages/shared/src/serverSettings.test.ts | 90 +- packages/shared/src/serverSettings.ts | 17 +- 205 files changed, 17851 insertions(+), 6517 deletions(-) delete mode 100644 apps/server/src/git/Layers/RoutingTextGeneration.ts create mode 100644 apps/server/src/git/Layers/TextGenerationLive.test.ts create mode 100644 apps/server/src/git/Layers/TextGenerationLive.ts create mode 100644 apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts create mode 100644 apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts create mode 100644 apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeDriver.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeHome.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeHome.ts create mode 100644 apps/server/src/provider/Drivers/CodexDriver.ts create mode 100644 apps/server/src/provider/Drivers/CodexHomeLayout.test.ts create mode 100644 apps/server/src/provider/Drivers/CodexHomeLayout.ts create mode 100644 apps/server/src/provider/Drivers/CursorDriver.ts create mode 100644 apps/server/src/provider/Drivers/OpenCodeDriver.ts create mode 100644 apps/server/src/provider/Layers/ProviderEventLoggers.ts create mode 100644 apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts create mode 100644 apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts create mode 100644 apps/server/src/provider/Layers/scopedSafeTeardown.test.ts create mode 100644 apps/server/src/provider/Layers/scopedSafeTeardown.ts create mode 100644 apps/server/src/provider/ProviderDriver.ts create mode 100644 apps/server/src/provider/ProviderInstanceEnvironment.test.ts create mode 100644 apps/server/src/provider/ProviderInstanceEnvironment.ts delete mode 100644 apps/server/src/provider/Services/ClaudeProvider.ts delete mode 100644 apps/server/src/provider/Services/CodexProvider.ts delete mode 100644 apps/server/src/provider/Services/CursorProvider.ts delete mode 100644 apps/server/src/provider/Services/OpenCodeProvider.ts create mode 100644 apps/server/src/provider/Services/ProviderInstanceRegistry.ts create mode 100644 apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts create mode 100644 apps/server/src/provider/builtInDrivers.ts create mode 100644 apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts create mode 100644 apps/server/src/provider/unavailableProviderSnapshot.ts create mode 100644 apps/web/src/components/chat/ProviderInstanceIcon.tsx create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.tsx create mode 100644 apps/web/src/components/settings/ProviderInstanceCard.test.ts create mode 100644 apps/web/src/components/settings/ProviderInstanceCard.tsx create mode 100644 apps/web/src/components/settings/ProviderModelsSection.tsx create mode 100644 apps/web/src/components/settings/SettingsPanels.logic.test.ts create mode 100644 apps/web/src/components/settings/SettingsPanels.logic.ts create mode 100644 apps/web/src/components/settings/providerDriverMeta.ts create mode 100644 apps/web/src/components/settings/providerStatus.ts create mode 100644 apps/web/src/components/ui/draft-input.tsx create mode 100644 apps/web/src/hooks/useCommitOnBlur.ts create mode 100644 apps/web/src/modelOrdering.test.ts create mode 100644 apps/web/src/modelOrdering.ts create mode 100644 apps/web/src/modelSelection.test.ts create mode 100644 apps/web/src/providerInstances.test.ts create mode 100644 apps/web/src/providerInstances.ts create mode 100644 docs/providers/claude.md create mode 100644 docs/providers/codex.md create mode 100644 packages/contracts/src/providerInstance.test.ts create mode 100644 packages/contracts/src/providerInstance.ts create mode 100644 packages/contracts/src/settings.test.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 192d7ac1064..71b6b7df4e8 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, diffWordWrap: true, favorites: [], + providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6f9f4c6f44f..d650f62308e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -3,7 +3,8 @@ import { execFileSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, - ProviderKind, + CodexSettings, + ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, } from "@t3tools/contracts"; @@ -36,13 +37,16 @@ import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; -import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; +import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; -import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; -import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; +import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../src/provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; @@ -214,7 +218,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; readonly realCodex?: boolean; } @@ -225,7 +229,7 @@ export const makeOrchestrationIntegrationHarness = ( const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; - const provider = options?.provider ?? "codex"; + const provider = options?.provider ?? ProviderDriverKind.make("codex"); const useRealCodex = options?.realCodex === true; const adapterHarness = useRealCodex ? null @@ -233,13 +237,10 @@ export const makeOrchestrationIntegrationHarness = ( provider, }); const fakeRegistry = adapterHarness - ? Layer.succeed(ProviderAdapterRegistry, { - getByProvider: (resolvedProvider) => - resolvedProvider === adapterHarness.provider - ? Effect.succeed(adapterHarness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), - listProviders: () => Effect.succeed([adapterHarness.provider]), - } as typeof ProviderAdapterRegistry.Service) + ? Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ [adapterHarness.provider]: adapterHarness.adapter }), + ) : null; const rootDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-orchestration-integration-", @@ -264,31 +265,30 @@ export const makeOrchestrationIntegrationHarness = ( const realCodexRegistry = Layer.effect( ProviderAdapterRegistry, Effect.gen(function* () { - const codexAdapter = yield* CodexAdapter; - return { - getByProvider: (resolvedProvider) => - resolvedProvider === "codex" - ? Effect.succeed(codexAdapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), - listProviders: () => Effect.succeed(["codex"] as const), - } as typeof ProviderAdapterRegistry.Service; + const codexSettings = Schema.decodeSync(CodexSettings)({}); + const codexAdapter = yield* makeCodexAdapter(codexSettings); + return makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codexAdapter, + }); }), ).pipe( - Layer.provide(makeCodexAdapterLive()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); + const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); const providerLayer = useRealCodex ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(realCodexRegistry), Layer.provide(AnalyticsService.layerTest), + Layer.provide(providerEventLoggersLayer), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), Layer.provide(AnalyticsService.layerTest), + Layer.provide(providerEventLoggersLayer), ); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index f4973953078..9ed58c0a5b8 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -10,7 +10,7 @@ import { ProviderTurnStartResult, ThreadId, TurnId, - ProviderKind, + ProviderDriverKind, } from "@t3tools/contracts"; import { Effect, Queue, Stream } from "effect"; @@ -36,7 +36,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; } function nowIso(): string { @@ -206,7 +206,7 @@ function nowIso(): string { } function sessionNotFound( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -216,7 +216,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); @@ -224,7 +224,7 @@ function missingSessionEffect( export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => Effect.gen(function* () { - const provider = options?.provider ?? "codex"; + const provider = options?.provider ?? ProviderDriverKind.make("codex"); const runtimeEvents = yield* Queue.unbounded(); let sessionCount = 0; const sessions = new Map(); @@ -257,6 +257,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const session: ProviderSession = { provider, + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), status: "ready", runtimeMode: input.runtimeMode, threadId, diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index 14a45518c3c..e1258c4cc62 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -1,7 +1,7 @@ -import { EventId, RuntimeRequestId } from "@t3tools/contracts"; +import { EventId, ProviderDriverKind, RuntimeRequestId } from "@t3tools/contracts"; import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts"; -const PROVIDER = "codex" as const; +const PROVIDER = ProviderDriverKind.make("codex"); const SESSION_ID = "fixture-session"; const THREAD_ID = "fixture-thread"; const TURN_ID = "fixture-turn"; diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a7f845672ca..6ea4d849086 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -4,14 +4,17 @@ import path from "node:path"; import { ApprovalRequestId, CommandId, + defaultInstanceIdForDriver, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, EventId, MessageId, ProjectId, - ProviderKind, + ProviderDriverKind, ThreadId, ModelSelection, + ProviderInstanceId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Option, Schema } from "effect"; @@ -39,7 +42,9 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.make("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = ProviderKind; +type IntegrationProvider = ProviderDriverKind; +const CODEX_PROVIDER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_PROVIDER = ProviderDriverKind.make("claudeAgent"); function nowIso() { return new Date().toISOString(); @@ -74,7 +79,11 @@ function waitForSync( }); } -function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") { +function runtimeBase( + eventId: string, + createdAt: string, + provider: IntegrationProvider = CODEX_PROVIDER, +) { return { eventId: asEventId(eventId), provider, @@ -84,7 +93,7 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr function withHarness( use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, - provider: IntegrationProvider = "codex", + provider: IntegrationProvider = CODEX_PROVIDER, ) { return Effect.acquireUseRelease( makeOrchestrationIntegrationHarness({ provider }), @@ -97,7 +106,7 @@ function withRealCodexHarness( use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, ) { return Effect.acquireUseRelease( - makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), + makeOrchestrationIntegrationHarness({ provider: CODEX_PROVIDER, realCodex: true }), use, (harness) => harness.dispose, ).pipe(Effect.provide(NodeServices.layer)); @@ -106,8 +115,9 @@ function withRealCodexHarness( const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); - const provider = harness.adapterHarness?.provider ?? "codex"; - const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; + const provider = harness.adapterHarness?.provider ?? CODEX_PROVIDER; + const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const instanceId = defaultInstanceIdForDriver(provider); yield* harness.engine.dispatch({ type: "project.create", @@ -116,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => title: "Integration Project", workspaceRoot: harness.workspaceDir, defaultModelSelection: { - provider, + instanceId, model: defaultModel, }, createdAt, @@ -129,7 +139,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Thread", modelSelection: { - provider, + instanceId, model: defaultModel, }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -265,7 +275,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( title: "Integration Project", workspaceRoot: harness.workspaceDir, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", }, createdAt, @@ -278,7 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( projectId: PROJECT_ID, title: "Integration Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -912,20 +922,32 @@ it.live("starts a claudeAgent session on first turn when provider is requested", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-start-1", + "2026-02-24T10:10:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-start-2", + "2026-02-24T10:10:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Claude first turn.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-start-3", + "2026-02-24T10:10:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -939,7 +961,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", messageId: "msg-user-claude-initial", text: "Use Claude", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -955,7 +977,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", ); assert.equal(thread.session?.providerName, "claudeAgent"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -969,20 +991,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re events: [ { type: "turn.started", - ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-1", + "2026-02-24T10:11:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-2", + "2026-02-24T10:11:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Turn before restart.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-3", + "2026-02-24T10:11:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -996,7 +1030,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re messageId: "msg-user-claude-recover-1", text: "Before restart", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1018,20 +1052,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re events: [ { type: "turn.started", - ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-4", + "2026-02-24T10:11:01.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-5", + "2026-02-24T10:11:01.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Turn after restart.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-6", + "2026-02-24T10:11:01.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1063,7 +1109,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re assert.equal(recoveredThread.session?.providerName, "claudeAgent"); assert.equal(recoveredThread.session?.threadId, "thread-1"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1077,13 +1123,21 @@ it.live("forwards claudeAgent approval responses to the provider session", () => events: [ { type: "turn.started", - ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-approval-1", + "2026-02-24T10:12:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "approval.requested", - ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-approval-2", + "2026-02-24T10:12:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, @@ -1092,7 +1146,11 @@ it.live("forwards claudeAgent approval responses to the provider session", () => }, { type: "turn.completed", - ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-approval-3", + "2026-02-24T10:12:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1106,7 +1164,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => messageId: "msg-user-claude-approval", text: "Need approval", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1137,7 +1195,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => ); assert.equal(approvalResponses[0]?.decision, "accept"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1151,20 +1209,32 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => events: [ { type: "turn.started", - ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-interrupt-1", + "2026-02-24T10:13:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-interrupt-2", + "2026-02-24T10:13:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Long running output.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-interrupt-3", + "2026-02-24T10:13:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1178,7 +1248,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => messageId: "msg-user-claude-interrupt", text: "Start long turn", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1206,7 +1276,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => ); assert.equal(interruptCalls.length, 1); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1220,20 +1290,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-1", + "2026-02-24T10:14:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-2", + "2026-02-24T10:14:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "README -> v2\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-3", + "2026-02-24T10:14:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1251,7 +1333,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", messageId: "msg-user-claude-revert-1", text: "First Claude edit", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1266,20 +1348,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-4", + "2026-02-24T10:14:01.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-5", + "2026-02-24T10:14:01.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "README -> v3\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-6", + "2026-02-24T10:14:01.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1330,6 +1424,6 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", ); assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 89cf6ac153d..60fe01d4c20 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,13 +1,17 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; -import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../src/provider/Layers/ProviderEventLoggers.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { ProviderService, @@ -29,6 +33,8 @@ import { codexTurnTextFixture, } from "./fixtures/providerRuntime.ts"; +const codexInstanceId = ProviderInstanceId.make("codex"); + const makeWorkspaceDirectory = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; @@ -47,13 +53,9 @@ const makeIntegrationFixture = Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness(); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(harness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), @@ -64,6 +66,7 @@ const makeIntegrationFixture = Effect.gen(function* () { Layer.succeed(ProviderAdapterRegistry, registry), ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ).pipe(Layer.provide(SqlitePersistenceMemory)); const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); @@ -124,7 +127,8 @@ it.live("replays typed runtime fixture events", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { threadId: ThreadId.make("thread-integration-typed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); @@ -142,6 +146,10 @@ it.live("replays typed runtime fixture events", () => observedEvents.map((event) => event.type), codexTurnTextFixture.map((event) => event.type), ); + assert.deepEqual( + observedEvents.map((event) => event.providerInstanceId), + codexTurnTextFixture.map(() => codexInstanceId), + ); }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -156,7 +164,8 @@ it.live("replays file-changing fixture turn events", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { threadId: ThreadId.make("thread-integration-tools"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); @@ -192,7 +201,8 @@ it.live("runs multi-turn tool/approval flow", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { threadId: ThreadId.make("thread-integration-multi"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); @@ -243,7 +253,8 @@ it.live("rolls back provider conversation state only", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { threadId: ThreadId.make("thread-integration-rollback"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 773f781eed1..eb7bf62ad48 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -1,24 +1,18 @@ +import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; +import { type TextGenerationShape } from "../Services/TextGeneration.ts"; import { sanitizeThreadTitle } from "../Utils.ts"; -import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; -const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-claude-text-generation-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), -); +const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-claude-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); function makeFakeClaudeBinary(dir: string) { return Effect.gen(function* () { @@ -52,6 +46,10 @@ function makeFakeClaudeBinary(dir: string) { " exit 4", " }", "fi", + 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', + ' printf "%s\\n" "HOME was $HOME" >&2', + " exit 5", + "fi", 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', "fi", @@ -73,23 +71,26 @@ function withFakeClaudeEnv( argsMustContain?: string; argsMustNotContain?: string; stdinMustContain?: string; + homeMustBe?: string; + claudeConfig?: Partial; }, - effect: Effect.Effect, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, ) { - return Effect.acquireUseRelease( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); - const binDir = yield* makeFakeClaudeBinary(tempDir); - const previousPath = process.env.PATH; - const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; - const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; - const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); + const binDir = yield* makeFakeClaudeBinary(tempDir); + const previousPath = process.env.PATH; + const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; + const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; + const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; + const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; - yield* Effect.sync(() => { + yield* Effect.acquireRelease( + Effect.sync(() => { process.env.PATH = `${binDir}:${previousPath ?? ""}`; process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; @@ -122,60 +123,65 @@ function withFakeClaudeEnv( } else { delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; } - }); - - return { - previousPath, - previousOutput, - previousExitCode, - previousStderr, - previousArgsMustContain, - previousArgsMustNotContain, - previousStdinMustContain, - }; - }), - () => effect, - (previous) => - Effect.sync(() => { - process.env.PATH = previous.previousPath; - if (previous.previousOutput === undefined) { - delete process.env.T3_FAKE_CLAUDE_OUTPUT; + if (input.homeMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; } else { - process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput; + delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; } + }), + () => + Effect.sync(() => { + process.env.PATH = previousPath; - if (previous.previousExitCode === undefined) { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } else { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode; - } + if (previousOutput === undefined) { + delete process.env.T3_FAKE_CLAUDE_OUTPUT; + } else { + process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; + } - if (previous.previousStderr === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } else { - process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr; - } + if (previousExitCode === undefined) { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } else { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; + } - if (previous.previousArgsMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain; - } + if (previousStderr === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } else { + process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; + } - if (previous.previousArgsMustNotContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain; - } + if (previousArgsMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; + } - if (previous.previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; - } - }), - ); + if (previousArgsMustNotContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; + } + + if (previousStdinMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; + } + + if (previousHomeMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + } else { + process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; + } + }), + ); + + const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {}); + const textGeneration = yield* makeClaudeTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); } it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { @@ -191,24 +197,23 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { argsMustContain: '--settings {"alwaysThinkingEnabled":false}', argsMustNotContain: "--effort", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/claude-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection("claudeAgent", "claude-haiku-4-5", [ - { id: "thinking", value: false }, - { id: "effort", value: "high" }, - ]), - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/claude-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ]), + }, + }); - expect(generated.subject).toBe("Add important change"); - }), + expect(generated.subject).toBe("Add important change"); + }), ), ); @@ -223,26 +228,25 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { }), argsMustContain: '--effort max --settings {"fastMode":true}', }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/claude-effect", - commitSummary: "Improve orchestration", - diffSummary: "1 file changed", - diffPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]), - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), + }, + }); - expect(generated.title).toBe("Improve orchestration flow"); - }), + expect(generated.title).toBe("Improve orchestration flow"); + }), ), ); @@ -257,27 +261,57 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { }), stdinMustContain: "You write concise thread titles for coding conversations.", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate reconnect failures after restarting the session.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate reconnect failures after restarting the session.", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - expect(generated.title).toBe( - sanitizeThreadTitle( - '"Reconnect failures after restart because the session state does not recover"', - ), - ); - }), + expect(generated.title).toBe( + sanitizeThreadTitle( + '"Reconnect failures after restart because the session state does not recover"', + ), + ); + }), ), ); + it.effect("runs Claude text generation with the configured Claude HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHome = path.join(process.cwd(), ".claude-work-test"); + return yield* withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Use Claude home", + }, + }), + homeMustBe: claudeHome, + claudeConfig: { homePath: claudeHome }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "thread title", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); + }), + ); + }), + ); + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => withFakeClaudeEnv( { @@ -287,20 +321,19 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { }, }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); - expect(generated.title).toBe("New thread"); - }), + expect(generated.title).toBe("New thread"); + }), ), ); }); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 8175dc54b22..33bf7bc0141 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -7,14 +7,14 @@ * * @module ClaudeTextGeneration */ -import { Effect, Layer, Option, Schema, Stream } from "effect"; +import { Effect, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { ClaudeModelSelection } from "@t3tools/contracts"; +import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { type TextGenerationShape } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -38,7 +38,7 @@ import { resolveClaudeApiModelId, resolveClaudeEffort, } from "../../provider/Layers/ClaudeProvider.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -50,9 +50,12 @@ const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, }); -const makeClaudeTextGeneration = Effect.gen(function* () { +export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverSettingsService = yield* Effect.service(ServerSettingsService); + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const readStreamAsString = ( operation: string, @@ -88,7 +91,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { cwd: string; prompt: string; outputSchemaJson: S; - modelSelection: ClaudeModelSelection; + modelSelection: ModelSelection; }): Effect.fn.Return { const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); const caps = getClaudeModelCapabilities(modelSelection.model); @@ -111,14 +114,9 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ...(fastMode ? { fastMode: true } : {}), }; - const claudeSettings = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => settings.providers.claudeAgent, - ).pipe(Effect.catch(() => Effect.undefined)); - const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { const command = ChildProcess.make( - claudeSettings?.binaryPath || "claude", + claudeSettings.binaryPath || "claude", [ "-p", "--output-format", @@ -132,6 +130,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--dangerously-skip-permissions", ], { + env: claudeEnvironment, cwd, shell: process.platform === "win32", stdin: { @@ -232,13 +231,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { includeBranch: input.includeBranch === true, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generateCommitMessage", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generateCommitMessage", cwd: input.cwd, @@ -267,13 +259,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { diffPatch: input.diffPatch, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generatePrContent", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generatePrContent", cwd: input.cwd, @@ -296,13 +281,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generateBranchName", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generateBranchName", cwd: input.cwd, @@ -324,13 +302,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generateThreadTitle", cwd: input.cwd, @@ -351,5 +322,3 @@ const makeClaudeTextGeneration = Effect.gen(function* () { generateThreadTitle, } satisfies TextGenerationShape; }); - -export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 2adfcca8483..f38d6a68a87 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -1,30 +1,24 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Result } from "effect"; +import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; +import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; + import { ServerConfig } from "../../config.ts"; -import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; -import { TextGenerationError } from "@t3tools/contracts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; - -const DEFAULT_TEST_MODEL_SELECTION = { - provider: "codex" as const, - model: "gpt-5.4-mini", -}; - -const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-codex-text-generation-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), +import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; + +const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.4-mini", ); +const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-codex-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + function makeFakeCodexBinary( dir: string, input: { @@ -162,36 +156,16 @@ function withFakeCodexEnv( stdinMustContain?: string; stdinMustNotContain?: string; }, - effect: Effect.Effect, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, ) { - return Effect.acquireUseRelease( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); - const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const serverSettings = yield* ServerSettingsService; - const previousSettings = yield* serverSettings.getSettings; - yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: codexPath, - }, - }, - }); - return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; - }), - () => effect, - ({ serverSettings, previousBinaryPath }) => - serverSettings - .updateSettings({ - providers: { - codex: { - binaryPath: previousBinaryPath, - }, - }, - }) - .pipe(Effect.asVoid), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); + const codexPath = yield* makeFakeCodexBinary(tempDir, input); + const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath }); + const textGeneration = yield* makeCodexTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); } it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { @@ -205,22 +179,21 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustNotContain: "branch must be a short semantic git branch fragment", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.subject.length).toBeLessThanOrEqual(72); - expect(generated.subject.endsWith(".")).toBe(false); - expect(generated.body).toBe("- added migration\n- updated tests"); - expect(generated.branch).toBeUndefined(); - }), + expect(generated.subject.length).toBeLessThanOrEqual(72); + expect(generated.subject.endsWith(".")).toBe(false); + expect(generated.body).toBe("- added migration\n- updated tests"); + expect(generated.branch).toBeUndefined(); + }), ), ); @@ -237,20 +210,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { requireReasoningEffort: "xhigh", stdinMustNotContain: "branch must be a short semantic git branch fragment", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - yield* textGeneration.generateCommitMessage({ + (textGeneration) => + textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/codex-effect", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection("codex", "gpt-5.4", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "reasoningEffort", value: "xhigh" }, { id: "fastMode", value: true }, ]), - }); - }), + }), ), ); @@ -263,17 +233,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), requireReasoningEffort: "low", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - yield* textGeneration.generateCommitMessage({ + (textGeneration) => + textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/codex-effect", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - }), + }), ), ); @@ -287,21 +254,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustContain: "branch must be a short semantic git branch fragment", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - includeBranch: true, - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + includeBranch: true, + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.subject).toBe("Add important change"); - expect(generated.branch).toBe("feature/fix/important-system-change"); - }), + expect(generated.subject).toBe("Add important change"); + expect(generated.branch).toBe("feature/fix/important-system-change"); + }), ), ); @@ -313,23 +279,22 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/codex-effect", - commitSummary: "feat: improve orchestration flow", - diffSummary: "2 files changed", - diffPatch: "diff --git a/a.ts b/a.ts", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/codex-effect", + commitSummary: "feat: improve orchestration flow", + diffSummary: "2 files changed", + diffPatch: "diff --git a/a.ts b/a.ts", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("Improve orchestration flow"); - expect(generated.body.startsWith("## Summary")).toBe(true); - expect(generated.body.endsWith("\n\n")).toBe(false); - }), + expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.body.startsWith("## Summary")).toBe(true); + expect(generated.body.endsWith("\n\n")).toBe(false); + }), ), ); @@ -341,17 +306,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustNotContain: "Image attachments supplied to the model", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Please update session handling.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Please update session handling.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.branch).toBe("feat/session"); - }), + expect(generated.branch).toBe("feat/session"); + }), ), ); @@ -363,17 +327,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate websocket reconnect regressions after a worktree restore.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate websocket reconnect regressions after a worktree restore.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); - }), + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + }), ), ); @@ -384,17 +347,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { title: ' """ """ ', }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("New thread"); - }), + expect(generated.title).toBe("New thread"); + }), ), ); @@ -405,17 +367,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { title: ` "' hello world '" `, }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("hello world"); - }), + expect(generated.title).toBe("hello world"); + }), ), ); @@ -427,17 +388,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustNotContain: "Attachment metadata:", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Fix timeout behavior.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix timeout behavior.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.branch).toBe("fix/session-timeout"); - }), + expect(generated.branch).toBe("fix/session-timeout"); + }), ), ); @@ -450,56 +410,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { requireImage: true, stdinMustContain: "Attachment metadata:", }, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(attachmentPath, Buffer.from("hello")); - - const textGeneration = yield* TextGeneration; - const generated = yield* textGeneration.generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }); - - expect(generated.branch).toBe("fix/ui-regression"); - }), - ), - ); - - it.effect("resolves persisted attachment ids to files for codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - }, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(imagePath, Buffer.from("hello")); - - const textGeneration = yield* TextGeneration; - const generated = yield* textGeneration - .generateBranchName({ + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); + yield* fs.writeFile(attachmentPath, Buffer.from("hello")); + + const generated = yield* textGeneration.generateBranchName({ modelSelection: DEFAULT_TEST_MODEL_SELECTION, cwd: process.cwd(), message: "Fix layout bug from screenshot.", @@ -512,24 +433,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { sizeBytes: 5, }, ], - }) - .pipe( - Effect.tap(() => - fs.stat(imagePath).pipe( - Effect.map((fileInfo) => { - expect(fileInfo.type).toBe("File"); - }), - ), - ), - Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), - ); + }); - expect(generated.branch).toBe("fix/ui-regression"); - }), + expect(generated.branch).toBe("fix/ui-regression"); + }), ), ); - it.effect("ignores missing attachment ids for codex image inputs", () => + it.effect("resolves persisted attachment ids to files for codex image inputs", () => withFakeCodexEnv( { output: JSON.stringify({ @@ -537,67 +448,115 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), requireImage: true, }, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); - - const textGeneration = yield* TextGeneration; - const result = yield* textGeneration - .generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: missingAttachmentId, - name: "outside.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("missing --image input"); - } - }), + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); + yield* fs.writeFile(imagePath, Buffer.from("hello")); + + const generated = yield* textGeneration + .generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe( + Effect.tap(() => + fs.stat(imagePath).pipe( + Effect.map((fileInfo) => { + expect(fileInfo.type).toBe("File"); + }), + ), + ), + Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), + ); + + expect(generated.branch).toBe("fix/ui-regression"); + }), ), ); - it.effect( - "fails with typed TextGenerationError when codex returns wrong branch payload shape", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: "This is not a branch payload", - }), - }, + it.effect("ignores missing attachment ids for codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + (textGeneration) => Effect.gen(function* () { - const textGeneration = yield* TextGeneration; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); + yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); const result = yield* textGeneration .generateBranchName({ - cwd: process.cwd(), - message: "Fix websocket reconnect flake", modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: missingAttachmentId, + name: "outside.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], }) .pipe(Effect.result); expect(Result.isFailure(result)).toBe(true); if (Result.isFailure(result)) { expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("Codex returned invalid structured output"); + expect(result.failure.message).toContain("missing --image input"); } }), + ), + ); + + it.effect( + "fails with typed TextGenerationError when codex returns wrong branch payload shape", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: "This is not a branch payload", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix websocket reconnect flake", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain("Codex returned invalid structured output"); + } + }), ), ); @@ -608,27 +567,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { exitCode: 1, stderr: "codex execution failed", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-error", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); - const result = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-error", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain( - "Codex CLI command failed: codex execution failed", - ); - } - }), + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain( + "Codex CLI command failed: codex execution failed", + ); + } + }), ), ); }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index ee1e39d0fa0..d4bc8f16327 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -1,9 +1,7 @@ -import { randomUUID } from "node:crypto"; - -import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; +import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { CodexModelSelection } from "@t3tools/contracts"; +import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -14,7 +12,6 @@ import { type BranchNameGenerationInput, type ThreadTitleGenerationResult, type TextGenerationShape, - TextGeneration, } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, @@ -29,7 +26,6 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, @@ -37,12 +33,18 @@ import { const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -const makeCodexTextGeneration = Effect.gen(function* () { +/** + * Build a Codex text-generation closure bound to a specific `CodexSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( + codexConfig: CodexSettings, + environment: NodeJS.ProcessEnv = process.env, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); - const serverSettingsService = yield* Effect.service(ServerSettingsService); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -68,21 +70,23 @@ const makeCodexTextGeneration = Effect.gen(function* () { prefix: string, content: string, ): Effect.Effect => { - return fileSystem - .makeTempFileScoped({ - prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, - }) - .pipe( - Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: `Failed to write temp file`, - cause, - }), - ), - ); + return Effect.gen(function* () { + const tempFileId = yield* Random.nextUUIDv4; + return yield* fileSystem + .makeTempFileScoped({ + prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, + }) + .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); + }).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to write temp file`, + cause, + }), + ), + ); }; const safeUnlink = (filePath: string): Effect.Effect => @@ -143,7 +147,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; - modelSelection: CodexModelSelection; + modelSelection: ModelSelection; }): Effect.fn.Return { const schemaPath = yield* writeTempFile( operation, @@ -152,17 +156,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); - const codexSettings = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => settings.providers.codex, - ).pipe(Effect.catch(() => Effect.undefined)); - const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( - codexSettings?.binaryPath || "codex", + codexConfig.binaryPath || "codex", [ "exec", "--ephemeral", @@ -185,10 +184,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { ], { env: { - ...process.env, - ...(codexSettings?.homePath - ? { CODEX_HOME: expandHomePath(codexSettings.homePath) } - : {}), + ...environment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), }, cwd, shell: process.platform === "win32", @@ -288,13 +285,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { includeBranch: input.includeBranch === true, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generateCommitMessage", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, @@ -323,13 +313,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { diffPatch: input.diffPatch, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generatePrContent", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generatePrContent", cwd: input.cwd, @@ -356,13 +339,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generateBranchName", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generateBranchName", cwd: input.cwd, @@ -389,13 +365,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generateThreadTitle", cwd: input.cwd, @@ -418,4 +387,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { } satisfies TextGenerationShape; }); -export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration); +// NOTE: `CodexTextGenerationLive` (the singleton Layer) has been removed. +// `makeCodexTextGeneration(codexConfig)` is now invoked directly by +// `CodexDriver.create()` for each configured instance. diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index b8d974fd94d..3718557664c 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -5,16 +5,15 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Schema } from "effect"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; -import { ServerSettingsError } from "@t3tools/contracts"; +import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); @@ -23,15 +22,9 @@ function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-cursor-text-generation-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), -); +const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpAgentWrapper(dir: string, env: Record): string { const binDir = path.join(dir, "bin"); @@ -57,44 +50,20 @@ function makeAcpAgentWrapper(dir: string, env: Record): string { function withFakeAcpAgent( env: Record, - effect: Effect.Effect, -): Effect.Effect { + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { return Effect.gen(function* () { const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); - const agentPath = makeAcpAgentWrapper(tempDir, env); - const serverSettings = yield* ServerSettingsService; - const previousSettings = yield* serverSettings.getSettings; - - yield* serverSettings.updateSettings({ - providers: { - cursor: { - binaryPath: agentPath, - }, - }, - }); - - return yield* effect.pipe( - Effect.ensuring( - serverSettings - .updateSettings({ - providers: { - cursor: { - binaryPath: previousSettings.providers.cursor.binaryPath, - }, - }, - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.ensuring( - Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); - }), - ), - Effect.asVoid, - ), - ), + yield* Effect.addFinalizer(() => + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), ); - }); + const agentPath = makeAcpAgentWrapper(tempDir, env); + const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath }); + const textGeneration = yield* makeCursorTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); } function waitForFileContent(path: string): Effect.Effect { @@ -126,85 +95,86 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { body: "- verify cursor acp model config path", }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-text-generation", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", - modelSelection: { - ...createModelSelection("cursor", "gpt-5.4", [ - { id: "reasoning", value: "xhigh" }, - { id: "fastMode", value: true }, - { id: "contextWindow", value: "1m" }, - ]), - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), + }, + }); - expect(generated.subject).toBe("Add generated commit message"); - expect(generated.body).toBe("- verify cursor acp model config path"); + expect(generated.subject).toBe("Add generated commit message"); + expect(generated.body).toBe("- verify cursor acp model config path"); - const requests = readFileSync(requestLogPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: Record }); + const requests = readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map( + (line) => JSON.parse(line) as { method?: string; params?: Record }, + ); - expect( - requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, - ).toMatchObject({ - _meta: { - parameterizedModelPicker: true, - }, - }); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "model" && - request.params?.value === "gpt-5.4", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "reasoning" && - request.params?.value === "extra-high", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "context" && - request.params?.value === "1m", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "fast" && - request.params?.value === "true", - ), - ).toBe(true); - expect( - requests.find((request) => request.method === "session/prompt")?.params?.prompt, - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "text", - text: expect.stringContaining("Staged patch:"), - }), - ]), - ); + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + _meta: { + parameterizedModelPicker: true, + }, + }); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "gpt-5.4", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "reasoning" && + request.params?.value === "extra-high", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "context" && + request.params?.value === "1m", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "fast" && + request.params?.value === "true", + ), + ).toBe(true); + expect( + requests.find((request) => request.method === "session/prompt")?.params?.prompt, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Staged patch:"), + }), + ]), + ); - rmSync(requestLogDir, { recursive: true, force: true }); - }), + rmSync(requestLogDir, { recursive: true, force: true }); + }), ); }); @@ -214,23 +184,22 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { T3_ACP_PROMPT_RESPONSE_TEXT: 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-noisy-json", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-noisy-json", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); - expect(generated.subject).toBe("Update README dummy comment with attribution and date"); - expect(generated.body).toBe(""); - }), + expect(generated.subject).toBe("Update README dummy comment with attribution and date"); + expect(generated.body).toBe(""); + }), ), ); @@ -241,20 +210,19 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { title: '"Trim reconnect spinner status after resume."', }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Fix the reconnect spinner after a resumed session.", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); - expect(generated.title).toBe("Trim reconnect spinner status after resume."); - }), + expect(generated.title).toBe("Trim reconnect spinner status after resume."); + }), ), ); @@ -270,28 +238,27 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { body: "", }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-runtime-close", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-runtime-close", + stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); - expect(generated.subject).toBe("Close runtime after generation"); + expect(generated.subject).toBe("Close runtime after generation"); - const exitLog = yield* waitForFileContent(exitLogPath); - expect(exitLog).toContain("exit:0"); + const exitLog = yield* waitForFileContent(exitLogPath); + expect(exitLog).toContain("exit:0"); - rmSync(exitLogDir, { recursive: true, force: true }); - }), + rmSync(exitLogDir, { recursive: true, force: true }); + }), ); }); }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 6b78728b953..c94c6dd180a 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -1,14 +1,13 @@ -import { Effect, Layer, Option, Ref, Schema } from "effect"; +import { Effect, Option, Ref, Schema } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { CursorModelSelection } from "@t3tools/contracts"; +import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts"; import { type ThreadTitleGenerationResult, type TextGenerationShape, - TextGeneration, } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, @@ -26,7 +25,6 @@ import { applyCursorAcpModelSelection, makeCursorAcpRuntime, } from "../../provider/acp/CursorAcpSupport.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; const CURSOR_TIMEOUT_MS = 180_000; @@ -55,9 +53,15 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { ); } -const makeCursorTextGeneration = Effect.gen(function* () { +/** + * Build a Cursor text-generation closure bound to a specific `CursorSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverSettingsService = yield* Effect.service(ServerSettingsService); const runCursorJson = ({ operation, @@ -74,17 +78,13 @@ const makeCursorTextGeneration = Effect.gen(function* () { cwd: string; prompt: string; outputSchemaJson: S; - modelSelection: CursorModelSelection; + modelSelection: ModelSelection; }): Effect.Effect => Effect.gen(function* () { - const cursorSettings = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => settings.providers.cursor, - ).pipe(Effect.catch(() => Effect.undefined)); - const outputRef = yield* Ref.make(""); const runtime = yield* makeCursorAcpRuntime({ cursorSettings, + environment, childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, @@ -186,13 +186,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { includeBranch: input.includeBranch === true, }); - if (input.modelSelection.provider !== "cursor") { - return yield* new TextGenerationError({ - operation: "generateCommitMessage", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCursorJson({ operation: "generateCommitMessage", cwd: input.cwd, @@ -221,13 +214,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { diffPatch: input.diffPatch, }); - if (input.modelSelection.provider !== "cursor") { - return yield* new TextGenerationError({ - operation: "generatePrContent", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCursorJson({ operation: "generatePrContent", cwd: input.cwd, @@ -250,13 +236,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "cursor") { - return yield* new TextGenerationError({ - operation: "generateBranchName", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCursorJson({ operation: "generateBranchName", cwd: input.cwd, @@ -278,13 +257,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "cursor") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCursorJson({ operation: "generateThreadTitle", cwd: input.cwd, @@ -306,4 +278,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { } satisfies TextGenerationShape; }); -export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration); +// NOTE: `CursorTextGenerationLive` (the singleton Layer) has been removed. +// `makeCursorTextGeneration(cursorConfig)` is now invoked directly by +// `CursorDriver.create()` so each provider instance owns its own closure. diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts index 28ee0a3e6fe..c3bdd6035eb 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -1,19 +1,19 @@ +import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Duration, Effect, Layer } from "effect"; +import { Duration, Effect, Layer, Schema } from "effect"; import { TestClock } from "effect/testing"; import { NetService } from "@t3tools/shared/Net"; import { beforeEach, expect } from "vitest"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { OpenCodeRuntime, OpenCodeRuntimeError, type OpenCodeRuntimeShape, } from "../../provider/opencodeRuntime.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; +import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; const runtimeMock = { state: { @@ -97,23 +97,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }; const DEFAULT_TEST_MODEL_SELECTION = { - provider: "opencode" as const, + instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; -const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge( - ServerSettingsService.layerTest({ - providers: { - opencode: { - binaryPath: "fake-opencode", - }, - }, - }), - ), +const OpenCodeTextGenerationTestLayer = Layer.succeed( + OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-test-", @@ -123,19 +116,10 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( Layer.provideMerge(NodeServices.layer), ); -const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge( - ServerSettingsService.layerTest({ - providers: { - opencode: { - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }, - }, - }), - ), +const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( + OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-existing-server-test-", @@ -145,6 +129,25 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive Layer.provideMerge(NodeServices.layer), ); +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", +}); +const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", +}); + +function withOpenCodeTextGeneration( + settings: OpenCodeSettings, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration(settings); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + beforeEach(() => { runtimeMock.reset(); }); @@ -157,155 +160,40 @@ const advanceIdleClock = Effect.gen(function* () { it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4301", - ]); - expect(runtimeMock.state.closeCalls).toEqual([]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("starts a new server after the warm server idles out", () => - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - yield* advanceIdleClock; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4302", - ]); - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => - Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; - const textGeneration = yield* TextGeneration; - - const error = yield* textGeneration - .generateCommitMessage({ + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/opencode-reuse", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode returned empty output."); - }), - ); - - it.effect("parses JSON returned as plain text output", () => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [ - { - type: "text", - text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', - }, - ], - }, - }; - const textGeneration = yield* TextGeneration; - - const result = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(result).toEqual({ - subject: "Tighten OpenCode parsing", - body: "Handle JSON text output locally.", - }); - }), - ); - - it.effect("surfaces the upstream OpenCode structured-output error message", () => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - info: { - error: { - name: "StructuredOutputError", - data: { - message: "Model did not produce structured output", - retries: 2, - }, - }, - }, - }, - }; - const textGeneration = yield* TextGeneration; - - const error = yield* textGeneration - .generateCommitMessage({ + }); + yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/opencode-reuse", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.flip); + }); - expect(error.message).toContain("Model did not produce structured output"); - }), + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), ); -}); -it.layer(OpenCodeTextGenerationExistingServerTestLayer)( - "OpenCodeTextGenerationLive with configured server URL", - (it) => { - it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + it.effect("starts a new server after the warm server idles out", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/opencode-reuse", @@ -313,6 +201,9 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( stagedPatch: "diff --git a/README.md b/README.md", modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); + + yield* advanceIdleClock; + yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/opencode-reuse", @@ -321,20 +212,135 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); - expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:9999", - "http://127.0.0.1:9999", - ]); - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:secret-password")}`, - `Basic ${btoa("opencode:secret-password")}`, + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); - yield* advanceIdleClock; + it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { data: {} }; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned empty output."); + }), + ), + ); - expect(runtimeMock.state.closeCalls).toEqual([]); - }).pipe(Effect.provide(TestClock.layer())), + it.effect("parses JSON returned as plain text output", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', + }, + ], + }, + }; + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(result).toEqual({ + subject: "Tighten OpenCode parsing", + body: "Handle JSON text output locally.", + }); + }), + ), + ); + + it.effect("surfaces the upstream OpenCode structured-output error message", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + info: { + error: { + name: "StructuredOutputError", + data: { + message: "Model did not produce structured output", + retries: 2, + }, + }, + }, + }, + }; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("Model did not produce structured output"); + }), + ), + ); +}); + +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( + "OpenCodeTextGenerationLive with configured server URL", + (it) => { + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }), + ).pipe(Effect.provide(TestClock.layer())), ); }, ); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index d9ebb094e72..f2d0b4b2724 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -1,24 +1,24 @@ -import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; +import { Effect, Exit, Fiber, Schema, Scope } from "effect"; import * as Semaphore from "effect/Semaphore"; import { TextGenerationError, type ChatAttachment, - type OpenCodeModelSelection, + type ModelSelection, + type OpenCodeSettings, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { type TextGenerationShape } from "../Services/TextGeneration.ts"; import { extractJsonObject, sanitizeCommitSubject, @@ -93,9 +93,11 @@ interface SharedOpenCodeTextGenerationServerState { idleCloseFiber: Fiber.Fiber | null; } -const makeOpenCodeTextGeneration = Effect.gen(function* () { +export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( + openCodeSettings: OpenCodeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { const serverConfig = yield* ServerConfig; - const serverSettingsService = yield* ServerSettingsService; const openCodeRuntime = yield* OpenCodeRuntime; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), @@ -202,6 +204,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { openCodeRuntime .startOpenCodeServerProcess({ binaryPath: input.binaryPath, + environment, }) .pipe( Effect.provideService(Scope.Scope, serverScope), @@ -267,7 +270,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; - readonly modelSelection: OpenCodeModelSelection; + readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; }) { const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); @@ -278,26 +281,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { }); } - const settings = yield* serverSettingsService.getSettings.pipe( - Effect.map( - (value) => - value.providers?.opencode ?? { - enabled: true, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - }, - ), - Effect.orElseSucceed(() => ({ - enabled: true, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - })), - ); - const fileParts = toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => @@ -310,8 +293,8 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, - ...(settings.serverUrl.length > 0 && settings.serverPassword - ? { serverPassword: settings.serverPassword } + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } : {}), }); const session = await client.session.create({ @@ -354,11 +337,11 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { }); const rawOutput = - settings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: settings.serverUrl }) + openCodeSettings.serverUrl.length > 0 + ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) : yield* Effect.acquireUseRelease( acquireSharedServer({ - binaryPath: settings.binaryPath, + binaryPath: openCodeSettings.binaryPath, operation: input.operation, }), runAgainstServer, @@ -383,13 +366,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( "OpenCodeTextGeneration.generateCommitMessage", )(function* (input) { - if (input.modelSelection.provider !== "opencode") { - return yield* new TextGenerationError({ - operation: "generateCommitMessage", - detail: "Invalid model selection.", - }); - } - const { prompt, outputSchema } = buildCommitMessagePrompt({ branch: input.branch, stagedSummary: input.stagedSummary, @@ -416,13 +392,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( "OpenCodeTextGeneration.generatePrContent", )(function* (input) { - if (input.modelSelection.provider !== "opencode") { - return yield* new TextGenerationError({ - operation: "generatePrContent", - detail: "Invalid model selection.", - }); - } - const { prompt, outputSchema } = buildPrContentPrompt({ baseBranch: input.baseBranch, headBranch: input.headBranch, @@ -447,13 +416,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( "OpenCodeTextGeneration.generateBranchName", )(function* (input) { - if (input.modelSelection.provider !== "opencode") { - return yield* new TextGenerationError({ - operation: "generateBranchName", - detail: "Invalid model selection.", - }); - } - const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, @@ -475,13 +437,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( "OpenCodeTextGeneration.generateThreadTitle", )(function* (input) { - if (input.modelSelection.provider !== "opencode") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, attachments: input.attachments, @@ -507,5 +462,3 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { generateThreadTitle, } satisfies TextGenerationShape; }); - -export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts deleted file mode 100644 index 873d616f39a..00000000000 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * RoutingTextGeneration – Dispatches text generation requests to either the - * Codex CLI or Claude CLI implementation based on the provider in each - * request input. - * - * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to - * the Claude layer; for any other value (including the default `undefined`) it - * falls through to the Codex layer. - * - * @module RoutingTextGeneration - */ -import { Effect, Layer, Context } from "effect"; - -import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; -import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; -import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; -import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; - -// --------------------------------------------------------------------------- -// Internal service tags so both concrete layers can coexist. -// --------------------------------------------------------------------------- - -class CodexTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/CodexTextGen", -) {} - -class ClaudeTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", -) {} - -class CursorTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/CursorTextGen", -) {} - -class OpenCodeTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", -) {} - -// --------------------------------------------------------------------------- -// Routing implementation -// --------------------------------------------------------------------------- - -const makeRoutingTextGeneration = Effect.gen(function* () { - const byProvider = { - codex: yield* CodexTextGen, - claudeAgent: yield* ClaudeTextGen, - cursor: yield* CursorTextGen, - opencode: yield* OpenCodeTextGen, - }; - - return { - generateCommitMessage: (input) => - byProvider[input.modelSelection.provider].generateCommitMessage(input), - generatePrContent: (input) => - byProvider[input.modelSelection.provider].generatePrContent(input), - generateBranchName: (input) => - byProvider[input.modelSelection.provider].generateBranchName(input), - generateThreadTitle: (input) => - byProvider[input.modelSelection.provider].generateThreadTitle(input), - } satisfies TextGenerationShape; -}); - -const InternalCodexLayer = Layer.effect( - CodexTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(CodexTextGenerationLive)); - -const InternalClaudeLayer = Layer.effect( - ClaudeTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(ClaudeTextGenerationLive)); - -const InternalCursorLayer = Layer.effect( - CursorTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(CursorTextGenerationLive)); - -const InternalOpenCodeLayer = Layer.effect( - OpenCodeTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(OpenCodeTextGenerationLive)); - -export const RoutingTextGenerationLive = Layer.effect( - TextGeneration, - makeRoutingTextGeneration, -).pipe( - Layer.provide(InternalCodexLayer), - Layer.provide(InternalClaudeLayer), - Layer.provide(InternalCursorLayer), - Layer.provide(InternalOpenCodeLayer), -); diff --git a/apps/server/src/git/Layers/TextGenerationLive.test.ts b/apps/server/src/git/Layers/TextGenerationLive.test.ts new file mode 100644 index 00000000000..3b03696eb42 --- /dev/null +++ b/apps/server/src/git/Layers/TextGenerationLive.test.ts @@ -0,0 +1,117 @@ +import { it } from "@effect/vitest"; +import { Effect, PubSub, Result, Stream } from "effect"; +import { describe, expect } from "vitest"; + +import { ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; +import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import type { TextGenerationShape } from "../Services/TextGeneration.ts"; + +import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts"; + +const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ + generateCommitMessage: () => + Effect.die("generateCommitMessage stub not configured for this test"), + generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), + generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), + generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + ...overrides, +}); + +const makeStubInstance = ( + instanceId: ProviderInstanceId, + textGeneration: TextGenerationShape, +): ProviderInstance => + ({ + instanceId, + driverKind: instanceId as unknown as ProviderInstance["driverKind"], + continuationIdentity: { + driverKind: instanceId as unknown as ProviderInstance["driverKind"], + continuationKey: `${instanceId}:test`, + }, + displayName: undefined, + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + adapter: {} as ProviderInstance["adapter"], + textGeneration, + }) satisfies ProviderInstance; + +const makeStubRegistry = ( + instances: ReadonlyArray, +): ProviderInstanceRegistryShape => { + const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); + return { + getInstance: (id) => Effect.succeed(byId.get(id)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + // Tests never drive changes through this stub; acquire a throwaway + // subscription on an unused PubSub so the shape is satisfied. + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +}; + +describe("makeTextGenerationFromRegistry", () => { + it.effect("delegates to the matching instance's textGeneration closure", () => + Effect.gen(function* () { + const personalId = ProviderInstanceId.make("codex_personal"); + const personalCalls: string[] = []; + const personal = makeStubInstance( + personalId, + makeStubTextGeneration({ + generateBranchName: (input) => { + personalCalls.push(input.message); + return Effect.succeed({ branch: "personal-branch" }); + }, + }), + ); + + const workId = ProviderInstanceId.make("codex_work"); + const work = makeStubInstance( + workId, + makeStubTextGeneration({ + generateBranchName: () => Effect.succeed({ branch: "work-branch" }), + }), + ); + + const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); + + const result = yield* tg.generateBranchName({ + cwd: process.cwd(), + message: "Refactor the routing layer", + modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), + }); + + expect(result.branch).toBe("personal-branch"); + expect(personalCalls).toEqual(["Refactor the routing layer"]); + }), + ); + + it.effect("fails with TextGenerationError when the instance is unknown", () => + Effect.gen(function* () { + const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); + + const result = yield* tg + .generateBranchName({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection( + ProviderInstanceId.make("missing_instance"), + "gpt-5", + ), + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("TextGenerationError"); + expect(result.failure.operation).toBe("generateBranchName"); + expect(result.failure.detail).toContain("missing_instance"); + } + }), + ); +}); diff --git a/apps/server/src/git/Layers/TextGenerationLive.ts b/apps/server/src/git/Layers/TextGenerationLive.ts new file mode 100644 index 00000000000..58e1541d55c --- /dev/null +++ b/apps/server/src/git/Layers/TextGenerationLive.ts @@ -0,0 +1,101 @@ +/** + * TextGenerationLive — registry-backed implementation of the `TextGeneration` + * service tag. + * + * The `TextGeneration` tag is kept as a thin facade over + * `ProviderInstanceRegistry`. Every op pulls `modelSelection.instanceId`, + * looks up the matching `ProviderInstance`, and delegates to that instance's + * own `textGeneration` closure (built by its driver's `create()`). + * + * There is deliberately no per-driver dispatch here — the registry already + * knows which driver backs each instance, and each `ProviderInstance` + * carries the fully-bound `TextGenerationShape` produced by its driver. + * That means: + * + * - Multiple instances of the same driver (e.g. `codex_personal`, + * `codex_work`) each get their own text-generation closure bound to + * their own settings — the routing is by instance, not by driver. + * - Unknown or disabled instances surface a `TextGenerationError` with + * the missing `instanceId`, instead of silently falling back to a + * default. + * + * This replaces the old `RoutingTextGenerationLive`, which tried to route + * by driver-kind and misused `modelSelection.instanceId` as a driver-id + * literal. + * + * @module git/Layers/TextGenerationLive + */ +import { Effect, Layer } from "effect"; + +import { TextGenerationError } from "@t3tools/contracts"; +import type { ProviderInstanceId } from "@t3tools/contracts"; + +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "../../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; +import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; + +type TextGenerationOp = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +const resolveInstance = ( + registry: ProviderInstanceRegistryShape, + operation: TextGenerationOp, + instanceId: ProviderInstanceId, +): Effect.Effect => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance + ? Effect.succeed(instance.textGeneration) + : Effect.fail( + new TextGenerationError({ + operation, + detail: `No provider instance registered for id '${instanceId}'.`, + }), + ), + ), + ); + +/** + * Build a `TextGenerationShape` that routes every call through the + * registry. Exposed separately from the Layer so tests can construct it + * against a stub registry without layering gymnastics. + */ +export const makeTextGenerationFromRegistry = ( + registry: ProviderInstanceRegistryShape, +): TextGenerationShape => ({ + generateCommitMessage: (input) => + resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( + Effect.flatMap((tg) => tg.generateCommitMessage(input)), + ), + generatePrContent: (input) => + resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + Effect.flatMap((tg) => tg.generatePrContent(input)), + ), + generateBranchName: (input) => + resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + Effect.flatMap((tg) => tg.generateBranchName(input)), + ), + generateThreadTitle: (input) => + resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + Effect.flatMap((tg) => tg.generateThreadTitle(input)), + ), +}); + +/** + * `TextGeneration` Layer wired to the `ProviderInstanceRegistry`. The rest + * of the server keeps using `yield* TextGeneration` — only the underlying + * wiring changed from kind-based routing to instance-based routing. + */ +export const TextGenerationLive = Layer.effect( + TextGeneration, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return makeTextGenerationFromRegistry(registry); + }), +); diff --git a/apps/server/src/observability/Metrics.test.ts b/apps/server/src/observability/Metrics.test.ts index 4604f43b63a..b5eeedaaa43 100644 --- a/apps/server/src/observability/Metrics.test.ts +++ b/apps/server/src/observability/Metrics.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; import { Effect, Metric } from "effect"; import { withMetrics } from "./Metrics.ts"; @@ -75,10 +76,11 @@ describe("withMetrics", () => { Effect.gen(function* () { const counter = Metric.counter("with_metrics_lazy_total"); const timer = Metric.timer("with_metrics_lazy_duration"); - let provider = "unknown"; + let provider = ProviderDriverKind.make("unknown"); + const lazyInittedProvider = ProviderDriverKind.make("codex"); yield* Effect.sync(() => { - provider = "codex"; + provider = lazyInittedProvider; }).pipe( withMetrics({ counter, @@ -93,7 +95,7 @@ describe("withMetrics", () => { const snapshots = yield* Metric.snapshot; assert.equal( hasMetricSnapshot(snapshots, "with_metrics_lazy_total", { - provider: "codex", + provider: lazyInittedProvider, operation: "lazy", outcome: "success", }), @@ -101,7 +103,7 @@ describe("withMetrics", () => { ); assert.equal( hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", { - provider: "codex", + provider: lazyInittedProvider, operation: "lazy", }), true, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 12e11450dd3..5603ce63252 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -3,7 +3,12 @@ import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderRuntimeEvent, + ProviderSession, + ProviderInstanceId, +} from "@t3tools/contracts"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, @@ -50,7 +55,7 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -64,7 +69,7 @@ function createProviderServiceHarness( cwd: string, hasSession = true, sessionCwd = cwd, - providerName: ProviderSession["provider"] = "codex", + providerName: ProviderSession["provider"] = ProviderDriverKind.make("codex"), ) { const now = new Date().toISOString(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -97,6 +102,17 @@ function createProviderServiceHarness( stopSession: () => unsupported(), listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => + Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make(providerName), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make(providerName), + continuationKey: `${providerName}:instance:${instanceId}`, + }, + }), rollbackConversation, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -243,7 +259,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; - readonly providerName?: ProviderKind; + readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; }) { const cwd = createGitRepository(); @@ -252,7 +268,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - options?.providerName ?? "codex", + options?.providerName ?? ProviderDriverKind.make("codex"), ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -315,7 +331,7 @@ describe("CheckpointReactor", () => { title: "Test Project", workspaceRoot: options?.projectWorkspaceRoot ?? cwd, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -329,7 +345,7 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -396,7 +412,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -411,7 +427,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -457,7 +473,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-refresh-local-status"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-refresh-local-status"), @@ -494,7 +510,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-main"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -510,7 +526,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-aux"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -526,7 +542,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-main"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -544,7 +560,7 @@ describe("CheckpointReactor", () => { it("captures pre-turn and completion checkpoints for claude runtime events", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, - providerName: "claudeAgent", + providerName: ProviderDriverKind.make("claudeAgent"), }); const createdAt = new Date().toISOString(); @@ -569,7 +585,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-claude-1"), - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), @@ -583,7 +599,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-claude-1"), - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), @@ -627,7 +643,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-missing-baseline"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -716,7 +732,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -762,7 +778,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "checkpoint.captured", eventId: EventId.make("evt-checkpoint-captured-3"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -812,7 +828,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-runtime-capture-failure"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -823,7 +839,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-after-runtime-failure"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: ThreadId.make("thread-1"), @@ -918,7 +934,7 @@ describe("CheckpointReactor", () => { }); it("executes provider revert and emits thread.reverted for claude sessions", async () => { - const harness = await createHarness({ providerName: "claudeAgent" }); + const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); const createdAt = new Date().toISOString(); await Effect.runPromise( diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b61664f1619..0af5b099a64 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -7,6 +7,7 @@ import { ThreadId, TurnId, type OrchestrationEvent, + ProviderInstanceId, } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; @@ -104,7 +105,7 @@ describe("OrchestrationEngine", () => { title: "Bootstrap Project", workspaceRoot: "/tmp/project-bootstrap", defaultModelSelection: { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [], @@ -119,7 +120,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-bootstrap"), title: "Bootstrap Thread", modelSelection: { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -198,7 +199,7 @@ describe("OrchestrationEngine", () => { title: "Project 1", workspaceRoot: "/tmp/project-1", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -212,7 +213,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -258,7 +259,7 @@ describe("OrchestrationEngine", () => { title: "Project Archive", workspaceRoot: "/tmp/project-archive", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -272,7 +273,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-archive"), title: "Archive me", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -325,7 +326,7 @@ describe("OrchestrationEngine", () => { title: "Replay Project", workspaceRoot: "/tmp/project-replay", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -339,7 +340,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "replay", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -383,7 +384,7 @@ describe("OrchestrationEngine", () => { title: "Stream Project", workspaceRoot: "/tmp/project-stream", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -407,7 +408,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "domain-stream", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -444,7 +445,7 @@ describe("OrchestrationEngine", () => { title: "Ack Project", workspaceRoot: "/tmp/project-ack", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -459,7 +460,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-ack"), title: "Ack Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -496,7 +497,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-missing"), title: "Missing Project Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -533,7 +534,7 @@ describe("OrchestrationEngine", () => { title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -547,7 +548,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -652,7 +653,7 @@ describe("OrchestrationEngine", () => { title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -668,7 +669,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-fail", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -688,7 +689,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-ok", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -747,7 +748,7 @@ describe("OrchestrationEngine", () => { title: "Atomic Project", workspaceRoot: "/tmp/project-atomic", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -761,7 +762,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "atomic", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -890,7 +891,7 @@ describe("OrchestrationEngine", () => { title: "Sync Project", workspaceRoot: "/tmp/project-sync", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -904,7 +905,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "sync-before", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -975,7 +976,7 @@ describe("OrchestrationEngine", () => { title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -990,7 +991,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1010,7 +1011,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 7a08765647f..7f364c717a7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -7,6 +7,7 @@ import { ProjectId, ThreadId, TurnId, + ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -92,7 +93,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-1"), title: "Thread 1", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -364,7 +365,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-clear-attachments"), title: "Thread Clear Attachments", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -492,7 +493,7 @@ it.layer( projectId: ProjectId.make("project-overwrite"), title: "Thread Overwrite", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -640,7 +641,7 @@ it.layer( projectId: ProjectId.make("project-rollback"), title: "Thread Rollback", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -769,7 +770,7 @@ it.layer( projectId: ProjectId.make("project-revert-files"), title: "Thread Revert Files", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -977,7 +978,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta projectId: ProjectId.make("project-delete-files"), title: "Thread Delete Files", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1140,7 +1141,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1267,7 +1268,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-empty"), title: "Thread Empty", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1407,7 +1408,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-conflict"), title: "Thread Conflict", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1551,7 +1552,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-stale-approval"), title: "Thread Stale Approval", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "approval-required", @@ -1694,7 +1695,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-nonstale-approval"), title: "Thread Non-Stale Approval", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "approval-required", @@ -1874,7 +1875,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-revert"), title: "Thread Revert", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -2195,7 +2196,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { title: "Live Project", workspaceRoot: "/tmp/project-live", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -2233,7 +2234,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -2253,7 +2254,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }, ], defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, }); @@ -2272,7 +2273,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', - defaultModelSelection: '{"provider":"codex","model":"gpt-5"}', + defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d981ae0da62..28a0208e75c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -945,6 +945,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, status: event.payload.session.status, providerName: event.payload.session.providerName, + providerInstanceId: event.payload.session.providerInstanceId ?? null, runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9f0d63545fc..cba5ce7e830 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,4 +1,12 @@ -import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + CheckpointRef, + EventId, + MessageId, + ProjectId, + ThreadId, + TurnId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -252,7 +260,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { workspaceRoot: "/tmp/project-1", repositoryIdentity: null, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [ @@ -275,7 +283,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { projectId: asProjectId("project-1"), title: "Thread 1", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "default", @@ -363,7 +371,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { workspaceRoot: "/tmp/project-1", repositoryIdentity: null, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [ @@ -385,7 +393,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { projectId: asProjectId("project-1"), title: "Thread 1", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "default", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 07645571ba7..59a396c926d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -195,6 +195,7 @@ function mapSessionRow( threadId: row.threadId, status: row.status, providerName: row.providerName, + ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, @@ -350,6 +351,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", provider_session_id AS "providerSessionId", provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", @@ -608,6 +610,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", @@ -879,6 +882,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadId: row.threadId, status: row.status, providerName: row.providerName, + ...(row.providerInstanceId !== null + ? { providerInstanceId: row.providerInstanceId } + : {}), runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 80f571285dd..c44f291504a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2,7 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import { + ModelSelection, + ProviderRuntimeEvent, + ProviderSession, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, @@ -37,7 +43,11 @@ import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryI import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; -import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; +import { + providerErrorLabel, + providerErrorLabelFromInstanceHint, + ProviderCommandReactorLive, +} from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -98,6 +108,30 @@ describe("ProviderCommandReactor", () => { createdBaseDirs.clear(); }); + describe("provider error attribution", () => { + it("uses the current provider instance slug when current instance lookup fails", () => { + expect( + providerErrorLabelFromInstanceHint({ + instanceId: "codex_personal", + modelSelectionInstanceId: "codex", + sessionProvider: "codex", + }), + ).toBe("codex_personal"); + }); + + it("uses the desired provider instance slug when desired instance lookup fails", () => { + expect( + providerErrorLabelFromInstanceHint({ + instanceId: "claude_openrouter", + }), + ).toBe("claude_openrouter"); + }); + + it("uses the unknown driver kind when the resolved driver is not registered locally", () => { + expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); + }); + }); + async function createHarness(input?: { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; @@ -112,7 +146,7 @@ describe("ProviderCommandReactor", () => { let nextSessionIndex = 1; const runtimeSessions: Array = []; const modelSelection = input?.threadModelSelection ?? { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }; const startSession = vi.fn((_: unknown, input: unknown) => { @@ -128,8 +162,24 @@ describe("ProviderCommandReactor", () => { typeof input.threadId === "string" ? ThreadId.make(input.threadId) : ThreadId.make(`thread-${sessionIndex}`); + const inputModelSelection = + typeof input === "object" && input !== null && "modelSelection" in input + ? (input.modelSelection as ModelSelection | undefined) + : undefined; + const providerInstanceId = + typeof input === "object" && input !== null && "providerInstanceId" in input + ? (input.providerInstanceId as ProviderInstanceId | undefined) + : inputModelSelection?.instanceId; + const provider = + typeof input === "object" && + input !== null && + "provider" in input && + typeof input.provider === "string" + ? (input.provider as ProviderSession["provider"]) + : ProviderDriverKind.make(inputModelSelection?.instanceId ?? modelSelection.instanceId); const session: ProviderSession = { - provider: modelSelection.provider, + provider, + ...(providerInstanceId ? { providerInstanceId } : {}), status: "ready" as const, runtimeMode: typeof input === "object" && @@ -144,7 +194,9 @@ describe("ProviderCommandReactor", () => { typeof input.cwd === "string" ? { cwd: input.cwd } : {}), - ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}), + ...((inputModelSelection?.model ?? modelSelection.model) + ? { model: inputModelSelection?.model ?? modelSelection.model } + : {}), threadId, resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, createdAt: now, @@ -236,6 +288,25 @@ describe("ProviderCommandReactor", () => { Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), + getInstanceInfo: (instanceId) => { + const raw = String(instanceId); + const driverKind = ProviderDriverKind.make( + raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, + ); + return Effect.succeed({ + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: + driverKind === ProviderDriverKind.make("codex") + ? "codex:home:/shared-codex" + : `${driverKind}:instance:${instanceId}`, + }, + }); + }, rollbackConversation: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -320,6 +391,7 @@ describe("ProviderCommandReactor", () => { refreshStatus, generateBranchName, generateThreadTitle, + runtimeSessions, stateDir, drain, }; @@ -352,7 +424,7 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "approval-required", @@ -574,7 +646,7 @@ describe("ProviderCommandReactor", () => { text: "hello fast mode", attachments: [], }, - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), @@ -587,14 +659,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), @@ -603,7 +675,10 @@ describe("ProviderCommandReactor", () => { it("forwards claude effort options through session start and turn send", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, }); const now = new Date().toISOString(); @@ -618,9 +693,11 @@ describe("ProviderCommandReactor", () => { text: "hello with effort", attachments: [], }, - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "max" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -630,21 +707,28 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "max" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "max" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); }); it("forwards claude fast mode options through session start and turn send", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, }); const now = new Date().toISOString(); @@ -659,9 +743,11 @@ describe("ProviderCommandReactor", () => { text: "hello with fast mode", attachments: [], }, - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "fastMode", value: true }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -671,15 +757,19 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "fastMode", value: true }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "fastMode", value: true }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), }); }); @@ -766,15 +856,15 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, }); }); - it("rejects a first turn when requested provider conflicts with the thread model", async () => { + it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, + threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, }); const now = new Date().toISOString(); @@ -790,7 +880,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -799,29 +889,25 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session).toBeNull(); + expect(thread?.session?.providerName).toBe("claudeAgent"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("claudeAgent")); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - summary: "Provider turn start failed", - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + ).toBeUndefined(); }); it("reuses the same provider session when runtime mode is unchanged", async () => { @@ -870,9 +956,74 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("restarts an existing Codex thread on a compatible requested instance", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-compatible-codex-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-compatible-codex-1"), + role: "user", + text: "first", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-compatible-codex-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-compatible-codex-2"), + role: "user", + text: "second", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: new Date().toISOString(), + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex_work"), + resumeCursor: { opaque: "resume-1" }, + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }); + it("restarts the provider session when the thread workspace changes", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, }); const now = new Date().toISOString(); @@ -933,7 +1084,7 @@ describe("ProviderCommandReactor", () => { cwd: "/tmp/provider-project-worktree", resumeCursor: { opaque: "resume-1" }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, runtimeMode: "approval-required", @@ -942,7 +1093,10 @@ describe("ProviderCommandReactor", () => { it("restarts claude sessions when claude effort changes", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, }); const now = new Date().toISOString(); @@ -957,9 +1111,11 @@ describe("ProviderCommandReactor", () => { text: "first claude turn", attachments: [], }, - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "medium" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "medium" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -980,9 +1136,11 @@ describe("ProviderCommandReactor", () => { text: "second claude turn", attachments: [], }, - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "max" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -993,9 +1151,11 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ resumeCursor: { opaque: "resume-1" }, - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "max" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); }); @@ -1086,7 +1246,10 @@ describe("ProviderCommandReactor", () => { it("does not inject derived model options when restarting claude on runtime mode changes", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, }); const now = new Date().toISOString(); @@ -1122,7 +1285,7 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, runtimeMode: "approval-required", @@ -1230,7 +1393,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1266,6 +1429,72 @@ describe("ProviderCommandReactor", () => { }); }); + it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stopped-provider-switch"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "stopped", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-stopped-provider-switch"), + role: "user", + text: "continue with claude", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.startSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(0); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); + }); + it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1349,7 +1578,7 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ threadId: ThreadId.make("thread-1"), modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "approval-required", @@ -1359,6 +1588,77 @@ describe("ProviderCommandReactor", () => { }); }); + it("rejects active runtime sessions that are missing provider instance ids", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-missing-instance"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + status: "ready", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project", + resumeCursor: { opaque: "resume-without-instance" }, + createdAt: now, + updatedAt: now, + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-instance"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-instance"), + role: "user", + text: "resume codex", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.startSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(0); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("without a provider instance id"), + }, + }); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1451,7 +1751,7 @@ describe("ProviderCommandReactor", () => { harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), method: "session/request_permission", detail: "Unknown pending permission request: approval-request-1", }), @@ -1546,7 +1846,7 @@ describe("ProviderCommandReactor", () => { harness.respondToUserInput.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), method: "item/tool/respondToUserInput", detail: "Unknown pending user-input request: user-input-request-1", }), @@ -1662,6 +1962,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -1686,6 +1987,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.session).not.toBeNull(); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f7ae38b2d2a..9a9f3d71b08 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,7 +4,7 @@ import { EventId, type ModelSelection, type OrchestrationEvent, - ProviderKind, + ProviderDriverKind, type OrchestrationSession, ThreadId, type ProviderSession, @@ -77,6 +77,21 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +export function providerErrorLabel(value: string | undefined): string { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : "unknown"; +} + +export function providerErrorLabelFromInstanceHint(input: { + readonly instanceId?: string | undefined; + readonly modelSelectionInstanceId?: string | undefined; + readonly sessionProvider?: string | undefined; +}): string { + return providerErrorLabel( + input.instanceId ?? input.modelSelectionInstanceId ?? input.sessionProvider, + ); +} + function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { const trimmedCurrentTitle = currentTitle.trim(); if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { @@ -271,42 +286,108 @@ const make = Effect.gen(function* () { } const desiredRuntimeMode = thread.runtimeMode; - const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)( - thread.session?.providerName, - ) - ? thread.session.providerName - : undefined; const requestedModelSelection = options?.modelSelection; - const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider; + const resolveActiveSession = (threadId: ThreadId) => + providerService + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); + + const activeSession = yield* resolveActiveSession(threadId); + const activeThreadSession = + thread.session !== null && thread.session.status !== "stopped" && activeSession + ? thread.session + : null; if ( - requestedModelSelection !== undefined && - requestedModelSelection.provider !== threadProvider + activeThreadSession !== null && + activeSession !== undefined && + (activeThreadSession.providerInstanceId === undefined || + activeSession.providerInstanceId === undefined) ) { return yield* new ProviderAdapterRequestError({ - provider: threadProvider, + provider: providerErrorLabel(activeThreadSession.providerName ?? undefined), method: "thread.turn.start", - detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, + detail: `Thread '${threadId}' has an active provider session without a provider instance id.`, }); } - const preferredProvider: ProviderKind = threadProvider; + const currentInstanceId = + activeThreadSession !== null && + activeSession !== undefined && + activeSession.providerInstanceId !== undefined + ? activeSession.providerInstanceId + : thread.modelSelection.instanceId; const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; + const desiredInstanceId = desiredModelSelection.instanceId; + const currentInfo = yield* providerService.getInstanceInfo(currentInstanceId).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(currentInstanceId), + modelSelectionInstanceId: String(thread.modelSelection.instanceId), + sessionProvider: thread.session?.providerName ?? undefined, + }), + method: "thread.turn.start", + detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, + }), + ), + ); + const desiredInfo = yield* providerService.getInstanceInfo(desiredInstanceId).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(desiredModelSelection.instanceId), + }), + method: "thread.turn.start", + detail: `Requested provider instance '${desiredInstanceId}' is not configured in this build.`, + }), + ), + ); + const desiredDriverKind = desiredInfo.driverKind; + if (!Schema.is(ProviderDriverKind)(desiredDriverKind)) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredDriverKind)), + method: "thread.turn.start", + detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, + }); + } + const preferredProvider: ProviderDriverKind = desiredDriverKind; + if ( + thread.session !== null && + requestedModelSelection !== undefined && + requestedModelSelection.instanceId !== currentInstanceId + ) { + if (currentInfo.driverKind !== desiredInfo.driverKind) { + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'.`, + }); + } + if ( + currentInfo.continuationIdentity.continuationKey !== + desiredInfo.continuationIdentity.continuationKey + ) { + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because their provider resume state is incompatible.`, + }); + } + } const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, }); - const resolveActiveSession = (threadId: ThreadId) => - providerService - .listSessions() - .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); - const startProviderSession = (input?: { readonly resumeCursor?: unknown; - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; }) => providerService.startSession(threadId, { threadId, ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), @@ -314,44 +395,55 @@ const make = Effect.gen(function* () { }); const bindSessionToThread = (session: ProviderSession) => - setThreadSession({ - threadId, - session: { + Effect.gen(function* () { + if (session.providerInstanceId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(session.provider), + method: "thread.turn.start", + detail: `Provider session '${session.threadId}' started without a provider instance id.`, + }); + } + yield* setThreadSession({ threadId, - status: mapProviderSessionStatusToOrchestrationStatus(session.status), - providerName: session.provider, - runtimeMode: desiredRuntimeMode, - // Provider turn ids are not orchestration turn ids. - activeTurnId: null, - lastError: session.lastError ?? null, - updatedAt: session.updatedAt, - }, - createdAt, + session: { + threadId, + status: mapProviderSessionStatusToOrchestrationStatus(session.status), + providerName: session.provider, + providerInstanceId: session.providerInstanceId, + runtimeMode: desiredRuntimeMode, + // Provider turn ids are not orchestration turn ids. + activeTurnId: null, + lastError: session.lastError ?? null, + updatedAt: session.updatedAt, + }, + createdAt, + }); }); - const activeSession = yield* resolveActiveSession(threadId); const existingSessionThreadId = thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const cwdChanged = effectiveCwd !== activeSession?.cwd; - const sessionModelSwitch = - currentProvider === undefined - ? "in-session" - : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; + const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) + .sessionModelSwitch; const modelChanged = requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; + const instanceChanged = + requestedModelSelection !== undefined && + activeSession?.providerInstanceId !== requestedModelSelection.instanceId; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; const previousModelSelection = threadModelSelections.get(threadId); const shouldRestartForModelSelectionChange = - currentProvider === "claudeAgent" && + preferredProvider === "claudeAgent" && requestedModelSelection !== undefined && !Equal.equals(previousModelSelection, requestedModelSelection); if ( !runtimeModeChanged && !cwdChanged && + !instanceChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { @@ -364,8 +456,10 @@ const make = Effect.gen(function* () { yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, - currentProvider, - desiredProvider: desiredModelSelection.provider, + currentProvider: activeSession?.provider, + currentInstanceId, + desiredInstanceId, + desiredProvider: desiredModelSelection.instanceId, currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, @@ -373,6 +467,7 @@ const make = Effect.gen(function* () { desiredCwd: effectiveCwd, cwdChanged, modelChanged, + instanceChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, @@ -429,7 +524,14 @@ const make = Effect.gen(function* () { const sessionModelSwitch = activeSession === undefined ? "in-session" - : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; + : activeSession.providerInstanceId === undefined + ? yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(activeSession.provider), + method: "thread.turn.start", + detail: `Active provider session '${activeSession.threadId}' is missing a provider instance id.`, + }) + : (yield* providerService.getCapabilities(activeSession.providerInstanceId)) + .sessionModelSwitch; const requestedModelSelection = input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = @@ -800,6 +902,9 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "stopped", providerName: thread.session?.providerName ?? null, + ...(thread.session?.providerInstanceId !== undefined + ? { providerInstanceId: thread.session.providerInstanceId } + : {}), runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, lastError: thread.session?.lastError ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 577c5050ea1..487d1a3aac7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,10 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { +import { OrchestrationReadModel, + ProviderDriverKind, ProviderRuntimeEvent, ProviderSession, + ProviderInstanceId, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -98,6 +100,19 @@ function createProviderServiceHarness() { stopSession: () => unsupported(), listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => { + const driverKind = ProviderDriverKind.make(String(instanceId)); + return Effect.succeed({ + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: `${driverKind}:instance:${instanceId}`, + }, + }); + }, rollbackConversation: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -232,7 +247,7 @@ describe("ProviderRuntimeIngestion", () => { title: "Provider Project", workspaceRoot, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -246,7 +261,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -274,7 +289,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); provider.setSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", runtimeMode: "approval-required", threadId: ThreadId.make("thread-1"), @@ -297,7 +312,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: now, turnId: asTurnId("turn-1"), @@ -311,7 +326,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), turnId: asTurnId("turn-1"), @@ -339,7 +354,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-waiting"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: waitingAt, payload: { @@ -358,7 +373,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-error"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), payload: { @@ -380,7 +395,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-stopped"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), payload: { @@ -401,7 +416,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-ready"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), payload: { @@ -427,7 +442,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-midturn-lifecycle"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-midturn-lifecycle"), @@ -443,14 +458,14 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.started", eventId: asEventId("evt-thread-started-midturn-lifecycle"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), }); harness.emit({ type: "session.started", eventId: asEventId("evt-session-started-midturn-lifecycle"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), }); @@ -464,7 +479,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-midturn-lifecycle"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-midturn-lifecycle"), @@ -502,7 +517,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-claude-placeholder"), - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-claude-placeholder"), @@ -518,7 +533,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-claude-placeholder"), - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-claude-placeholder"), @@ -538,7 +553,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-primary"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-primary"), @@ -553,7 +568,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-aux"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-aux"), @@ -569,7 +584,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-primary"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-primary"), @@ -589,7 +604,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-guarded"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-guarded-main"), @@ -605,7 +620,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-other"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-guarded-other"), @@ -621,7 +636,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-main"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-guarded-main"), @@ -641,7 +656,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-2"), @@ -654,7 +669,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-2"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-2"), @@ -667,7 +682,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-2"), @@ -698,7 +713,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-assistant-item-completed-no-delta"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-no-delta"), @@ -730,7 +745,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-tool-completed-with-data"), - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-tool-completed"), @@ -786,7 +801,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-command-completed"), - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-command-completed"), @@ -828,7 +843,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-read-path-completed"), - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-read-path"), @@ -870,7 +885,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-item-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-final"), @@ -909,7 +924,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Source", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "plan", @@ -944,7 +959,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Target", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -972,7 +987,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", runtimeMode: "approval-required", threadId: targetThreadId, @@ -984,7 +999,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-source-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: sourceThreadId, turnId: sourceTurnId, @@ -1054,7 +1069,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-plan-target-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: targetThreadId, turnId: targetTurnId, @@ -1096,7 +1111,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Source", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "plan", @@ -1124,7 +1139,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "running", runtimeMode: "approval-required", threadId: targetThreadId, @@ -1136,7 +1151,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-already-running"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: targetThreadId, turnId: activeTurnId, @@ -1153,7 +1168,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-source-completed-guarded"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: sourceThreadId, turnId: sourceTurnId, @@ -1206,7 +1221,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-stale-plan-implementation"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: targetThreadId, turnId: staleTurnId, @@ -1249,7 +1264,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Source", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "plan", @@ -1284,7 +1299,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Target", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1315,7 +1330,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-source-completed-unrelated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: sourceThreadId, turnId: sourceTurnId, @@ -1366,7 +1381,7 @@ describe("ProviderRuntimeIngestion", () => { ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "running", runtimeMode: "approval-required", threadId: targetThreadId, @@ -1378,7 +1393,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-unrelated-plan-implementation"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: targetThreadId, turnId: replayedTurnId, @@ -1405,7 +1420,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-plan-buffer"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1420,7 +1435,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1431,7 +1446,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-2"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1442,7 +1457,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-plan-buffer"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1471,7 +1486,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered"), @@ -1485,7 +1500,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered"), @@ -1508,7 +1523,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-buffered"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered"), @@ -1539,7 +1554,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered-request-flush"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-flush"), @@ -1554,7 +1569,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered-request-flush"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-flush"), @@ -1567,7 +1582,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.opened", eventId: asEventId("evt-request-opened-buffered-request-flush"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-flush"), @@ -1599,7 +1614,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered-user-input-flush"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-user-input-flush"), @@ -1614,7 +1629,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered-user-input-flush"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-user-input-flush"), @@ -1627,7 +1642,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "user-input.requested", eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-user-input-flush"), @@ -1667,7 +1682,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered-whitespace-request"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-whitespace-request"), @@ -1682,7 +1697,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered-whitespace-request"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-whitespace-request"), @@ -1695,7 +1710,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.opened", eventId: asEventId("evt-request-opened-buffered-whitespace-request"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: pausedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-whitespace-request"), @@ -1729,7 +1744,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered-request-append"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-append"), @@ -1744,7 +1759,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered-request-append-initial"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-append"), @@ -1757,7 +1772,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.opened", eventId: asEventId("evt-request-opened-buffered-request-append"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: pausedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-append"), @@ -1780,7 +1795,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered-request-append-followup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: resumedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-append"), @@ -1793,7 +1808,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-buffered-request-append"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: completedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered-request-append"), @@ -1861,7 +1876,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-streaming-request-segment"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-request-segment"), @@ -1876,7 +1891,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-streaming-request-segment-initial"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-request-segment"), @@ -1889,7 +1904,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.opened", eventId: asEventId("evt-request-opened-streaming-request-segment"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: pausedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-request-segment"), @@ -1912,7 +1927,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-streaming-request-segment-followup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: resumedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-request-segment"), @@ -1925,7 +1940,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-streaming-request-segment"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: completedAt, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-request-segment"), @@ -1983,7 +1998,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-streaming-mode"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-mode"), @@ -1998,7 +2013,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-streaming-mode"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-mode"), @@ -2025,7 +2040,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-streaming-mode"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-mode"), @@ -2058,7 +2073,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffer-spill"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffer-spill"), @@ -2073,7 +2088,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffer-spill"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffer-spill"), @@ -2086,7 +2101,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-buffer-spill"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffer-spill"), @@ -2118,7 +2133,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -2134,7 +2149,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -2147,7 +2162,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -2160,7 +2175,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -2204,7 +2219,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.opened", eventId: asEventId("evt-request-opened"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), requestId: ApprovalRequestId.make("req-open"), @@ -2217,7 +2232,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.resolved", eventId: asEventId("evt-request-resolved"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), requestId: ApprovalRequestId.make("req-open"), @@ -2270,7 +2285,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-3"), @@ -2297,7 +2312,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error-activity"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-runtime-error-activity"), @@ -2328,7 +2343,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-warning-turn-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-warning"), @@ -2338,7 +2353,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.warning", eventId: asEventId("evt-warning-runtime"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-warning"), @@ -2372,7 +2387,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.started", eventId: asEventId("evt-session-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), message: "session started", @@ -2380,14 +2395,14 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.started", eventId: asEventId("evt-thread-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), }); harness.emit({ type: "item.started", eventId: asEventId("evt-tool-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), @@ -2424,7 +2439,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.metadata.updated", eventId: asEventId("evt-thread-metadata-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2436,7 +2451,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.plan.updated", eventId: asEventId("evt-turn-plan-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -2452,7 +2467,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.updated", eventId: asEventId("evt-item-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -2469,7 +2484,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.warning", eventId: asEventId("evt-runtime-warning"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -2482,7 +2497,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.diff.updated", eventId: asEventId("evt-turn-diff-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -2558,7 +2573,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.token-usage.updated", eventId: asEventId("evt-thread-token-usage-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2610,7 +2625,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.token-usage.updated", eventId: asEventId("evt-thread-token-usage-updated-camel"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2663,7 +2678,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.token-usage.updated", eventId: asEventId("evt-thread-token-usage-updated-claude-window"), - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2707,7 +2722,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.state.changed", eventId: asEventId("evt-thread-compacted"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), @@ -2737,7 +2752,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "task.started", eventId: asEventId("evt-task-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2750,7 +2765,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "task.progress", eventId: asEventId("evt-task-progress"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2764,7 +2779,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "task.completed", eventId: asEventId("evt-task-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2777,7 +2792,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-task-proposed-plan-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2840,7 +2855,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "user-input.requested", eventId: asEventId("evt-user-input-requested"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-user-input"), @@ -2865,7 +2880,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "user-input.resolved", eventId: asEventId("evt-user-input-resolved"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-user-input"), @@ -2913,7 +2928,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-invalid-delta"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-invalid"), @@ -2927,7 +2942,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error-after-failure"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-after-failure"), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7eeeed2d51b..b7a4c195a5b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1211,6 +1211,9 @@ const make = Effect.gen(function* () { threadId: thread.id, status, providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, @@ -1456,6 +1459,9 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "error", providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: eventTurnId ?? null, lastError: runtimeErrorMessage, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index a678bcea166..1e33a355d06 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -7,6 +7,7 @@ import { ThreadId, type OrchestrationCommand, type OrchestrationReadModel, + ProviderInstanceId, } from "@t3tools/contracts"; import { Effect } from "effect"; @@ -29,7 +30,7 @@ const readModel: OrchestrationReadModel = { title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [], @@ -42,7 +43,7 @@ const readModel: OrchestrationReadModel = { title: "Project B", workspaceRoot: "/tmp/project-b", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [], @@ -57,7 +58,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -80,7 +81,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.make("project-b"), title: "Thread B", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -157,7 +158,7 @@ describe("commandInvariants", () => { projectId: ProjectId.make("project-a"), title: "new", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -181,7 +182,7 @@ describe("commandInvariants", () => { projectId: ProjectId.make("project-a"), title: "dup", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index 2b323714932..548fcc0b68e 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -7,6 +7,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + ProviderInstanceId, } from "@t3tools/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; @@ -63,7 +64,7 @@ async function seedReadModel(): Promise { projectId: asProjectId("project-delete"), title: "Thread Delete 1", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -93,7 +94,7 @@ async function seedReadModel(): Promise { projectId: asProjectId("project-delete"), title: "Thread Delete 2", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 1feabb0ed85..23566099196 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -5,6 +5,7 @@ import { MessageId, ProjectId, ThreadId, + ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { describe, expect, it } from "vitest"; @@ -138,7 +139,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -163,7 +164,7 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), @@ -188,7 +189,7 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.make("thread-1"), messageId: asMessageId("message-user-1"), - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), @@ -239,7 +240,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -321,7 +322,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index a61153bb529..b50bc2da598 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -2,6 +2,7 @@ import { CommandId, EventId, ProjectId, + ProviderDriverKind, ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; @@ -57,7 +58,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -77,7 +78,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + instanceId: "codex", model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -118,7 +119,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, branch: null, @@ -150,7 +151,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -253,7 +254,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -319,7 +320,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -376,7 +377,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -463,7 +464,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -678,7 +679,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -831,7 +832,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "capped", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index d42be699458..fc5f7a5c155 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -28,7 +28,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { title: "Null options project", workspaceRoot: "/tmp/project-null-options", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, scripts: [], @@ -52,7 +52,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual( row.defaultModelSelection, JSON.stringify({ - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }), ); @@ -61,7 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.make("project-null-options"), }); assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }); }), @@ -77,7 +77,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.make("project-null-options"), title: "Null options thread", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, runtimeMode: "full-access", @@ -110,7 +110,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual( row.modelSelection, JSON.stringify({ - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }), ); @@ -119,7 +119,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { threadId: ThreadId.make("thread-null-options"), }); assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }); }), diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 2499eba1967..80d241436f8 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -23,6 +23,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id, status, provider_name, + provider_instance_id, runtime_mode, active_turn_id, last_error, @@ -32,6 +33,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.threadId}, ${row.status}, ${row.providerName}, + ${row.providerInstanceId}, ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, @@ -41,6 +43,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { DO UPDATE SET status = excluded.status, provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, @@ -57,6 +60,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index da3e8bce90a..778e0c6d2ee 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -46,6 +46,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { INSERT INTO provider_session_runtime ( thread_id, provider_name, + provider_instance_id, adapter_key, runtime_mode, status, @@ -56,6 +57,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { VALUES ( ${runtime.threadId}, ${runtime.providerName}, + ${runtime.providerInstanceId}, ${runtime.adapterKey}, ${runtime.runtimeMode}, ${runtime.status}, @@ -66,6 +68,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ON CONFLICT (thread_id) DO UPDATE SET provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, adapter_key = excluded.adapter_key, runtime_mode = excluded.runtime_mode, status = excluded.status, @@ -83,6 +86,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", adapter_key AS "adapterKey", runtime_mode AS "runtimeMode", status, @@ -102,6 +106,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", adapter_key AS "adapterKey", runtime_mode AS "runtimeMode", status, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c8d4647fb72..025e9e4831a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -39,6 +39,8 @@ import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; +import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; +import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; /** * Migration loader with all migrations defined inline. @@ -77,6 +79,8 @@ export const migrationEntries = [ [24, "BackfillProjectionThreadShellSummary", Migration0024], [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], [26, "CanonicalizeModelSelectionOptions", Migration0026], + [27, "ProviderSessionRuntimeInstanceId", Migration0027], + [28, "ProjectionThreadSessionInstanceId", Migration0028], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts new file mode 100644 index 00000000000..3233f5043af --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("027_028_ProviderInstanceIdColumns", (it) => { + it.effect("continues when provider_session_runtime was partially migrated", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 26 }); + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id IN (27, 28) + ORDER BY migration_id + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 27, + name: "ProviderSessionRuntimeInstanceId", + }, + { + migration_id: 28, + name: "ProjectionThreadSessionInstanceId", + }, + ]); + + const providerSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(provider_session_runtime) + `; + assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); + + const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), + ); + + const providerSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(provider_session_runtime) + `; + assert.ok( + providerSessionIndexes.some( + (index) => index.name === "idx_provider_session_runtime_instance", + ), + ); + + const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionIndexes.some( + (index) => index.name === "idx_projection_thread_sessions_instance", + ), + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts b/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts new file mode 100644 index 00000000000..7ae0e55b13c --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts @@ -0,0 +1,38 @@ +/** + * Adds the nullable `provider_instance_id` routing column to + * `provider_session_runtime`. + * + * Slice D of the provider-array refactor splits "driver kind" from + * "configured instance". Existing rows have only the driver name in + * `provider_name`; new rows additionally carry the user-defined instance + * routing key. The column remains nullable so legacy rows can still decode; + * the persistence boundary is responsible for materializing a concrete + * instance id before any hot routing path sees the binding. + * + * The column is nullable on purpose — backfilling it during the migration + * would require knowing which configured instance "owned" each historical + * session, and that mapping is ambiguous when the user later configures + * multiple instances of the same driver. Keeping that compatibility at the + * persistence boundary keeps the fallback out of active routing code. + */ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(provider_session_runtime) + `; + if (!columns.some((column) => column.name === "provider_instance_id")) { + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_instance + ON provider_session_runtime(provider_instance_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts b/apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts new file mode 100644 index 00000000000..bc4ba98044f --- /dev/null +++ b/apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts @@ -0,0 +1,21 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + if (!columns.some((column) => column.name === "provider_instance_id")) { + yield* sql` + ALTER TABLE projection_thread_sessions + ADD COLUMN provider_instance_id TEXT + `; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_instance + ON projection_thread_sessions(provider_instance_id) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index fcd13f068da..5d2c3c87d1d 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -10,6 +10,7 @@ import { RuntimeMode, IsoDateTime, OrchestrationSessionStatus, + ProviderInstanceId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -22,6 +23,7 @@ export const ProjectionThreadSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), + providerInstanceId: Schema.NullOr(ProviderInstanceId), runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index bf8e658e8a6..6ba15b32d96 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -7,6 +7,7 @@ */ import { IsoDateTime, + ProviderInstanceId, ProviderSessionRuntimeStatus, RuntimeMode, ThreadId, @@ -19,6 +20,14 @@ import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; export const ProviderSessionRuntime = Schema.Struct({ threadId: ThreadId, providerName: Schema.String, + /** + * User-defined routing key for the configured provider instance that + * owns this session. Nullable only at the storage/migration boundary: + * rows persisted before the driver/instance split carry only + * `providerName`. Repository consumers must materialize a concrete + * instance id before routing. + */ + providerInstanceId: Schema.NullOr(ProviderInstanceId), adapterKey: Schema.String, runtimeMode: RuntimeMode, status: ProviderSessionRuntimeStatus, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts new file mode 100644 index 00000000000..feddd3b86b2 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -0,0 +1,157 @@ +/** + * ClaudeDriver — `ProviderDriver` for the Claude Agent SDK runtime. + * + * Mirrors `CodexDriver`: a plain value whose `create()` returns one + * `ProviderInstance` bundling `snapshot` / `adapter` / `textGeneration` + * closures captured over the per-instance `ClaudeSettings`. + * + * Unlike Codex, the Claude snapshot probe may invoke a secondary probe + * (`probeClaudeCapabilities`) to read Anthropic account + slash-command + * metadata. That probe is per-instance and keyed by binary + resolved HOME so + * two concurrent Claude instances don't cross-contaminate account metadata. + * + * @module provider/Drivers/ClaudeDriver + */ +import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { + checkClaudeProviderStatus, + makePendingClaudeProvider, + probeClaudeCapabilities, +} from "../Layers/ClaudeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const CAPABILITIES_PROBE_TTL = Duration.minutes(5); + +export type ClaudeDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const ClaudeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Claude", + supportsMultipleInstances: true, + }, + configSchema: ClaudeSettings, + defaultConfig: (): ClaudeSettings => Schema.decodeSync(ClaudeSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey, + }); + + const adapterOptions = { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }; + const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); + const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); + + // Per-instance capabilities cache: keyed on binary + resolved HOME so + // account-specific probes never share auth metadata across instances. + const capabilitiesProbeCache = yield* Cache.make({ + capacity: 1, + timeToLive: CAPABILITIES_PROBE_TTL, + lookup: () => + probeClaudeCapabilities(effectiveConfig, processEnv).pipe( + Effect.provideService(Path.Path, path), + ), + }); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + + const checkProvider = checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity: { + ...fallbackContinuationIdentity, + continuationKey: continuationGroupKey, + }, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts new file mode 100644 index 00000000000..84c9c331cc8 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -0,0 +1,52 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Path } from "effect"; + +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeContinuationGroupKey, + makeClaudeEnvironment, + resolveClaudeHomePath, +} from "./ClaudeHome.ts"; + +it.layer(NodeServices.layer)("ClaudeHome", (it) => { + describe("Claude home resolution", () => { + it.effect("uses the process home when no Claude home override is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir()); + + expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); + }), + ); + + it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homePath = "~/.claude-work"; + const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); + + expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); + expect((yield* makeClaudeEnvironment({ homePath })).HOME).toBe(resolved); + expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); + expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( + `claude\0${resolved}`, + ); + }), + ); + + it.effect("keeps continuation compatible across instances with the same Claude HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir()); + + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( + `claude:home:${resolved}`, + ); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts new file mode 100644 index 00000000000..c959096677e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -0,0 +1,43 @@ +import * as NodeOS from "node:os"; + +import type { ClaudeSettings } from "@t3tools/contracts"; +import { Effect, Path } from "effect"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( + config: Pick, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); +}); + +export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( + config: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const homePath = config.homePath.trim(); + if (homePath.length === 0) return baseEnv; + const resolvedHomePath = yield* resolveClaudeHomePath(config); + return { + ...baseEnv, + HOME: resolvedHomePath, + }; +}); + +export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( + function* (config: Pick): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config); + return `claude:home:${resolvedHomePath}`; + }, +); + +export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( + function* ( + config: Pick, + ): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config); + return `${config.binaryPath}\0${resolvedHomePath}`; + }, +); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts new file mode 100644 index 00000000000..13598ab5f61 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -0,0 +1,171 @@ +/** + * CodexDriver — first concrete `ProviderDriver` in the new per-instance model. + * + * A driver is a plain value (not a Context.Service) whose `create()` returns + * one `ProviderInstance` bundling: + * - `snapshot` — the live `ServerProviderShape` for this instance; + * - `adapter` — the Codex session/turn/approval runtime; + * - `textGeneration` — commit/PR/branch/title generation via `codex exec`. + * + * Each call to `create()` captures the `codexConfig` argument in closures + * owned by the returned instance. Two instances created with different + * `homePath`s (e.g. `codex_personal` + `codex_work`) therefore run with + * fully independent Codex app-server processes and `CODEX_HOME` + * environments — no shared mutable state. + * + * Resource lifecycle: `create()` runs in a scope handed in by the registry. + * Closing that scope releases the adapter's child processes, the managed + * snapshot's refresh fibre, and the text-generation binaries' transient + * scratch files. The registry uses this to tear down an instance when its + * `providerInstances` entry disappears or its config changes. + * + * @module provider/Drivers/CodexDriver + */ +import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; +import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + codexContinuationIdentity, + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./CodexHomeLayout.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("codex"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +/** + * Services the driver needs to materialize an instance. Surfaced as the + * driver's `R` so the registry layer aggregates these across every + * registered driver and the runtime satisfies them once. + */ +export type CodexDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +/** + * Stamp instance identity onto a `ServerProvider` snapshot produced by the + * driver-kind-only codex helpers. Once `buildServerProvider` in + * `providerSnapshot.ts` is widened to accept `instanceId`/`driver`, this + * wrapper disappears. + */ +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CodexDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Codex", + supportsMultipleInstances: true, + }, + configSchema: CodexSettings, + defaultConfig: (): CodexSettings => Schema.decodeSync(CodexSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const homeLayout = yield* resolveCodexHomeLayout(config); + const continuationIdentity = codexContinuationIdentity(homeLayout); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ); + const effectiveConfig = { + ...config, + enabled, + homePath: homeLayout.effectiveHomePath ?? "", + } satisfies CodexSettings; + + // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error + // channels at construction time — their failure modes are all on the + // per-operation closures they return. No `mapError` wrapper is needed + // here; the registry only has to worry about snapshot-build and + // spawner-availability failures surfaced from `checkCodexProviderStatus` + // below. + const adapter = yield* makeCodexAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); + + // Build a managed snapshot whose settings never change — mutations come + // in as instance rebuilds from the registry rather than in-place + // updates. Pre-provide `ChildProcessSpawner` so the check fits + // `makeManagedServerProvider.checkProvider`'s `R = never`. + const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts new file mode 100644 index 00000000000..b84a43c4032 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -0,0 +1,209 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Schema } from "effect"; + +import { CodexSettings } from "@t3tools/contracts"; +import { + CodexShadowHomeError, + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./CodexHomeLayout.ts"; + +const decodeCodexSettings = (input: { + readonly enabled?: boolean; + readonly homePath?: string; + readonly shadowHomePath?: string; + readonly customModels?: readonly string[]; + readonly binaryPath?: string; +}): CodexSettings => Schema.decodeSync(CodexSettings)(input); + +const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* ( + filePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); +}); + +it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { + describe("resolveCodexHomeLayout", () => { + it.effect("uses direct CODEX_HOME when no shadow home is configured", () => + Effect.gen(function* () { + const homePath = yield* makeTempDir("t3code-codex-home-"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath, + }), + ); + + expect(layout).toMatchObject({ + mode: "direct", + sharedHomePath: homePath, + effectiveHomePath: homePath, + continuationKey: `codex:home:${homePath}`, + }); + }), + ); + + it.effect("uses the shared home for continuation and the shadow home for runtime", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + expect(layout).toMatchObject({ + mode: "authOverlay", + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + continuationKey: `codex:home:${sharedHome}`, + }); + }), + ); + }); + + describe("materializeCodexShadowHome", () => { + it.effect("materializes a shadow home with shared state links and private auth", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions")); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n'); + yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); + yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.symlink( + path.join(sharedHome, "models_cache.json"), + path.join(shadowHome, "models_cache.json"), + ); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); + const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const modelsCacheExists = yield* fileSystem.exists( + path.join(shadowHome, "models_cache.json"), + ); + const authLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "auth.json")) + .pipe(Effect.result); + const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); + + expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); + expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(modelsCacheExists).toBe(false); + expect(authLinkResult._tag).toBe("Failure"); + expect(authContents).toContain("shadow"); + }), + ); + + it.effect("accepts Codex-created shadow-local runtime directories", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(path.join(sharedHome, "log")); + yield* fileSystem.makeDirectory(path.join(sharedHome, "memories")); + yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp")); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true }); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const logLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "log")) + .pipe(Effect.result); + const memoriesLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "memories")) + .pipe(Effect.result); + const tmpLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "tmp")) + .pipe(Effect.result); + + expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(logLinkResult._tag).toBe("Failure"); + expect(memoriesLinkResult._tag).toBe("Failure"); + expect(tmpLinkResult._tag).toBe("Failure"); + }), + ); + + it.effect("rejects shadow homes that point at the shared home", () => + Effect.gen(function* () { + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: sharedHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CodexShadowHomeError); + }), + ); + + it.effect("rejects shared entries that already exist in the shadow home as real files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error.detail).toContain("already exists and is not a symlink"); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts new file mode 100644 index 00000000000..0b6cd6b8918 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -0,0 +1,263 @@ +import * as NodeOS from "node:os"; + +import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +export interface CodexHomeLayout { + readonly mode: "direct" | "authOverlay"; + readonly sharedHomePath: string; + readonly effectiveHomePath: string | undefined; + readonly continuationKey: string; +} + +const KNOWN_SHARED_DIRECTORIES = [ + "sessions", + "archived_sessions", + "sqlite", + "shell_snapshots", + "worktrees", + "skills", + "plugins", + "cache", + "logs", +] as const; + +const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); +const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); + +function resolveHomePath(path: Path.Path, value: string | undefined): string { + const expanded = + value && value.trim().length > 0 + ? expandHomePath(value) + : path.join(NodeOS.homedir(), ".codex"); + return path.resolve(expanded); +} + +export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(function* ( + config: CodexSettings, +): Effect.fn.Return { + const path = yield* Path.Path; + const sharedHomePath = resolveHomePath(path, config.homePath); + const shadowHomePath = config.shadowHomePath.trim(); + if (shadowHomePath.length === 0) { + return { + mode: "direct", + sharedHomePath, + effectiveHomePath: config.homePath.trim().length > 0 ? sharedHomePath : undefined, + continuationKey: `codex:home:${sharedHomePath}`, + }; + } + + const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath)); + return { + mode: "authOverlay", + sharedHomePath, + effectiveHomePath, + continuationKey: `codex:home:${sharedHomePath}`, + }; +}); + +export class CodexShadowHomeError extends Schema.TaggedErrorClass()( + "CodexShadowHomeError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return this.detail; + } +} + +type LinkState = + | { + readonly _tag: "Missing"; + } + | { + readonly _tag: "NotSymlink"; + } + | { + readonly _tag: "Symlink"; + readonly target: string; + }; + +function toShadowHomeError(cause: unknown): CodexShadowHomeError { + return Schema.is(CodexShadowHomeError)(cause) + ? cause + : new CodexShadowHomeError({ + detail: "Failed to materialize Codex shadow home.", + cause, + }); +} + +function normalizeShadowHomeError( + effect: Effect.Effect, +): Effect.Effect { + return effect.pipe(Effect.mapError(toShadowHomeError)); +} + +function isNotSymlinkError(error: PlatformError.PlatformError): boolean { + const cause = error.reason.cause; + return ( + error.reason._tag === "Unknown" && + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "EINVAL" + ); +} + +const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( + fileSystem: FileSystem.FileSystem, + linkPath: string, +): Effect.fn.Return { + return yield* fileSystem.readLink(linkPath).pipe( + Effect.map((target): LinkState => ({ _tag: "Symlink", target })), + Effect.catch((error) => { + if (error.reason._tag === "NotFound") { + return Effect.succeed({ _tag: "Missing" }); + } + if (isNotSymlinkError(error)) { + return Effect.succeed({ _tag: "NotSymlink" }); + } + return Effect.fail(toShadowHomeError(error)); + }), + ); +}); + +const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly shadowPath: string; + readonly entryName: string; +}): Effect.fn.Return { + const path = yield* Path.Path; + const privatePath = path.join(input.shadowPath, input.entryName); + const state = yield* readLinkState(input.fileSystem, privatePath); + if (state._tag === "Symlink") { + yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); + } +}); + +const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly shadowPath: string; + readonly sharedPath: string; + readonly entryName: string; +}): Effect.fn.Return { + const path = yield* Path.Path; + const target = path.join(input.sharedPath, input.entryName); + const link = path.join(input.shadowPath, input.entryName); + const state = yield* readLinkState(input.fileSystem, link); + + if (state._tag === "NotSymlink") { + return yield* new CodexShadowHomeError({ + detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, + }); + } + + if (state._tag === "Missing") { + return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + } + + const resolvedExisting = path.resolve(path.dirname(link), state.target); + if (resolvedExisting !== target) { + yield* normalizeShadowHomeError(input.fileSystem.remove(link)); + yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + } +}); + +const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( + fileSystem: FileSystem.FileSystem, + shadowPath: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const authPath = path.join(shadowPath, "auth.json"); + const state = yield* readLinkState(fileSystem, authPath); + if (state._tag === "Symlink") { + return yield* new CodexShadowHomeError({ + detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, + }); + } +}); + +export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( + layout: CodexHomeLayout, +) { + if (layout.mode !== "authOverlay") return; + const effectiveHomePath = layout.effectiveHomePath; + if (!effectiveHomePath) return; + if (layout.sharedHomePath === effectiveHomePath) { + return yield* new CodexShadowHomeError({ + detail: "Codex shadow home path must be different from the shared home path.", + }); + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* normalizeShadowHomeError( + Effect.all( + [ + fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), + fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), + ...KNOWN_SHARED_DIRECTORIES.map((directory) => + fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { + recursive: true, + }), + ), + ], + { concurrency: "unbounded" }, + ), + ); + + const sharedEntryNames = yield* normalizeShadowHomeError( + fileSystem.readDirectory(layout.sharedHomePath), + ); + const entries = new Set(KNOWN_SHARED_DIRECTORIES); + for (const entryName of sharedEntryNames) { + if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { + entries.add(entryName); + } + } + + yield* Effect.forEach( + PRIVATE_ENTRY_NAMES, + (entryName) => + entryName === "auth.json" + ? Effect.void + : removePrivateSymlink({ + fileSystem, + shadowPath: effectiveHomePath, + entryName, + }), + { discard: true }, + ); + + yield* Effect.forEach( + entries, + (entryName) => { + if (PRIVATE_ENTRY_NAMES.has(entryName)) { + return Effect.void; + } + return ensureSymlink({ + fileSystem, + shadowPath: effectiveHomePath, + sharedPath: layout.sharedHomePath, + entryName, + }); + }, + { discard: true }, + ); + + yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); +}); + +export function codexContinuationIdentity(layout: CodexHomeLayout) { + return { + driverKind: ProviderDriverKind.make("codex"), + continuationKey: layout.continuationKey, + }; +} diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts new file mode 100644 index 00000000000..b7eda591bc8 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -0,0 +1,148 @@ +/** + * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime. + * + * Cursor exposes an ACP-based CLI. The driver is still a plain value, but + * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` + * hook to run the slow ACP model-capability probe in the background without + * blocking the initial `ready`-state publish. + * + * Text generation is supported via the ACP runtime — `makeCursorTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/CursorDriver + */ +import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; +import { + buildInitialCursorProviderSnapshot, + checkCursorProviderStatus, + enrichCursorSnapshot, +} from "../Layers/CursorProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("cursor"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type CursorDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CursorDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Cursor", + supportsMultipleInstances: true, + }, + configSchema: CursorSettings, + defaultConfig: (): CursorSettings => Schema.decodeSync(CursorSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies CursorSettings; + + const adapter = yield* makeCursorAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(buildInitialCursorProviderSnapshot(settings)), + checkProvider, + // Preserve the background ACP model-capability probe that used to + // live on `CursorProviderLive`. Only fires when the snapshot reports + // an authenticated, enabled provider with at least one non-custom + // model whose capabilities haven't been captured yet. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichCursorSnapshot({ + settings, + environment: processEnv, + snapshot: currentSnapshot, + publishSnapshot, + stampIdentity, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts new file mode 100644 index 00000000000..29f74ead023 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -0,0 +1,135 @@ +/** + * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. + * + * Mirrors the Codex / Claude drivers: a plain value whose `create()` + * bundles `snapshot` / `adapter` / `textGeneration` closures over the + * per-instance `OpenCodeSettings`. + * + * Two instances with different `serverUrl`s therefore talk to independent + * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation + * shares spin up their own scoped child processes, and those child + * processes are released when the registry scope closes. + * + * @module provider/Drivers/OpenCodeDriver + */ +import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; +import { + checkOpenCodeProviderStatus, + makePendingOpenCodeProvider, +} from "../Layers/OpenCodeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("opencode"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type OpenCodeDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | OpenCodeRuntime + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenCodeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenCode", + supportsMultipleInstances: true, + }, + configSchema: OpenCodeSettings, + defaultConfig: (): OpenCodeSettings => Schema.decodeSync(OpenCodeSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; + + const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOpenCodeProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index e4e46d37486..2bd926512ed 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -116,6 +116,46 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass()( + "ProviderInstanceNotFoundError", + { + instanceId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `No provider instance bound to id '${this.instanceId}'`; + } +} + +/** + * ProviderDriverError - A driver `create` call failed before producing an + * instance. Surfaced to the registry, which marks the offending entry as + * an "unavailable" shadow snapshot rather than crashing the server. + */ +export class ProviderDriverError extends Schema.TaggedErrorClass()( + "ProviderDriverError", + { + driver: Schema.String, + instanceId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; + } +} + /** * ProviderSessionNotFoundError - Provider-facing session not found. */ @@ -157,6 +197,7 @@ export type ProviderAdapterError = export type ProviderServiceError = | ProviderValidationError | ProviderUnsupportedError + | ProviderInstanceNotFoundError | ProviderSessionNotFoundError | ProviderSessionDirectoryPersistenceError | ProviderAdapterError diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9c83bce8584..14cca34b20d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -12,21 +12,29 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, + ClaudeSettings, + ProviderDriverKind, ProviderItemId, ProviderRuntimeEvent, type RuntimeMode, ThreadId, + ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, Fiber, Layer, Random, Stream } from "effect"; +import { Context, Effect, Fiber, Layer, Random, Schema, Stream } from "effect"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; + +// Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. +class ClaudeAdapter extends Context.Service()( + "test/ClaudeAdapter", +) {} class FakeClaudeQuery implements AsyncIterable { private readonly queue: Array = []; @@ -137,6 +145,8 @@ function makeHarness(config?: { readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; readonly baseDir?: string; + readonly claudeConfig?: Partial; + readonly instanceId?: ProviderInstanceId; }) { const query = new FakeClaudeQuery(); let createInput: @@ -147,6 +157,7 @@ function makeHarness(config?: { | undefined; const adapterOptions: ClaudeAdapterLiveOptions = { + ...(config?.instanceId ? { instanceId: config.instanceId } : {}), createQuery: (input) => { createInput = input; return query; @@ -164,7 +175,13 @@ function makeHarness(config?: { }; return { - layer: makeClaudeAdapterLive(adapterOptions).pipe( + layer: Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = Schema.decodeSync(ClaudeSettings)(config?.claudeConfig ?? {}); + return yield* makeClaudeAdapter(claudeConfig, adapterOptions); + }), + ).pipe( Layer.provideMerge( ServerConfig.layerTest( config?.cwd ?? "/tmp/claude-adapter-test", @@ -247,7 +264,11 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; const result = yield* adapter - .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) + .startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("codex"), + runtimeMode: "full-access", + }) .pipe(Effect.result); assert.equal(result._tag, "Failure"); @@ -257,7 +278,7 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual( result.failure, new ProviderAdapterValidationError({ - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "startSession", issue: "Expected provider 'claudeAgent' but received 'codex'.", }), @@ -274,7 +295,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -294,7 +315,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -314,7 +335,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -333,10 +354,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "max" }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -348,15 +371,37 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { + const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.env?.HOME, path.join(os.homedir(), ".claude-work")); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-7", }, runtimeMode: "full-access", @@ -376,10 +421,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-7", [ - { id: "effort", value: "xhigh" }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-7", + [{ id: "effort", value: "xhigh" }], + ), runtimeMode: "full-access", }); @@ -397,10 +444,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "max" }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -418,10 +467,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ - { id: "effort", value: "high" }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "effort", value: "high" }], + ), runtimeMode: "full-access", }); @@ -439,10 +490,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ - { id: "thinking", value: false }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "thinking", value: false }], + ), runtimeMode: "full-access", }); @@ -462,10 +515,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "thinking", value: false }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "thinking", value: false }], + ), runtimeMode: "full-access", }); @@ -483,10 +538,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "fastMode", value: true }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), runtimeMode: "full-access", }); @@ -506,10 +563,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "fastMode", value: true }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "fastMode", value: true }], + ), runtimeMode: "full-access", }); @@ -527,10 +586,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "ultrathink" }, - ]), + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ), runtimeMode: "full-access", }); @@ -538,9 +599,11 @@ describe("ClaudeAdapterLive", () => { threadId: session.threadId, input: "Investigate the edge cases", attachments: [], - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "ultrathink" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ), }); const createInput = harness.getLastCreateQueryInput(); @@ -585,7 +648,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -630,9 +693,9 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-5", }, runtimeMode: "full-access", @@ -807,7 +870,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -986,7 +1049,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1077,7 +1140,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1153,7 +1216,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1219,7 +1282,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1270,13 +1333,19 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the previous session before replacing an existing thread session", () => { const queries: FakeClaudeQuery[] = []; - const layer = makeClaudeAdapterLive({ - createQuery: () => { - const query = new FakeClaudeQuery(); - queries.push(query); - return query; - }, - }).pipe( + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = Schema.decodeSync(ClaudeSettings)({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), @@ -1292,13 +1361,13 @@ describe("ClaudeAdapterLive", () => { const firstSession = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); const secondSession = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", resumeCursor: firstSession.resumeCursor, }); @@ -1347,21 +1416,27 @@ describe("ClaudeAdapterLive", () => { let promptConsumerError: unknown = undefined; - const layer = makeClaudeAdapterLive({ - createQuery: (input) => { - // Simulate the SDK consuming the prompt iterable - (async () => { - try { - for await (const _message of input.prompt) { - /* SDK processes user messages */ - } - } catch (error) { - promptConsumerError = error; - } - })(); - return query; - }, - }).pipe( + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = Schema.decodeSync(ClaudeSettings)({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: (input) => { + // Simulate the SDK consuming the prompt iterable + (async () => { + try { + for await (const _message of input.prompt) { + /* SDK processes user messages */ + } + } catch (error) { + promptConsumerError = error; + } + })(); + return query; + }, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), @@ -1379,7 +1454,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1416,7 +1491,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1463,7 +1538,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1517,7 +1592,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1584,7 +1659,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1649,7 +1724,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1730,7 +1805,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1821,7 +1896,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1987,7 +2062,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2056,7 +2131,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2278,7 +2353,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); assert.equal(session.threadId, THREAD_ID); @@ -2351,7 +2426,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2460,7 +2535,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2533,7 +2608,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: RESUME_THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), resumeCursor: { threadId: "resume-thread-1", resume: "550e8400-e29b-41d4-a716-446655440000", @@ -2575,7 +2650,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: RESUME_THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), resumeCursor: { threadId: RESUME_THREAD_ID, resume: durableSessionId, @@ -2658,7 +2733,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2692,7 +2767,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2772,14 +2847,14 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, attachments: [], @@ -2792,6 +2867,34 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("updates model on sendTurn for the adapter's bound custom instance id", () => { + const customInstanceId = ProviderInstanceId.make("claude_openrouter"); + const harness = makeHarness({ instanceId: customInstanceId }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + modelSelection: { + instanceId: customInstanceId, + model: "openai/gpt-5.5", + }, + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["openai/gpt-5.5"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect( "does not re-set the Claude model when the session already uses the same effective API model", () => { @@ -2799,13 +2902,13 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; const modelSelection = { - provider: "claudeAgent" as const, + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }; const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), modelSelection, runtimeMode: "full-access", }); @@ -2838,23 +2941,25 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "contextWindow", value: "1m" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "contextWindow", value: "1m" }], + ), attachments: [], }); yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, attachments: [], @@ -2874,7 +2979,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ @@ -2904,7 +3009,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode, }); @@ -2956,7 +3061,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ @@ -2979,7 +3084,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -3045,7 +3150,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -3117,7 +3222,7 @@ describe("ClaudeAdapterLive", () => { // Start session in approval-required mode so canUseTool fires. const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -3240,7 +3345,7 @@ describe("ClaudeAdapterLive", () => { // AskUserQuestion should still go through the user-input flow. const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -3306,7 +3411,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -3393,7 +3498,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 35f326af07b..c4cffbb16e3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -24,8 +24,11 @@ import { ApprovalRequestId, type CanonicalItemType, type CanonicalRequestType, + type ClaudeSettings, EventId, type ProviderApprovalDecision, + ProviderDriverKind, + ProviderInstanceId, ProviderItemId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, @@ -56,7 +59,7 @@ import { Exit, FileSystem, Fiber, - Layer, + Path, Queue, Random, Ref, @@ -65,7 +68,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, normalizeClaudeCliEffort, @@ -80,10 +83,10 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; -import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const PROVIDER = "claudeAgent" as const; +const PROVIDER = ProviderDriverKind.make("claudeAgent"); type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, @@ -182,6 +185,8 @@ interface ClaudeQueryRuntime extends AsyncIterable { } export interface ClaudeAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; @@ -565,13 +570,16 @@ const CLAUDE_SETTING_SOURCES = [ "local", ] as const satisfies ReadonlyArray; -function buildPromptText(input: ProviderSendTurnInput): string { +function buildPromptText( + input: ProviderSendTurnInput, + boundInstanceId: ProviderInstanceId, +): string { const rawEffort = - input.modelSelection?.provider === "claudeAgent" + input.modelSelection?.instanceId === boundInstanceId ? getModelSelectionStringOptionValue(input.modelSelection, "effort") : null; const claudeModel = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); @@ -611,9 +619,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( dependencies: { readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; + readonly boundInstanceId: ProviderInstanceId; }, ) { - const text = buildPromptText(input); + const text = buildPromptText(input, dependencies.boundInstanceId); const sdkContent: Array> = []; if (text.length > 0) { @@ -963,11 +972,17 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { return undefined; } -const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( +export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( + claudeSettings: ClaudeSettings, options?: ClaudeAdapterLiveOptions, ) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( + Effect.provideService(Path.Path, path), + ); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -989,7 +1004,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); - const serverSettingsService = yield* ServerSettingsService; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); @@ -2820,22 +2834,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const claudeSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.claudeAgent), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); const claudeBinaryPath = claudeSettings.binaryPath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; @@ -2863,7 +2865,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), }; - const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -2885,7 +2886,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: process.env, + env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; @@ -2933,6 +2934,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const session: ProviderSession = { threadId, provider: PROVIDER, + providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), @@ -3044,7 +3046,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSession(input.threadId); const modelSelection = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId + ? input.modelSelection + : undefined; if (context.turnState) { // Auto-close a stale synthetic turn (from background agent responses @@ -3118,6 +3122,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const message = yield* buildUserMessageEffect(input, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, + boundInstanceId, }); yield* Queue.offer(context.promptQueue, { @@ -3255,9 +3260,3 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, } satisfies ClaudeAdapterShape; }); - -export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); - -export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { - return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options)); -} diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f41082bbbab..43505967002 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,16 +1,13 @@ -import type { - ClaudeSettings, - ClaudeModelSelection, - ModelCapabilities, - ServerProvider, - ServerProviderModel, - ServerProviderAuth, - ServerProviderSlashCommand, - ServerProviderState, +import { + type ClaudeSettings, + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, + type ServerProviderModel, + type ServerProviderSlashCommand, } from "@t3tools/contracts"; -import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; +import { Effect, Option, Path, Result } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { createModelCapabilities, getModelSelectionStringOptionValue, @@ -27,27 +24,22 @@ import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, - AUTH_PROBE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, detailFromResult, - extractAuthBoolean, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, - type CommandResult, + type ServerProviderDraft, } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { ServerSettingsError } from "@t3tools/contracts"; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -const PROVIDER = "claudeAgent" as const; +const PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, @@ -242,7 +234,7 @@ export function normalizeClaudeCliEffort(effort: string | null | undefined): str return effort; } -export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { +export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { case "1m": return `${modelSelection.model}[1m]`; @@ -250,181 +242,6 @@ export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): s return modelSelection.model; } } -export function parseClaudeAuthStatusFromOutput(result: CommandResult): { - readonly status: Exclude; - readonly auth: Pick; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - auth: { status: "unknown" }, - message: - "Claude Agent authentication status command is unavailable in this version of Claude.", - }; - } - - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `claude login`") || - lowerOutput.includes("run claude login") - ) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", auth: { status: "authenticated" } }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - auth: { status: "unknown" }, - message: - "Could not verify Claude authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", auth: { status: "authenticated" } }; - } - - const detail = detailFromResult(result); - return { - status: "warning", - auth: { status: "unknown" }, - message: detail - ? `Could not verify Claude authentication status. ${detail}` - : "Could not verify Claude authentication status.", - }; -} - -// ── Subscription type detection ───────────────────────────────────── -// -// The SDK probe returns typed `AccountInfo.subscriptionType` directly. -// This walker is a best-effort fallback for the `claude auth status` -// JSON output whose shape is not guaranteed. - -/** Keys that directly hold a subscription/plan identifier. */ -const SUBSCRIPTION_TYPE_KEYS = [ - "subscriptionType", - "subscription_type", - "plan", - "tier", - "planType", - "plan_type", -] as const; - -/** Keys whose value may be a nested object containing subscription info. */ -const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const; -const AUTH_METHOD_KEYS = ["authMethod", "auth_method"] as const; -const AUTH_METHOD_CONTAINER_KEYS = ["auth", "account", "session"] as const; - -/** Lift an unknown value into `Option` if it is a non-empty string. */ -const asNonEmptyString = (v: unknown): Option.Option => - typeof v === "string" && v.length > 0 ? Option.some(v) : Option.none(); - -/** Lift an unknown value into `Option` if it is a plain object. */ -const asRecord = (v: unknown): Option.Option> => - typeof v === "object" && v !== null && !globalThis.Array.isArray(v) - ? Option.some(v as Record) - : Option.none(); - -/** - * Walk an unknown parsed JSON value looking for a subscription/plan - * identifier, returning the first match as an `Option`. - */ -function findSubscriptionType(value: unknown): Option.Option { - if (globalThis.Array.isArray(value)) { - return Option.firstSomeOf(value.map(findSubscriptionType)); - } - - return asRecord(value).pipe( - Option.flatMap((record) => { - const direct = Option.firstSomeOf( - SUBSCRIPTION_TYPE_KEYS.map((key) => asNonEmptyString(record[key])), - ); - if (Option.isSome(direct)) return direct; - - return Option.firstSomeOf( - SUBSCRIPTION_CONTAINER_KEYS.map((key) => - asRecord(record[key]).pipe(Option.flatMap(findSubscriptionType)), - ), - ); - }), - ); -} - -function findAuthMethod(value: unknown): Option.Option { - if (globalThis.Array.isArray(value)) { - return Option.firstSomeOf(value.map(findAuthMethod)); - } - - return asRecord(value).pipe( - Option.flatMap((record) => { - const direct = Option.firstSomeOf( - AUTH_METHOD_KEYS.map((key) => asNonEmptyString(record[key])), - ); - if (Option.isSome(direct)) return direct; - - return Option.firstSomeOf( - AUTH_METHOD_CONTAINER_KEYS.map((key) => - asRecord(record[key]).pipe(Option.flatMap(findAuthMethod)), - ), - ); - }), - ); -} - -/** - * Try to extract a subscription type from the `claude auth status` JSON - * output. This is a zero-cost operation on data we already have. - */ -const decodeUnknownJson = decodeJsonResult(Schema.Unknown); - -function extractSubscriptionTypeFromOutput(result: CommandResult): string | undefined { - const parsed = decodeUnknownJson(result.stdout.trim()); - if (Result.isFailure(parsed)) return undefined; - return Option.getOrUndefined(findSubscriptionType(parsed.success)); -} - -function extractClaudeAuthMethodFromOutput(result: CommandResult): string | undefined { - const parsed = decodeUnknownJson(result.stdout.trim()); - if (Result.isFailure(parsed)) return undefined; - return Option.getOrUndefined(findAuthMethod(parsed.success)); -} function toTitleCaseWords(value: string): string { return value @@ -439,11 +256,27 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | if (!normalized) return undefined; switch (normalized) { + case "claudemaxsubscription": + return "Max"; + case "claudemax5xsubscription": + return "Max 5x"; + case "claudemax20xsubscription": + return "Max 20x"; + case "claudeenterprisesubscription": + return "Enterprise"; + case "claudeteamsubscription": + return "Team"; + case "claudeprosubscription": + return "Pro"; + case "claudefreesubscription": + return "Free"; case "max": case "maxplan": + return "Max"; case "max5": + return "Max 5x"; case "max20": - return "Max"; + return "Max 20x"; case "enterprise": return "Enterprise"; case "team": @@ -460,10 +293,33 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); if (!normalized) return undefined; - if (normalized === "apikey") return "apiKey"; + if ( + normalized === "apikey" || + normalized === "anthropicapikey" || + normalized === "anthropicauthtoken" + ) { + return "apiKey"; + } return undefined; } +function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { + const subscriptionLabel = + claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); + const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); + + if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { + return subscriptionLabel; + } + if (normalized.startsWith("claude")) { + return `${subscriptionLabel} Subscription`; + } + if (normalized.endsWith("subscription")) { + return `Claude ${subscriptionLabel}`; + } + return `Claude ${subscriptionLabel} Subscription`; +} + function claudeAuthMetadata(input: { readonly subscriptionType: string | undefined; readonly authMethod: string | undefined; @@ -476,10 +332,9 @@ function claudeAuthMetadata(input: { } if (input.subscriptionType) { - const subscriptionLabel = claudeSubscriptionLabel(input.subscriptionType); return { type: input.subscriptionType, - label: `Claude ${subscriptionLabel ?? toTitleCaseWords(input.subscriptionType)} Subscription`, + label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), }; } @@ -495,6 +350,13 @@ function nonEmptyProbeString(value: string): string | undefined { return candidate ? candidate : undefined; } +type ClaudeCapabilitiesProbe = { + readonly email: string | undefined; + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly slashCommands: ReadonlyArray; +}; + function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { @@ -580,30 +442,46 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = (binaryPath: string) => { +const probeClaudeCapabilities = ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv = process.env, +) => { const abort = new AbortController(); - return Effect.tryPromise(async () => { - const q = claudeQuery({ - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. - // oxlint-disable-next-line require-yield - prompt: (async function* (): AsyncGenerator { - await waitForAbortSignal(abort.signal); - })(), - options: { - persistSession: false, - pathToClaudeCodeExecutable: binaryPath, - abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - stderr: () => {}, - }, + return Effect.gen(function* () { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + return yield* Effect.tryPromise(async () => { + const q = claudeQuery({ + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), + options: { + persistSession: false, + pathToClaudeCodeExecutable: claudeSettings.binaryPath, + abortController: abort, + settingSources: ["user", "project", "local"], + allowedTools: [], + env: claudeEnvironment, + stderr: () => {}, + }, + }); + const init = await q.initializationResult(); + const account = init.account as + | { + readonly email?: string; + readonly subscriptionType?: string; + readonly tokenSource?: string; + } + | undefined; + return { + email: account?.email, + subscriptionType: account?.subscriptionType, + tokenSource: account?.tokenSource, + slashCommands: parseClaudeInitializationCommands(init.commands), + } satisfies ClaudeCapabilitiesProbe; }); - const init = await q.initializationResult(); - return { - subscriptionType: init.account?.subscriptionType, - slashCommands: parseClaudeInitializationCommands(init.commands), - }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -619,31 +497,30 @@ const probeClaudeCapabilities = (binaryPath: string) => { ); }; -const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: ReadonlyArray) { - const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.claudeAgent), - ); +const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( + claudeSettings: ClaudeSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { + env: claudeEnvironment, shell: process.platform === "win32", }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( - resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, - resolveSlashCommands?: ( - binaryPath: string, - ) => Effect.Effect | undefined>, + claudeSettings: ClaudeSettings, + resolveCapabilities?: ( + claudeSettings: ClaudeSettings, + ) => Effect.Effect, + environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return< - ServerProvider, - ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Path.Path > { - const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.claudeAgent), - ); const checkedAt = new Date().toISOString(); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, @@ -654,7 +531,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (!claudeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, @@ -669,7 +545,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -677,7 +553,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, @@ -696,7 +571,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(versionProbe.success)) { return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, @@ -717,7 +591,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (version.code !== 0) { const detail = detailFromResult(version); return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, @@ -744,66 +617,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? undefined : formatClaudeOpus47UpgradeMessage(parsedVersion); - const slashCommands = - (resolveSlashCommands - ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( - Effect.orElseSucceed(() => undefined), - ) - : undefined) ?? []; + const capabilities = resolveCapabilities + ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - // ── Auth check + subscription detection ──────────────────────────── - - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), - Effect.result, - ); - - // Determine subscription type from multiple sources (cheapest first): - // 1. `claude auth status` JSON output (may or may not contain it) - // 2. Cached SDK probe (spawns a Claude process on miss, reads - // `initializationResult()` for account metadata, then aborts - // immediately — no API tokens are consumed) - - let subscriptionType: string | undefined; - let authMethod: string | undefined; - - if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { - subscriptionType = extractSubscriptionTypeFromOutput(authProbe.success.value); - authMethod = extractClaudeAuthMethodFromOutput(authProbe.success.value); - } - - if (!subscriptionType && resolveSubscriptionType) { - subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); - } - - // ── Handle auth results (same logic as before, adjusted models) ── - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - presentation: CLAUDE_PRESENTATION, - enabled: claudeSettings.enabled, - checkedAt, - models, - slashCommands: dedupedSlashCommands, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: - error instanceof Error - ? `Could not verify Claude authentication status: ${error.message}.` - : "Could not verify Claude authentication status.", - }, - }); - } - - if (Option.isNone(authProbe.success)) { + if (!capabilities) { return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, @@ -814,15 +635,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "warning", auth: { status: "unknown" }, - message: "Could not verify Claude authentication status. Timed out while running command.", + message: "Could not verify Claude authentication status from initialization result.", }, }); } - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); - const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); + const authMetadata = claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }); return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, @@ -831,21 +653,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( probe: { installed: true, version: parsedVersion, - status: parsed.status, + status: "ready", auth: { - ...parsed.auth, + status: "authenticated", + ...(capabilities.email ? { email: capabilities.email } : {}), ...(authMetadata ? authMetadata : {}), }, - ...(parsed.message - ? { message: parsed.message } - : opus47UpgradeMessage - ? { message: opus47UpgradeMessage } - : {}), + ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}), }, }); }); -const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => { +export const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProviderDraft => { const checkedAt = new Date().toISOString(); const models = providerModelsFromSettings( BUILT_IN_MODELS, @@ -856,7 +675,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid if (!claudeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, @@ -872,7 +690,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid } return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: true, checkedAt, @@ -887,43 +704,4 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid }); }; -export const ClaudeProviderLive = Layer.effect( - ClaudeProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const subscriptionProbeCache = yield* Cache.make({ - capacity: 1, - timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), - }); - - const checkProvider = checkClaudeProviderStatus( - (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( - Effect.map((probe) => probe?.subscriptionType), - ), - (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( - Effect.map((probe) => probe?.slashCommands), - ), - ).pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - - return yield* makeManagedServerProvider({ - getSettings: serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.claudeAgent), - Effect.orDie, - ), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.claudeAgent), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: makePendingClaudeProvider, - checkProvider, - }); - }), -); +export { probeClaudeCapabilities }; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index ad6fddab1ca..4df4fb5d32f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -4,7 +4,10 @@ import os from "node:os"; import path from "node:path"; import { ApprovalRequestId, + CodexSettings, EventId, + ProviderDriverKind, + ProviderInstanceId, ProviderItemId, type ProviderApprovalDecision, type ProviderEvent, @@ -18,13 +21,13 @@ import { createModelSelection } from "@t3tools/shared/model"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; -import { Effect, Exit, Fiber, Layer, Option, Queue, Scope, Stream } from "effect"; +import { Context, Effect, Exit, Fiber, Layer, Option, Queue, Schema, Scope, Stream } from "effect"; import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { type CodexSessionRuntimeOptions, @@ -32,7 +35,12 @@ import { type CodexSessionRuntimeShape, type CodexThreadSnapshot, } from "./CodexSessionRuntime.ts"; -import { makeCodexAdapterLive } from "./CodexAdapter.ts"; +import { makeCodexAdapter } from "./CodexAdapter.ts"; + +// Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`. +class CodexAdapter extends Context.Service()( + "test/CodexAdapter", +) {} const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -45,7 +53,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { public readonly startImpl = vi.fn(() => Promise.resolve({ - provider: "codex" as const, + provider: ProviderDriverKind.make("codex"), status: "ready" as const, runtimeMode: this.options.runtimeMode, threadId: this.options.threadId, @@ -203,7 +211,15 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory const validationRuntimeFactory = makeRuntimeFactory(); const validationLayer = it.layer( - makeCodexAdapterLive({ makeRuntime: validationRuntimeFactory.factory }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: validationRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -217,7 +233,7 @@ validationLayer("CodexAdapterLive validation", (it) => { const adapter = yield* CodexAdapter; const result = yield* adapter .startSession({ - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), threadId: asThreadId("thread-1"), runtimeMode: "full-access", }) @@ -227,7 +243,7 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.deepStrictEqual( result.failure, new ProviderAdapterValidationError({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), operation: "startSession", issue: "Expected provider 'codex' but received 'claudeAgent'.", }), @@ -241,9 +257,9 @@ validationLayer("CodexAdapterLive validation", (it) => { const adapter = yield* CodexAdapter; yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "fastMode", value: true }, ]), runtimeMode: "full-access", @@ -253,6 +269,7 @@ validationLayer("CodexAdapterLive validation", (it) => { binaryPath: "codex", cwd: process.cwd(), model: "gpt-5.3-codex", + providerInstanceId: ProviderInstanceId.make("codex"), serviceTier: "fast", threadId: asThreadId("thread-1"), runtimeMode: "full-access", @@ -263,7 +280,15 @@ validationLayer("CodexAdapterLive validation", (it) => { const sessionRuntimeFactory = makeRuntimeFactory(); const sessionErrorLayer = it.layer( - makeCodexAdapterLive({ makeRuntime: sessionRuntimeFactory.factory }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: sessionRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -294,7 +319,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { Effect.gen(function* () { const adapter = yield* CodexAdapter; yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("sess-missing"), runtimeMode: "full-access", }); @@ -306,7 +331,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { adapter.sendTurn({ threadId: asThreadId("sess-missing"), input: "hello", - modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), @@ -322,11 +347,74 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }); }), ); + + it.effect("maps codex model options for the adapter's bound custom instance id", () => { + const customInstanceId = ProviderInstanceId.make("codex_personal"); + const customRuntimeFactory = makeRuntimeFactory(); + const customLayer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + instanceId: customInstanceId, + makeRuntime: customRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-custom-instance"), + runtimeMode: "full-access", + }); + const runtime = customRuntimeFactory.lastRuntime; + assert.ok(runtime); + runtime.sendTurnImpl.mockClear(); + + yield* Effect.ignore( + adapter.sendTurn({ + threadId: asThreadId("sess-custom-instance"), + input: "hello", + modelSelection: createModelSelection( + ProviderInstanceId.make("codex_personal"), + "gpt-5.3-codex", + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ), + attachments: [], + }), + ); + + assert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { + input: "hello", + model: "gpt-5.3-codex", + effort: "high", + serviceTier: "fast", + }); + }).pipe(Effect.provide(customLayer)); + }); }); const lifecycleRuntimeFactory = makeRuntimeFactory(); const lifecycleLayer = it.layer( - makeCodexAdapterLive({ makeRuntime: lifecycleRuntimeFactory.factory }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: lifecycleRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -338,7 +426,7 @@ function startLifecycleRuntime() { return Effect.gen(function* () { const adapter = yield* CodexAdapter; yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -357,7 +445,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-msg-complete"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), method: "item/completed", threadId: asThreadId("thread-1"), @@ -399,7 +487,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-plan-complete"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), method: "item/completed", threadId: asThreadId("thread-1"), @@ -440,7 +528,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-plan-delta"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), method: "item/plan/delta", threadId: asThreadId("thread-1"), @@ -477,7 +565,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-session-closed"), kind: "session", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "session/closed", @@ -508,7 +596,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-retryable-error"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "error", @@ -546,7 +634,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-process-stderr"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "process/stderr", @@ -580,7 +668,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-process-stderr-websocket"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "process/stderr", @@ -616,7 +704,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-request-resolved"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "serverRequest/resolved", @@ -651,7 +739,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-file-read-request-resolved"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "serverRequest/resolved", @@ -686,7 +774,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-user-input-empty"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "item/tool/requestUserInput/answered", @@ -726,7 +814,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-windows-sandbox-failed"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "windowsSandbox/setupCompleted", @@ -771,7 +859,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-user-input-requested"), kind: "request", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "item/tool/requestUserInput", @@ -798,7 +886,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-user-input-resolved"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "item/tool/requestUserInput/answered", @@ -838,7 +926,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit({ id: asEventId("evt-codex-thread-token-usage-updated"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), createdAt: new Date().toISOString(), @@ -897,7 +985,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); const scopedLifecycleLayer = it.layer( - makeCodexAdapterLive({ makeRuntime: scopedLifecycleRuntimeFactory.factory }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: scopedLifecycleRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -912,7 +1008,7 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { const adapter = yield* CodexAdapter; yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-stop"), runtimeMode: "full-access", }); @@ -933,7 +1029,15 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true }); const scopedFailureLayer = it.layer( - makeCodexAdapterLive({ makeRuntime: scopedFailureRuntimeFactory.factory }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: scopedFailureRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -949,7 +1053,7 @@ scopedFailureLayer("CodexAdapterLive scoped startup failure", (it) => { const result = yield* adapter .startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-fail"), runtimeMode: "full-access", }) @@ -974,10 +1078,16 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => let scopeClosed = false; try { - const layer = makeCodexAdapterLive({ - makeRuntime: runtimeFactory.factory, - nativeEventLogPath: basePath, - }).pipe( + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: runtimeFactory.factory, + nativeEventLogPath: basePath, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -987,7 +1097,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context)); yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-logger"), runtimeMode: "full-access", }); @@ -999,7 +1109,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => yield* runtime.emit({ id: asEventId("evt-native-log"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-logger"), createdAt: new Date().toISOString(), method: "process/stderr", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5c824dc7309..5186dc29627 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -10,7 +10,10 @@ import { type CanonicalItemType, type CanonicalRequestType, + type CodexSettings, + ProviderDriverKind, type ProviderEvent, + ProviderInstanceId, type ProviderRuntimeEvent, type ProviderRequestKind, type ThreadTokenUsageSnapshot, @@ -21,7 +24,7 @@ import { ThreadId, ProviderSendTurnInput, } from "@t3tools/contracts"; -import { Effect, Exit, Fiber, FileSystem, Layer, Queue, Schema, Scope, Stream } from "effect"; +import { Effect, Exit, Fiber, FileSystem, Queue, Schema, Scope, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; @@ -39,10 +42,9 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; -import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, @@ -53,9 +55,11 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const PROVIDER = "codex" as const; +const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; readonly makeRuntime?: ( options: CodexSessionRuntimeOptions, ) => Effect.Effect< @@ -1318,9 +1322,20 @@ function mapToRuntimeEvents( return []; } -const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( +/** + * Build a Codex provider adapter bound to a specific `CodexSettings` payload. + * + * The adapter is a captured closure over `codexConfig` — the `binaryPath` and + * `homePath` are read from that payload, not from `ServerSettingsService`. + * This is what makes multi-instance routing possible: each `ProviderInstance` + * in the registry owns its own closure with its own config, so two Codex + * instances with different `homePath`s cannot step on each other. + */ +export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( + codexConfig: CodexSettings, options?: CodexAdapterLiveOptions, ) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); @@ -1333,7 +1348,6 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( : undefined); const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const serverSettingsService = yield* ServerSettingsService; const runtimeEventQueue = yield* Queue.unbounded(); const sessions = new Map(); @@ -1353,31 +1367,21 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* Effect.suspend(() => stopSessionInternal(existing)); } - const codexSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.codex), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, + providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), - binaryPath: codexSettings.binaryPath, - ...(codexSettings.homePath ? { homePath: codexSettings.homePath } : {}), + binaryPath: codexConfig.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), + ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(Schema.is(CodexResumeCursorSchema)(input.resumeCursor) ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: input.runtimeMode, - ...(input.modelSelection?.provider === "codex" + ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), - ...(input.modelSelection?.provider === "codex" && + ...(input.modelSelection?.instanceId === boundInstanceId && getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), @@ -1492,17 +1496,17 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const session = yield* requireSession(input.threadId); const reasoningEffort = - input.modelSelection?.provider === "codex" + input.modelSelection?.instanceId === boundInstanceId ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") : undefined; const fastMode = - input.modelSelection?.provider === "codex" + input.modelSelection?.instanceId === boundInstanceId ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") : undefined; return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), - ...(input.modelSelection?.provider === "codex" + ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), ...(reasoningEffort @@ -1676,8 +1680,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } satisfies CodexAdapterShape; }); -export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); - -export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { - return Layer.effect(CodexAdapter, makeCodexAdapter(options)); -} +// NOTE: the old `CodexAdapterLive` / `makeCodexAdapterLive` singleton Layer +// exports have been removed as part of the per-instance-driver refactor. +// `makeCodexAdapter(codexConfig, options?)` is now invoked directly by +// `CodexDriver.create()` for each configured instance; downstream consumers +// (server bootstrap, integration harness, this module's tests) will be +// migrated to the registry in a follow-up pass. diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 915609ca219..0917d842a6d 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,15 +1,4 @@ -import { - DateTime, - Duration, - Effect, - Equal, - Layer, - Option, - Result, - Schema, - Stream, - Types, -} from "effect"; +import { DateTime, Duration, Effect, Layer, Option, Result, Schema, Types } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; @@ -27,14 +16,11 @@ import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { buildServerProvider } from "../providerSnapshot.ts"; -import { CodexProvider } from "../Services/CodexProvider.ts"; +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; import packageJson from "../../../package.json" with { type: "json" }; -const PROVIDER = "codex" as const; const PROVIDER_PROBE_TIMEOUT_MS = 8_000; const CODEX_PRESENTATION = { displayName: "Codex", @@ -90,6 +76,11 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun } } +function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { + if (!account || account.type !== "chatgpt") return undefined; + return account.email; +} + function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { @@ -245,18 +236,33 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } +// Wrapped with `scopedSafeTeardown("codex-probe")` rather than the usual +// `Effect.scoped` so that a defect from the `Layer.build` finalizer (e.g. +// `ChildProcess.kill` throwing because the `codex app-server` child exited +// early) cannot override a successful probe body. Without this guard the +// defect bubbles past `Effect.result` in `checkCodexProviderStatus`, dies +// `refreshOneSource`, and `providersRef` never receives the snapshot. const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; }) { + // `~` is not shell-expanded when env vars are set via `child_process.spawn`, + // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip + // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; const clientContext = yield* Layer.build( CodexClient.layerCommand({ command: input.binaryPath, args: ["app-server"], cwd: input.cwd, - ...(input.homePath ? { env: { CODEX_HOME: expandHomePath(input.homePath) } } : {}), + env: { + ...(input.environment ?? process.env), + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }, }), ); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( @@ -305,7 +311,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun models: appendCustomCodexModels(models, input.customModels ?? []), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; -}, Effect.scoped); +}, scopedSafeTeardown("codex-probe")); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => codexSettings.customModels @@ -318,13 +324,12 @@ const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvi capabilities: null, })); -const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => { +const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProviderDraft => { const checkedAt = new Date().toISOString(); const models = emptyCodexModelsFromSettings(codexSettings); if (!codexSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CODEX_PRESENTATION, enabled: false, checkedAt, @@ -341,7 +346,6 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider } return buildServerProvider({ - provider: PROVIDER, presentation: CODEX_PRESENTATION, enabled: true, checkedAt, @@ -363,10 +367,12 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); + const authEmail = codexAccountEmail(account.account); const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), + ...(authEmail ? { email: authEmail } : {}), } satisfies ServerProvider["auth"]; if (account.account) { @@ -385,31 +391,29 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): } export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( + codexSettings: CodexSettings, probe: (input: { readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; readonly customModels: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner > = probeCodexAppServerProvider, + environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return< - ServerProvider, + ServerProviderDraft, ServerSettingsError, - ServerSettingsService | ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner > { - const codexSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.codex), - ); const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); if (!codexSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CODEX_PRESENTATION, enabled: false, checkedAt, @@ -430,13 +434,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, cwd: process.cwd(), customModels: codexSettings.customModels, + environment, }).pipe(Effect.timeoutOption(Duration.millis(PROVIDER_PROBE_TIMEOUT_MS)), Effect.result); if (Result.isFailure(probeResult)) { const error = probeResult.failure; const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error); return buildServerProvider({ - provider: PROVIDER, presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, @@ -456,7 +460,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Option.isNone(probeResult.success)) { return buildServerProvider({ - provider: PROVIDER, presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, @@ -476,7 +479,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const accountStatus = accountProbeStatus(snapshot.account); return buildServerProvider({ - provider: PROVIDER, presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, @@ -492,28 +494,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); }); -export const CodexProviderLive = Layer.effect( - CodexProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const checkProvider = checkCodexProviderStatus().pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - - return yield* makeManagedServerProvider({ - getSettings: serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.codex), - Effect.orDie, - ), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.codex), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: makePendingCodexProvider, - checkProvider, - refreshInterval: Duration.minutes(5), - }); - }), -); +// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of +// the per-instance-driver refactor. `CodexDriver.create()` builds a managed +// snapshot per instance (each with its own `CodexSettings`) and hands the +// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. +// +// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are +// re-exported for use by `CodexDriver`. +export { makePendingCodexProvider }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index e45e1825ee2..4f9011fba04 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1,10 +1,10 @@ -import { randomUUID } from "node:crypto"; - import { ApprovalRequestId, - DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_MODEL, EventId, + ProviderDriverKind, ProviderItemId, + type ProviderInstanceId, type ProviderApprovalDecision, type ProviderEvent, type ProviderInteractionMode, @@ -17,7 +17,7 @@ import { TurnId, } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Schema, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Random, Schema, Stream } from "effect"; import * as SchemaIssue from "effect/SchemaIssue"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; @@ -26,13 +26,13 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; -const PROVIDER = "codex" as const; +const PROVIDER = ProviderDriverKind.make("codex"); const ANSI_ESCAPE_CHAR = String.fromCharCode(27); const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); @@ -76,8 +76,10 @@ type CodexThreadItem = export interface CodexSessionRuntimeOptions { readonly threadId: ThreadId; + readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; readonly homePath?: string; + readonly environment?: NodeJS.ProcessEnv; readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model?: string; @@ -87,7 +89,10 @@ export interface CodexSessionRuntimeOptions { export interface CodexSessionRuntimeSendTurnInput { readonly input?: string; - readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string }>; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; readonly model?: string; readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier | undefined; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; @@ -303,7 +308,7 @@ function buildCodexCollaborationMode(input: { if (input.interactionMode === undefined) { return undefined; } - const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL_BY_PROVIDER.codex; + const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; return { mode: input.interactionMode, settings: { @@ -321,7 +326,10 @@ export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; readonly prompt?: string; - readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string }>; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; readonly model?: string; readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; @@ -680,13 +688,19 @@ export const makeCodexSessionRuntime = ( const collabReceiverTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); + // `~` is not shell-expanded when env vars are set via + // `child_process.spawn`; `expandHomePath` lets a configured + // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. + const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; + const env = { + ...(options.environment ?? process.env), + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; const child = yield* spawner .spawn( ChildProcess.make(options.binaryPath, ["app-server"], { cwd: options.cwd, - ...(options.homePath - ? { env: { ...process.env, CODEX_HOME: expandHomePath(options.homePath) } } - : {}), + env, shell: process.platform === "win32", }), ) @@ -712,6 +726,7 @@ export const makeCodexSessionRuntime = ( const initialSession = { provider: PROVIDER, + ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), status: "connecting", runtimeMode: options.runtimeMode, cwd: options.cwd, @@ -725,13 +740,15 @@ export const makeCodexSessionRuntime = ( const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); const emitEvent = (event: Omit) => - offerEvent({ - id: EventId.make(randomUUID()), - provider: PROVIDER, - createdAt: new Date().toISOString(), - ...event, - }); - + Effect.flatMap(Random.nextUUIDv4, (id) => + offerEvent({ + id: EventId.make(id), + provider: PROVIDER, + ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), + createdAt: new Date().toISOString(), + ...event, + }), + ); const emitSessionEvent = (method: string, message: string) => emitEvent({ kind: "session", @@ -891,7 +908,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(randomUUID()); + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const decision = yield* Deferred.make(); @@ -947,7 +964,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(randomUUID()); + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const decision = yield* Deferred.make(); @@ -1003,7 +1020,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(randomUUID()); + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const answers = yield* Deferred.make(); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 1f619d49f1f..375eec049a2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -5,15 +5,27 @@ import { fileURLToPath } from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; +import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"; import { createModelSelection } from "@t3tools/shared/model"; -import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import { + ApprovalRequestId, + CursorSettings, + ProviderDriverKind, + type ProviderRuntimeEvent, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { makeCursorAdapterLive } from "./CursorAdapter.ts"; +import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapter } from "./CursorAdapter.ts"; + +// Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. +class CursorAdapter extends Context.Service()( + "test/CursorAdapter", +) {} const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); @@ -91,8 +103,31 @@ async function waitForFileContent(filePath: string, attempts = 40) { throw new Error(`Timed out waiting for file content at ${filePath}`); } +// Tests mutate `ServerSettingsService` mid-flight (e.g. setting +// `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter +// captures `cursorSettings` once at construction, so without a resolver +// the mutation is invisible — sessions would spawn the constructor's +// (empty) binary path. Wiring `resolveSettings` through +// `ServerSettingsService.getSettings` makes each session read the latest +// snapshot, matching the old "always read live" behavior that these +// tests assumed. +const makeResolveCursorSettings = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + return serverSettings.getSettings.pipe( + Effect.map((snapshot) => snapshot.providers.cursor), + Effect.orDie, + ); +}); + const cursorAdapterTestLayer = it.layer( - makeCursorAdapterLive().pipe( + Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = Schema.decodeSync(CursorSettings)({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); + }), + ).pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -120,10 +155,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const session = yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); assert.equal(session.provider, "cursor"); @@ -205,10 +240,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); yield* adapter.stopSession(threadId); @@ -244,17 +279,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { [ adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }), adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }), ], { concurrency: "unbounded" }, @@ -276,7 +311,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const result = yield* adapter .startSession({ threadId: ThreadId.make("bad-provider"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), cwd: process.cwd(), runtimeMode: "full-access", }) @@ -302,10 +337,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "composer-2" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, }); yield* adapter.sendTurn({ @@ -358,7 +393,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { providers: { cursor: { binaryPath: wrapperPath } }, }); - const modelSelection = createModelSelection("cursor", "gpt-5.4", [ + const modelSelection = createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ { id: "reasoning", value: "xhigh" }, { id: "contextWindow", value: "1m" }, { id: "fastMode", value: true }, @@ -366,7 +401,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection, @@ -460,10 +495,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const program = Effect.gen(function* () { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "approval-required", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const turn = yield* adapter.sendTurn({ @@ -560,7 +595,14 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); }).pipe( Effect.provide( - makeCursorAdapterLive().pipe( + Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = Schema.decodeSync(CursorSettings)({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); + }), + ).pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -615,10 +657,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const turn = yield* adapter.sendTurn({ @@ -714,10 +756,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const turn = yield* adapter.sendTurn({ @@ -836,10 +878,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "approval-required", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const sendTurnFiber = yield* adapter @@ -906,10 +948,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "approval-required", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const sendTurnFiber = yield* adapter @@ -949,10 +991,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const sendTurnFiber = yield* adapter @@ -992,10 +1034,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const sendTurnFiber = yield* adapter @@ -1035,10 +1077,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); @@ -1073,10 +1115,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "composer-2" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, }); yield* adapter.sendTurn({ @@ -1089,7 +1131,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "second turn after switching model", attachments: [], - modelSelection: createModelSelection("cursor", "composer-2", [ + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ { id: "fastMode", value: true }, ]), }); @@ -1136,17 +1178,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "composer-2" }, + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, }); yield* adapter.sendTurn({ threadId, input: "first turn with fast mode", attachments: [], - modelSelection: createModelSelection("cursor", "composer-2", [ + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ { id: "fastMode", value: true }, ]), }); @@ -1155,7 +1197,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "second turn without fast mode", attachments: [], - modelSelection: createModelSelection("cursor", "composer-2", [ + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ { id: "fastMode", value: false }, ]), }); @@ -1174,4 +1216,91 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect( + "applies fast mode on the first turn when modelSelection uses a non-default instance id", + () => { + const customInstanceId = ProviderInstanceId.make("cursor_secondary"); + // Custom-instance cases can't share the suite-level `CursorAdapter` + // layer because that one binds `instanceId: "cursor"`. We build a + // fresh layer graph — including a fresh `ServerSettingsService` — so + // mid-test `updateSettings` calls target the same service instance the + // adapter's `resolveSettings` reads from, and so the outer + // `yield* ServerSettingsService` sees the same snapshot as well. + const customAdapterLayer = Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = Schema.decodeSync(CursorSettings)({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { + instanceId: customInstanceId, + resolveSettings, + }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-custom-instance-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-fast-mode-custom-instance"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: customInstanceId, + model: "composer-2", + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn with fast mode", + attachments: [], + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: true }, + ]), + instanceId: customInstanceId, + }, + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const fastConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "fast", + ); + assert.isAbove( + fastConfigRequests.length, + 0, + "fast mode should apply when instance id matches the adapter binding", + ); + const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; + assert.equal((lastFastConfig?.params as Record)?.value, "true"); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(customAdapterLayer)); + }, + ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5e12bfbefe2..34d1221b022 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,6 +7,7 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, + type CursorSettings, type ProviderOptionSelection, EventId, type ProviderApprovalDecision, @@ -14,6 +15,8 @@ import { type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, RuntimeRequestId, type RuntimeMode, type ThreadId, @@ -26,7 +29,6 @@ import { Exit, Fiber, FileSystem, - Layer, Option, PubSub, Random, @@ -40,7 +42,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -72,19 +73,37 @@ import { extractPlanMarkdown, extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; -import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const PROVIDER = "cursor" as const; +const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_RESUME_VERSION = 1 as const; const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; const ACP_APPROVAL_MODE_ALIASES = ["ask"]; export interface CursorAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`cursor`). + */ + readonly instanceId?: typeof ProviderInstanceId.Type; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `cursorSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; } interface PendingApproval { @@ -280,12 +299,15 @@ function selectAutoApprovedPermissionOption( return undefined; } -function makeCursorAdapter(options?: CursorAdapterLiveOptions) { +export function makeCursorAdapter( + cursorSettings: CursorSettings, + options?: CursorAdapterLiveOptions, +) { return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); const fileSystem = yield* FileSystem.FileSystem; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); - const serverSettingsService = yield* ServerSettingsService; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -440,25 +462,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const cwd = nodePath.resolve(input.cwd.trim()); const cursorModelSelection = - input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* stopSessionInternal(existing); } - const cursorSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.cursor), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - const pendingApprovals = new Map(); const pendingUserInputs = new Map(); const sessionScope = yield* Scope.make("sequential"); @@ -475,8 +484,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { threadId: input.threadId, }); + // Resolve the CursorSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { cursor: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveCursorSettings = options?.resolveSettings + ? yield* options.resolveSettings + : cursorSettings; + const acp = yield* makeCursorAcpRuntime({ - cursorSettings, + cursorSettings: effectiveCursorSettings, + ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), @@ -666,6 +688,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const now = yield* nowIso; const session: ProviderSession = { provider: PROVIDER, + providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, cwd, @@ -815,7 +838,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const ctx = yield* requireSession(input.threadId); const turnId = TurnId.make(crypto.randomUUID()); const turnModelSelection = - input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const model = turnModelSelection?.model ?? ctx.session.model; const resolvedModel = resolveCursorAcpBaseModelId(model); yield* applyRequestedSessionConfiguration({ @@ -1049,9 +1072,3 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { } satisfies CursorAdapterShape; }); } - -export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); - -export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) { - return Layer.effect(CursorAdapter, makeCursorAdapter(opts)); -} diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index c0ac5d20b0c..33ef20acf21 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,10 +1,7 @@ -import * as path from "node:path"; -import * as os from "node:os"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; @@ -14,6 +11,7 @@ import { buildCursorProviderSnapshot, buildCursorCapabilitiesFromConfigOptions, buildCursorDiscoveredModelsFromConfigOptions, + checkCursorProviderStatus, discoverCursorModelCapabilitiesViaAcp, discoverCursorModelsViaAcp, getCursorFallbackModels, @@ -25,8 +23,14 @@ import { resolveCursorAcpConfigUpdates, } from "./CursorProvider.ts"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const runNode = ( + effect: Effect.Effect, +): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); + +const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); +}); function selectDescriptor( id: string, @@ -53,8 +57,16 @@ function booleanDescriptor(id: string, label: string, currentValue?: boolean) { }; } -async function makeMockAgentWrapper(extraEnv?: Record) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); +const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( + extraEnv?: Record, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-mock-", + }); const wrapperPath = path.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) @@ -63,23 +75,80 @@ async function makeMockAgentWrapper(extraEnv?: Record) { ${envExports} exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); return wrapperPath; -} +}); + +const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-about-mock-", + }); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const script = `#!/bin/sh +if [ "$1" = "about" ]; then + printf 'CLI Version 2026.04.09-f2b0fcd\\n' + printf 'User Email cursor@example.com\\n' + exit 0 +fi +exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" +`; + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); + return wrapperPath; +}); -async function waitForFileContent(filePath: string, attempts = 40): Promise { +const waitForFileContent = Effect.fn("waitForFileContent")(function* ( + filePath: string, + attempts = 40, +) { + const fileSystem = yield* FileSystem.FileSystem; for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const content = await readFile(filePath, "utf8"); + const content = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.catch(() => Effect.void)); + if (content !== undefined) { if (content.trim().length > 0) { return content; } - } catch {} - await new Promise((resolve) => setTimeout(resolve, 50)); + } + yield* Effect.sleep("50 millis"); } - throw new Error(`Timed out waiting for file content at ${filePath}`); -} + return yield* Effect.fail(new Error(`Timed out waiting for file content at ${filePath}`)); +}); + +const makeProviderStatusEnvFixture = Effect.fn("makeProviderStatusEnvFixture")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-status-env-", + }); + return { + requestLogPath: path.join(tempDir, "requests.ndjson"), + wrapperPath: yield* makeMockAgentWithAboutWrapper(), + }; +}); + +const makeExitLogFixture = Effect.fn("makeExitLogFixture")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix, + }); + const exitLogPath = path.join(tempDir, "exit.log"); + return { + exitLogPath, + wrapperPath: yield* makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + }; +}); const parameterizedGpt54ConfigOptions = [ { @@ -428,9 +497,38 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { }); }); +describe("checkCursorProviderStatus", () => { + it("passes the injected environment to ACP model discovery", async () => { + const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); + + const provider = await Effect.runPromise( + checkCursorProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + { + ...process.env, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + ).pipe(Effect.provide(NodeServices.layer)), + ); + + expect(provider.models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + await expect(runNode(waitForFileContent(requestLogPath))).resolves.toContain("initialize"); + }); +}); + describe("discoverCursorModelsViaAcp", () => { it("keeps the ACP probe runtime alive long enough to discover models", async () => { - const wrapperPath = await makeMockAgentWrapper(); + const wrapperPath = await runNode(makeMockAgentWrapper()); const models = await Effect.runPromise( discoverCursorModelsViaAcp({ @@ -450,11 +548,9 @@ describe("discoverCursorModelsViaAcp", () => { }); it("closes the ACP probe runtime after discovery completes", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-")); - const exitLogPath = path.join(tempDir, "exit.log"); - const wrapperPath = await makeMockAgentWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }); + const { exitLogPath, wrapperPath } = await runNode( + makeExitLogFixture("cursor-provider-exit-log-"), + ); await Effect.runPromise( discoverCursorModelsViaAcp({ @@ -465,18 +561,16 @@ describe("discoverCursorModelsViaAcp", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - const exitLog = await waitForFileContent(exitLogPath); + const exitLog = await runNode(waitForFileContent(exitLogPath)); expect(exitLog).toContain("SIGTERM"); }); }); describe("discoverCursorModelCapabilitiesViaAcp", () => { it("closes all ACP probe runtimes after capability enrichment completes", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-")); - const exitLogPath = path.join(tempDir, "exit.log"); - const wrapperPath = await makeMockAgentWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }); + const { exitLogPath, wrapperPath } = await runNode( + makeExitLogFixture("cursor-capabilities-exit-log-"), + ); const existingModels: ReadonlyArray = [ { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, @@ -508,7 +602,7 @@ describe("discoverCursorModelCapabilitiesViaAcp", () => { "claude-opus-4-6", ]); - const exitLog = await waitForFileContent(exitLogPath); + const exitLog = await runNode(waitForFileContent(exitLogPath)); expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); }); }); @@ -530,6 +624,7 @@ describe("parseCursorAboutOutput", () => { status: "ready", auth: { status: "authenticated", + email: "jmarminge@gmail.com", type: "Team", label: "Cursor Team Subscription", }, diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 4225023f7d8..ad52f63fbb2 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,6 +1,4 @@ -import * as nodeFs from "node:fs"; import * as nodeOs from "node:os"; -import * as nodePath from "node:path"; import type { CursorSettings, @@ -10,10 +8,10 @@ import type { ServerProviderAuth, ServerProviderModel, ServerProviderState, - ServerSettingsError, } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, @@ -29,13 +27,11 @@ import { isCommandMissingCause, providerModelsFromSettings, type CommandResult, + type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { CursorProvider } from "../Services/CursorProvider.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -const PROVIDER = "cursor" as const; +const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_PRESENTATION = { displayName: "Cursor", badgeLabel: "Early Access", @@ -48,7 +44,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; -const CURSOR_REFRESH_INTERVAL = "1 hour"; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { @@ -56,13 +51,14 @@ export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { }, } satisfies NonNullable; -function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider { +export function buildInitialCursorProviderSnapshot( + cursorSettings: CursorSettings, +): ServerProviderDraft { const checkedAt = new Date().toISOString(); const models = getCursorFallbackModels(cursorSettings); if (!cursorSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, @@ -78,7 +74,6 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser } return buildServerProvider({ - provider: PROVIDER, presentation: CURSOR_PRESENTATION, enabled: true, checkedAt, @@ -112,7 +107,12 @@ function flattenSessionConfigSelectOptions( } return configOption.options.flatMap((entry) => "value" in entry - ? [{ value: entry.value.trim(), name: entry.name.trim() } satisfies CursorSessionSelectOption] + ? [ + { + value: entry.value.trim(), + name: entry.name.trim(), + } satisfies CursorSessionSelectOption, + ] : entry.options.map( (option) => ({ @@ -392,7 +392,10 @@ export function buildCursorDiscoveredModelsFromConfigOptions( ); } -const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => +const makeCursorAcpProbeRuntime = ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const acpContext = yield* Layer.build( @@ -404,6 +407,7 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => "acp", ], cwd: process.cwd(), + env: environment, }, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -417,7 +421,12 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, -) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped); + environment: NodeJS.ProcessEnv = process.env, +) => + makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( + Effect.flatMap(useRuntime), + Effect.scoped, + ); function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( @@ -460,12 +469,18 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s export function resolveCursorAcpConfigUpdates( configOptions: ReadonlyArray | null | undefined, selections: ReadonlyArray | null | undefined, -): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { +): ReadonlyArray<{ + readonly configId: string; + readonly value: string | boolean; +}> { if (!configOptions || configOptions.length === 0) { return []; } - const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + const updates: Array<{ + readonly configId: string; + readonly value: string | boolean; + }> = []; const reasoningOption = findCursorEffortConfigOption(configOptions); const requestedReasoning = normalizeCursorReasoningValue( @@ -525,43 +540,129 @@ export function resolveCursorAcpConfigUpdates( return updates; } -export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => - withCursorAcpProbeRuntime(cursorSettings, (acp) => - Effect.map(acp.start(), (started) => - buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []), - ), +export const discoverCursorModelsViaAcp = ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + withCursorAcpProbeRuntime( + cursorSettings, + (acp) => + Effect.map(acp.start(), (started) => + buildCursorDiscoveredModelsFromConfigOptions( + started.sessionSetupResult.configOptions ?? [], + ), + ), + environment, ); export const discoverCursorModelCapabilitiesViaAcp = ( cursorSettings: CursorSettings, existingModels: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, ) => - withCursorAcpProbeRuntime(cursorSettings, (acp) => - Effect.gen(function* () { - const started = yield* acp.start(); - const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; - const modelOption = findCursorModelConfigOption(initialConfigOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } + withCursorAcpProbeRuntime( + cursorSettings, + (acp) => + Effect.gen(function* () { + const started = yield* acp.start(); + const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; + const modelOption = findCursorModelConfigOption(initialConfigOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return []; + } + + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + const capabilitiesBySlug = new Map(); + if (currentModelValue) { + capabilitiesBySlug.set( + currentModelValue, + buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + ); + } - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const capabilitiesBySlug = new Map(); - if (currentModelValue) { - capabilitiesBySlug.set( - currentModelValue, - buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + const targetModelSlugs = new Set( + existingModels + .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) + .map((model) => model.slug), ); - } + if (targetModelSlugs.size === 0) { + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); + } + + const probedCapabilities = yield* Effect.forEach( + modelChoices, + (modelChoice) => { + const modelSlug = modelChoice.value.trim(); + if ( + !modelSlug || + !targetModelSlugs.has(modelSlug) || + capabilitiesBySlug.has(modelSlug) + ) { + return Effect.void.pipe( + Effect.as(undefined), + ); + } + + return withCursorAcpProbeRuntime( + cursorSettings, + (probeAcp) => + Effect.gen(function* () { + const probeStarted = yield* probeAcp.start(); + const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; + const probeModelOption = findCursorModelConfigOption(probeConfigOptions); + const probeCurrentModelValue = + probeModelOption?.type === "select" + ? probeModelOption.currentValue?.trim() || undefined + : undefined; + yield* Effect.annotateCurrentSpan({ + "cursor.acp.model.value": modelSlug, + "cursor.acp.model.currentValue": probeCurrentModelValue, + "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, + }); + const nextConfigOptions = + probeCurrentModelValue === modelSlug + ? probeConfigOptions + : yield* probeAcp + .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) + .pipe( + Effect.map((response) => response.configOptions ?? probeConfigOptions), + ); + return [ + modelSlug, + buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), + ] as const; + }), + environment, + ).pipe( + Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), + Effect.retry({ times: 3 }), + Effect.withSpan("cursor-acp-model-capability-probe"), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP capability probe failed", { + modelSlug, + cause: Cause.pretty(cause), + }), + ), + ); + }, + { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, + ); + + for (const entry of probedCapabilities) { + if (!entry) { + continue; + } + capabilitiesBySlug.set(entry[0], entry[1]); + } - const targetModelSlugs = new Set( - existingModels - .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) - .map((model) => model.slug), - ); - if (targetModelSlugs.size === 0) { return buildCursorDiscoveredModels( modelChoices.map((modelChoice) => ({ slug: modelChoice.value.trim(), @@ -569,73 +670,8 @@ export const discoverCursorModelCapabilitiesViaAcp = ( capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, })), ); - } - - const probedCapabilities = yield* Effect.forEach( - modelChoices, - (modelChoice) => { - const modelSlug = modelChoice.value.trim(); - if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) { - return Effect.void.pipe( - Effect.as(undefined), - ); - } - - return withCursorAcpProbeRuntime(cursorSettings, (probeAcp) => - Effect.gen(function* () { - const probeStarted = yield* probeAcp.start(); - const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; - const probeModelOption = findCursorModelConfigOption(probeConfigOptions); - const probeCurrentModelValue = - probeModelOption?.type === "select" - ? probeModelOption.currentValue?.trim() || undefined - : undefined; - yield* Effect.annotateCurrentSpan({ - "cursor.acp.model.value": modelSlug, - "cursor.acp.model.currentValue": probeCurrentModelValue, - "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, - }); - const nextConfigOptions = - probeCurrentModelValue === modelSlug - ? probeConfigOptions - : yield* probeAcp - .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) - .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions)); - return [ - modelSlug, - buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), - ] as const; - }), - ).pipe( - Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), - Effect.retry({ times: 3 }), - Effect.withSpan("cursor-acp-model-capability-probe"), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP capability probe failed", { - modelSlug, - cause: Cause.pretty(cause), - }), - ), - ); - }, - { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, - ); - - for (const entry of probedCapabilities) { - if (!entry) { - continue; - } - capabilitiesBySlug.set(entry[0], entry[1]); - } - - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), - ); - }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), + }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), + environment, ); export function getCursorFallbackModels( @@ -683,10 +719,9 @@ export function buildCursorProviderSnapshot(input: { readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; -}): ServerProvider { +}): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); return buildServerProvider({ - provider: PROVIDER, presentation: CURSOR_PRESENTATION, enabled: input.cursorSettings.enabled, checkedAt: input.checkedAt, @@ -811,14 +846,13 @@ function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { ); } -function readCursorCliConfigChannel(): string | undefined { - try { - const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json"); - return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8")); - } catch { - return undefined; - } -} +const readCursorCliConfigChannel = Effect.fn("readCursorCliConfigChannel")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(nodeOs.homedir(), ".cursor", "cli-config.json"); + const raw = yield* fileSystem.readFileString(configPath).pipe(Effect.orElseSucceed(() => "")); + return parseCursorCliConfigChannel(raw); +}); export function getCursorParameterizedModelPickerUnsupportedMessage(input: { readonly version: string | null | undefined; @@ -934,6 +968,7 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult status: "ready", auth: { status: "authenticated", + email: userEmail, ...authMetadata, }, }; @@ -989,17 +1024,22 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult } // Any non-empty email value means authenticated. - return { version, status: "ready", auth: { status: "authenticated" } }; + return { + version, + status: "ready", + auth: { status: "authenticated", email: userEmail }, + }; } -const runCursorCommand = (args: ReadonlyArray) => +const runCursorCommand = ( + cursorSettings: CursorSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.cursor), - ); const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { + env: environment, shell: process.platform === "win32", }); @@ -1016,203 +1056,202 @@ const runCursorCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runCursorAboutCommand = Effect.gen(function* () { - const jsonResult = yield* runCursorCommand(["about", "--format", "json"]); - if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { - return jsonResult; - } - return yield* runCursorCommand(["about"]); -}); - -export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( - function* (): Effect.fn.Return< - ServerProvider, - ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService - > { - const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.cursor), +const runCursorAboutCommand = ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const jsonResult = yield* runCursorCommand( + cursorSettings, + ["about", "--format", "json"], + environment, ); - const checkedAt = new Date().toISOString(); - const fallbackModels = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - presentation: CURSOR_PRESENTATION, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); + if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { + return jsonResult; } + return yield* runCursorCommand(cursorSettings, ["about"], environment); + }); - // Single `agent about` probe: returns version + auth status in one call. - const aboutProbe = yield* runCursorAboutCommand.pipe( - Effect.timeoutOption(ABOUT_TIMEOUT_MS), - Effect.result, - ); +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = new Date().toISOString(); + const fallbackModels = getCursorFallbackModels(cursorSettings); - if (Result.isFailure(aboutProbe)) { - const error = aboutProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." - : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }, - }); - } + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } - if (Option.isNone(aboutProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Cursor Agent CLI is installed but timed out while running `agent about`.", - }, - }); - } + // Single `agent about` probe: returns version + auth status in one call. + const aboutProbe = yield* runCursorAboutCommand(cursorSettings, environment).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); - const parsed = parseCursorAboutOutput(aboutProbe.success.value); - const parameterizedModelPickerUnsupportedMessage = - getCursorParameterizedModelPickerUnsupportedMessage({ - version: parsed.version, - channel: readCursorCliConfigChannel(), - }); - if (parameterizedModelPickerUnsupportedMessage) { - return buildServerProvider({ - provider: PROVIDER, - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: parsed.version, - status: "error", - auth: parsed.auth, - message: - parsed.auth.status === "unauthenticated" && parsed.message - ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` - : parameterizedModelPickerUnsupportedMessage, - }, - }); - } - let discoveredModels = Option.none>(); - let discoveryWarning: string | undefined; - if (parsed.auth.status !== "unauthenticated") { - const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - ), - ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Cursor ACP model discovery failed", { - cause: Cause.pretty(discoveryExit.cause), - }); - discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; - } else if (Option.isNone(discoveryExit.value)) { - discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; - } else if (discoveryExit.value.value.length === 0) { - discoveryWarning = "Cursor ACP model discovery returned no built-in models."; - } else { - discoveredModels = discoveryExit.value; - } - } - return buildCursorProviderSnapshot({ + if (Result.isFailure(aboutProbe)) { + const error = aboutProbe.failure; + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, checkedAt, - cursorSettings, - parsed, - discoveredModels: Option.getOrElse( - Option.filter(discoveredModels, (models) => models.length > 0), - () => [] as const, - ), - ...(discoveryWarning ? { discoveryWarning } : {}), + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." + : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, }); - }, -); + } -export const CursorProviderLive = Layer.effect( - CursorProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + if (Option.isNone(aboutProbe.success)) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Cursor Agent CLI is installed but timed out while running `agent about`.", + }, + }); + } - const checkProvider = checkCursorProviderStatus().pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + const parsed = parseCursorAboutOutput(aboutProbe.success.value); + const cursorCliConfigChannel = yield* readCursorCliConfigChannel(); + const parameterizedModelPickerUnsupportedMessage = + getCursorParameterizedModelPickerUnsupportedMessage({ + version: parsed.version, + channel: cursorCliConfigChannel, + }); + if (parameterizedModelPickerUnsupportedMessage) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: parsed.version, + status: "error", + auth: parsed.auth, + message: + parsed.auth.status === "unauthenticated" && parsed.message + ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` + : parameterizedModelPickerUnsupportedMessage, + }, + }); + } + let discoveredModels = Option.none>(); + let discoveryWarning: string | undefined; + if (parsed.auth.status !== "unauthenticated") { + const discoveryExit = yield* Effect.exit( + discoverCursorModelsViaAcp(cursorSettings, environment).pipe( + Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Cursor ACP model discovery failed", { + cause: Cause.pretty(discoveryExit.cause), + }); + discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; + } else if (Option.isNone(discoveryExit.value)) { + discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; + } else if (discoveryExit.value.value.length === 0) { + discoveryWarning = "Cursor ACP model discovery returned no built-in models."; + } else { + discoveredModels = discoveryExit.value; + } + } + return buildCursorProviderSnapshot({ + checkedAt, + cursorSettings, + parsed, + discoveredModels: Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => [] as const, + ), + ...(discoveryWarning ? { discoveryWarning } : {}), + }); +}); - return yield* makeManagedServerProvider({ - getSettings: serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.cursor), - Effect.orDie, - ), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.cursor), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: buildInitialCursorProviderSnapshot, - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => { - if ( - !settings.enabled || - snapshot.auth.status === "unauthenticated" || - !snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)) - ) { - return Effect.void; - } +export function hasUncapturedCursorModels(snapshot: Pick): boolean { + return snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)); +} - return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.flatMap((discoveredModels) => { - if (discoveredModels.length === 0) { - return Effect.void; - } +/** + * Background capability enrichment for a Cursor snapshot. + * + * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot` + * hook: runs the slow ACP per-model capability probe, and republishes the + * snapshot through `publishSnapshot` when new capabilities arrive. Skips + * the probe when the provider is disabled, unauthenticated, or has no + * uncaptured models. Keeps `EMPTY_CAPABILITIES` and the `PROVIDER` literal + * private to this module. + */ +export const enrichCursorSnapshot = (input: { + readonly settings: CursorSettings; + readonly environment?: NodeJS.ProcessEnv; + readonly snapshot: ServerProvider; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; +}): Effect.Effect => { + const { settings, snapshot, publishSnapshot } = input; + const stampIdentity = input.stampIdentity ?? ((value) => value); - return publishSnapshot({ - ...snapshot, - models: providerModelsFromSettings( - discoveredModels, - PROVIDER, - settings.customModels, - EMPTY_CAPABILITIES, - ), - }); - }), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP background capability enrichment failed", { - models: snapshot.models.map((model) => model.slug), - cause: Cause.pretty(cause), - }).pipe(Effect.asVoid), + if ( + !settings.enabled || + snapshot.auth.status === "unauthenticated" || + !hasUncapturedCursorModels(snapshot) + ) { + return Effect.void; + } + + return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe( + Effect.flatMap((discoveredModels) => { + if (discoveredModels.length === 0) { + return Effect.void; + } + return publishSnapshot( + stampIdentity({ + ...snapshot, + models: providerModelsFromSettings( + discoveredModels, + PROVIDER, + settings.customModels, + EMPTY_CAPABILITIES, ), - ); - }, - refreshInterval: CURSOR_REFRESH_INTERVAL, - }); - }), -); + }), + ); + }), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP background capability enrichment failed", { + models: snapshot.models.map((model) => model.slug), + cause: Cause.pretty(cause), + }).pipe(Effect.asVoid), + ), + ); +}; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9a391b55394..370e5c028ff 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2,14 +2,20 @@ import assert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"; import { beforeEach } from "vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { + OpenCodeSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { OpenCodeRuntime, OpenCodeRuntimeError, @@ -17,10 +23,15 @@ import { } from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, - makeOpenCodeAdapterLive, + makeOpenCodeAdapter, mergeOpenCodeAssistantText, } from "./OpenCodeAdapter.ts"; +// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. +class OpenCodeAdapter extends Context.Service()( + "test/OpenCodeAdapter", +) {} + const asThreadId = (value: string): ThreadId => ThreadId.make(value); type MessageEntry = { @@ -39,6 +50,7 @@ const runtimeMock = { abortCalls: [] as string[], closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + promptCalls: [] as Array, promptAsyncError: null as Error | null, closeError: null as Error | null, messages: [] as MessageEntry[], @@ -51,6 +63,7 @@ const runtimeMock = { this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; + this.state.promptCalls.length = 0; this.state.promptAsyncError = null; this.state.closeError = null; this.state.messages = []; @@ -111,7 +124,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, - promptAsync: async () => { + promptAsync: async (input: unknown) => { + runtimeMock.state.promptCalls.push(input); if (runtimeMock.state.promptAsyncError) { throw runtimeMock.state.promptAsyncError; } @@ -165,7 +179,24 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory listBindings: () => Effect.succeed([]), }); -const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( +// The adapter now receives its settings as a plain argument (the old design +// read from `ServerSettingsService` internally). The test-only +// `ServerSettingsService` below is still kept because other dependencies in +// the layer graph reach for it — but the routing values the assertions +// probe (serverUrl, serverPassword) must be threaded directly through the +// decoded `OpenCodeSettings`. +const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", +}); + +const OpenCodeAdapterTestLayer = Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings); + }), +).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( @@ -196,7 +227,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const adapter = yield* OpenCodeAdapter; const session = yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), runtimeMode: "full-access", }); @@ -215,7 +246,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), runtimeMode: "full-access", }); @@ -230,16 +261,42 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("emits one session.exited event when stopping a session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stop-event"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.stopSession(threadId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + assert.deepEqual( + events.map((event) => event.type), + ["session.started", "thread.started", "session.exited"], + ); + }), + ); + it.effect("clears session state even when cleanup finalizers throw", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-stop-all-a"), runtimeMode: "full-access", }); yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-stop-all-b"), runtimeMode: "full-access", }); @@ -261,11 +318,44 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("completes streamEvents when the adapter scope closes", () => + Effect.gen(function* () { + const scope = yield* Scope.make("sequential"); + let scopeClosed = false; + + try { + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + const context = yield* Layer.buildWithScope(adapterLayer, scope); + const adapter = yield* Effect.service(OpenCodeAdapter).pipe(Effect.provide(context)); + const eventsFiber = yield* adapter.streamEvents.pipe(Stream.runCollect, Effect.forkChild); + + yield* Scope.close(scope, Exit.void); + scopeClosed = true; + + const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); + assert.equal(Exit.hasInterrupts(exit), true); + } finally { + if (!scopeClosed) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + } + }), + ); + it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-send-turn-failure"), runtimeMode: "full-access", }); @@ -276,7 +366,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { threadId: asThreadId("thread-send-turn-failure"), input: "Fix it", modelSelection: { - provider: "opencode", + instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }, }) @@ -299,12 +389,158 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { + const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { + instanceId: customInstanceId, + }); + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-custom-instance"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: asThreadId("thread-custom-instance"), + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + [ + { id: "agent", value: "github-copilot" }, + { id: "variant", value: "high" }, + ], + ), + }); + + assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + agent: "github-copilot", + variant: "high", + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { + const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { + instanceId: customInstanceId, + }); + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-custom-instance-fallback-model"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + ), + }); + + yield* adapter.sendTurn({ + threadId, + input: "Fix it", + }); + + assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("rejects sendTurn model selections for another instance id", () => { + const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { + instanceId: customInstanceId, + }); + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-custom-instance-wrong-selection"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const error = yield* adapter + .sendTurn({ + threadId, + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet-4-5", + ), + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag !== "ProviderAdapterValidationError") { + throw new Error("Unexpected error type"); + } + assert.equal( + error.issue, + "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", + ); + assert.deepEqual(runtimeMock.state.promptCalls, []); + }).pipe(Effect.provide(adapterLayer)); + }); + it.effect("reverts the full thread when rollback removes every assistant turn", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-rollback-all"); yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId, runtimeMode: "full-access", }); @@ -396,9 +632,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { close: () => Effect.void, }; - const adapterLayer = makeOpenCodeAdapterLive({ - nativeEventLogger, - }).pipe( + const adapterLayer = Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger, + }); + }), + ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( @@ -419,7 +660,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const session = yield* Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const started = yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-native-log"), runtimeMode: "full-access", }); @@ -475,9 +716,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { close: () => Effect.void, }; - const adapterLayer = makeOpenCodeAdapterLive({ - nativeEventLogger, - }).pipe( + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger, + }), + ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( @@ -504,7 +748,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; yield* adapter.startSession({ - provider: "opencode", + provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-native-log-failure"), runtimeMode: "full-access", }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index aa504d5b969..a7b179ab1c3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,7 +1,8 @@ -import { randomUUID } from "node:crypto"; - import { EventId, + type OpenCodeSettings, + ProviderDriverKind, + ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, RuntimeItemId, @@ -11,13 +12,12 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; +import { Cause, Effect, Exit, Queue, Random, Ref, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -26,7 +26,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { buildOpenCodePermissionRules, OpenCodeRuntime, @@ -41,7 +41,7 @@ import { type OpenCodeServerConnection, } from "../opencodeRuntime.ts"; -const PROVIDER = "opencode" as const; +const PROVIDER = ProviderDriverKind.make("opencode"); interface OpenCodeTurnSnapshot { readonly id: TurnId; @@ -89,6 +89,8 @@ interface OpenCodeSessionContext { } export interface OpenCodeAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -125,35 +127,38 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc cause, }); -function buildEventBase(input: { +const buildEventBase = (input: { readonly threadId: ThreadId; readonly turnId?: TurnId | undefined; readonly itemId?: string | undefined; readonly requestId?: string | undefined; readonly createdAt?: string | undefined; readonly raw?: unknown; -}): Pick< - ProviderRuntimeEvent, - "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" -> { - return { - eventId: EventId.make(randomUUID()), - provider: PROVIDER, - threadId: input.threadId, - createdAt: input.createdAt ?? nowIso(), - ...(input.turnId ? { turnId: input.turnId } : {}), - ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), - ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), - ...(input.raw !== undefined - ? { - raw: { - source: "opencode.sdk.event", - payload: input.raw, - }, - } - : {}), - }; -} +}): Effect.Effect< + Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" + > +> => + Random.nextUUIDv4.pipe( + Effect.map((uuid) => ({ + eventId: EventId.make(uuid), + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt ?? nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw !== undefined + ? { + raw: { + source: "opencode.sdk.event", + payload: input.raw, + }, + } + : {}), + })), + ); function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); @@ -245,13 +250,19 @@ function ensureSessionContext( ): OpenCodeSessionContext { const session = sessions.get(threadId); if (!session) { - throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); } // `ensureSessionContext` is a sync gate used from both sync helpers and // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell — // no fiber suspension required, which keeps this callable everywhere. if (Ref.getUnsafe(session.stopped)) { - throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + throw new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }); } return session; } @@ -419,7 +430,7 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( ) { // Race-safe one-shot: first caller flips the flag, everyone else no-ops. if (yield* Ref.getAndSet(context.stopped, true)) { - return; + return false; } // Best-effort remote abort. The scope close below tears down the local @@ -433,957 +444,990 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( // runs each finalizer we registered — the `AbortController.abort()` call, // the child-process termination, etc. yield* Scope.close(context.sessionScope, Exit.void); + return true; }); -export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { - return Layer.effect( - OpenCodeAdapter, - Effect.gen(function* () { - const serverConfig = yield* ServerConfig; - const serverSettings = yield* ServerSettingsService; - const openCodeRuntime = yield* OpenCodeRuntime; - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - // Only close loggers we created. If the caller passed one in via - // `options.nativeEventLogger`, they own its lifecycle. - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const runtimeEvents = yield* Queue.unbounded(); - const sessions = new Map(); - - // Layer-level finalizer: when the adapter layer shuts down, stop every - // session. Each session's `Scope.close` tears down its spawned OpenCode - // server (via the `ChildProcessSpawner` finalizer installed in - // `startOpenCodeServerProcess`) and interrupts the forked event/exit - // fibers. Consumers that can't reason about Effect scopes therefore - // cannot leak OpenCode child processes by forgetting to call `stopAll`. - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - // `ignoreCause` swallows both typed failures (none here) and defects - // from throwing scope finalizers so a sibling's death can't interrupt - // the remaining cleanups. - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); - // Close the logger AFTER session teardown so any final lifecycle - // events emitted during shutdown still get written. `close` flushes - // the `Logger.batched` window and closes each per-thread - // `RotatingFileSink` handle owned by the logger's internal scope. - if (managedNativeEventLogger !== undefined) { - yield* managedNativeEventLogger.close(); - } - }), - ); - - const emit = (event: ProviderRuntimeEvent) => - Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); - const writeNativeEvent = ( - threadId: ThreadId, - event: { - readonly observedAt: string; - readonly event: Record; +export function makeOpenCodeAdapter( + openCodeSettings: OpenCodeSettings, + options?: OpenCodeAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); + const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + // Only close loggers we created. If the caller passed one in via + // `options.nativeEventLogger`, they own its lifecycle. + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + // Layer-level finalizer: when the adapter layer shuts down, stop every + // session. Each session's `Scope.close` tears down its spawned OpenCode + // server (via the `ChildProcessSpawner` finalizer installed in + // `startOpenCodeServerProcess`) and interrupts the forked event/exit + // fibers. Consumers that can't reason about Effect scopes therefore + // cannot leak OpenCode child processes by forgetting to call `stopAll`. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + // `ignoreCause` swallows both typed failures (none here) and defects + // from throwing scope finalizers so a sibling's death can't interrupt + // the remaining cleanups. + yield* Effect.forEach( + contexts, + (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + { concurrency: "unbounded", discard: true }, + ); + // Close the logger AFTER session teardown so any final lifecycle + // events emitted during shutdown still get written. `close` flushes + // the `Logger.batched` window and closes each per-thread + // `RotatingFileSink` handle owned by the logger's internal scope. + if (managedNativeEventLogger !== undefined) { + yield* managedNativeEventLogger.close(); + } + }).pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))), + ); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const writeNativeEvent = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); + const writeNativeEventBestEffort = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + + const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( + context: OpenCodeSessionContext, + message: string, + ) { + // Atomic one-shot: two fibers can race here (the event-pump on stream + // failure and the server-exit watcher). `getAndSet` flips the flag in + // a single step so the loser observes `true` and returns; a plain + // `Ref.get` would let both racers slip past and emit duplicates. + if (yield* Ref.getAndSet(context.stopped, true)) { + return; + } + const turnId = context.activeTurnId; + sessions.delete(context.session.threadId); + // Emit lifecycle events BEFORE tearing down the scope. Both call sites + // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; + // closing that scope triggers the fiber-interrupt finalizer, so any + // subsequent yield point would unwind and silently drop these emits. + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "runtime.error", + payload: { + message, + class: "transport_error", }, - ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); - const writeNativeEventBestEffort = ( - threadId: ThreadId, - event: { - readonly observedAt: string; - readonly event: Record; + }).pipe(Effect.ignore); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", }, - ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); - - const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( - context: OpenCodeSessionContext, - message: string, - ) { - // Atomic one-shot: two fibers can race here (the event-pump on stream - // failure and the server-exit watcher). `getAndSet` flips the flag in - // a single step so the loser observes `true` and returns; a plain - // `Ref.get` would let both racers slip past and emit duplicates. - if (yield* Ref.getAndSet(context.stopped, true)) { - return; - } - const turnId = context.activeTurnId; - sessions.delete(context.session.threadId); - // Emit lifecycle events BEFORE tearing down the scope. Both call sites - // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; - // closing that scope triggers the fiber-interrupt finalizer, so any - // subsequent yield point would unwind and silently drop these emits. + }).pipe(Effect.ignore); + // Inline the teardown that `stopOpenCodeContext` would do; we can't + // delegate to it because our `getAndSet` above already flipped the + // one-shot guard, so the call would no-op. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); + yield* Scope.close(context.sessionScope, Exit.void); + }); + + /** Emit content.delta and item.completed events for an assistant text part. */ + const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( + context: OpenCodeSessionContext, + part: Part, + turnId: TurnId | undefined, + raw: unknown, + ) { + const text = textFromPart(part); + if (text === undefined) { + return; + } + const previousText = context.emittedTextByPartId.get(part.id); + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); + context.emittedTextByPartId.set(part.id, latestText); + if (latestText !== text) { + context.partById.set( + part.id, + (part.type === "text" || part.type === "reasoning" + ? { ...part, text: latestText } + : part) satisfies Part, + ); + } + if (deltaToEmit.length > 0) { yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId }), - type: "runtime.error", + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + part.type === "text" || part.type === "reasoning" + ? isoFromEpochMs(part.time?.start) + : undefined, + raw, + })), + type: "content.delta", payload: { - message, - class: "transport_error", + streamKind: resolveTextStreamKind(part), + delta: deltaToEmit, }, - }).pipe(Effect.ignore); + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId }), - type: "session.exited", + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw, + })), + type: "item.completed", payload: { - reason: message, - recoverable: false, - exitKind: "error", + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(latestText.length > 0 ? { detail: latestText } : {}), }, - }).pipe(Effect.ignore); - // Inline the teardown that `stopOpenCodeContext` would do; we can't - // delegate to it because our `getAndSet` above already flipped the - // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); - yield* Scope.close(context.sessionScope, Exit.void); + }); + } + }); + + const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeSubscribedEvent, + ) { + const payloadSessionId = + "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined; + if (payloadSessionId !== context.openCodeSessionId) { + return; + } + + const turnId = context.activeTurnId; + yield* writeNativeEventBestEffort(context.session.threadId, { + observedAt: nowIso(), + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + payload: event, + }, }); - /** Emit content.delta and item.completed events for an assistant text part. */ - const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( - context: OpenCodeSessionContext, - part: Part, - turnId: TurnId | undefined, - raw: unknown, - ) { - const text = textFromPart(part); - if (text === undefined) { - return; + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; + } + yield* emitAssistantTextDelta(context, part, turnId, event); + } + } + break; } - const previousText = context.emittedTextByPartId.get(part.id); - const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); - context.emittedTextByPartId.set(part.id, latestText); - if (latestText !== text) { - context.partById.set( - part.id, - (part.type === "text" || part.type === "reasoning" - ? { ...part, text: latestText } - : part) satisfies Part, - ); + + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; } - if (deltaToEmit.length > 0) { + + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + if (!existingPart) { + break; + } + const role = messageRoleForPart(context, existingPart); + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousText = + context.emittedTextByPartId.get(event.properties.partID) ?? + textFromPart(existingPart) ?? + ""; + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); + if (deltaToEmit.length === 0) { + break; + } + context.emittedTextByPartId.set(event.properties.partID, nextText); + if (existingPart.type === "text" || existingPart.type === "reasoning") { + context.partById.set(event.properties.partID, { + ...existingPart, + text: nextText, + }); + } yield* emit({ - ...buildEventBase({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - itemId: part.id, - createdAt: - part.type === "text" || part.type === "reasoning" - ? isoFromEpochMs(part.time?.start) - : undefined, - raw, - }), + itemId: event.properties.partID, + raw: event, + })), type: "content.delta", payload: { - streamKind: resolveTextStreamKind(part), + streamKind, delta: deltaToEmit, }, }); + break; } - if ( - part.type === "text" && - part.time?.end !== undefined && - !context.completedAssistantPartIds.has(part.id) - ) { - context.completedAssistantPartIds.add(part.id); + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); + + if (messageRole === "assistant") { + yield* emitAssistantTextDelta(context, part, turnId, event); + } + + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + })), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + yield* emit(runtimeEvent); + } + break; + } + + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); yield* emit({ - ...buildEventBase({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - itemId: part.id, - createdAt: isoFromEpochMs(part.time.end), - raw, - }), - type: "item.completed", + requestId: event.properties.id, + raw: event, + })), + type: "request.opened", payload: { - itemType: "assistant_message", - status: "completed", - title: "Assistant message", - ...(latestText.length > 0 ? { detail: latestText } : {}), + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, }, }); + break; } - }); - const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( - context: OpenCodeSessionContext, - event: OpenCodeSubscribedEvent, - ) { - const payloadSessionId = - "properties" in event - ? (event.properties as { sessionID?: unknown }).sessionID - : undefined; - if (payloadSessionId !== context.openCodeSessionId) { - return; + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + })), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; } - const turnId = context.activeTurnId; - yield* writeNativeEventBestEffort(context.session.threadId, { - observedAt: nowIso(), - event: { - provider: PROVIDER, - threadId: context.session.threadId, - providerThreadId: context.openCodeSessionId, - type: event.type, - ...(turnId ? { turnId } : {}), - payload: event, - }, - }); + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + })), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } - switch (event.type) { - case "message.updated": { - context.messageRoleById.set(event.properties.info.id, event.properties.info.role); - if (event.properties.info.role === "assistant") { - for (const part of context.partById.values()) { - if (part.messageID !== event.properties.info.id) { - continue; - } - yield* emitAssistantTextDelta(context, part, turnId, event); - } - } - break; - } + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + })), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } - case "message.removed": { - context.messageRoleById.delete(event.properties.messageID); - break; - } + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + })), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } - case "message.part.delta": { - const existingPart = context.partById.get(event.properties.partID); - if (!existingPart) { - break; - } - const role = messageRoleForPart(context, existingPart); - if (role !== "assistant") { - break; - } - const streamKind = resolveTextStreamKind(existingPart); - const delta = event.properties.delta; - if (delta.length === 0) { - break; - } - const previousText = - context.emittedTextByPartId.get(event.properties.partID) ?? - textFromPart(existingPart) ?? - ""; - const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); - if (deltaToEmit.length === 0) { - break; - } - context.emittedTextByPartId.set(event.properties.partID, nextText); - if (existingPart.type === "text" || existingPart.type === "reasoning") { - context.partById.set(event.properties.partID, { - ...existingPart, - text: nextText, - }); - } - yield* emit({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: event.properties.partID, - raw: event, - }), - type: "content.delta", - payload: { - streamKind, - delta: deltaToEmit, - }, + case "session.status": { + if (event.properties.status.type === "busy") { + updateProviderSession(context, { + status: "running", + activeTurnId: turnId, }); - break; - } - - case "message.part.updated": { - const part = event.properties.part; - context.partById.set(part.id, part); - const messageRole = messageRoleForPart(context, part); - - if (messageRole === "assistant") { - yield* emitAssistantTextDelta(context, part, turnId, event); - } - - if (part.type === "tool") { - const itemType = toToolLifecycleItemType(part.tool); - const title = - part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; - const detail = detailFromToolPart(part); - const payload = { - itemType, - ...(part.state.status === "error" - ? { status: "failed" as const } - : part.state.status === "completed" - ? { status: "completed" as const } - : { status: "inProgress" as const }), - ...(title ? { title } : {}), - ...(detail ? { detail } : {}), - data: { - tool: part.tool, - state: part.state, - }, - }; - const runtimeEvent: ProviderRuntimeEvent = { - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.callID, - createdAt: toolStateCreatedAt(part), - raw: event, - }), - type: - part.state.status === "pending" - ? "item.started" - : part.state.status === "completed" || part.state.status === "error" - ? "item.completed" - : "item.updated", - payload, - }; - appendTurnItem(context, turnId, part); - yield* emit(runtimeEvent); - } - break; } - case "permission.asked": { - context.pendingPermissions.set(event.properties.id, event.properties); + if (event.properties.status.type === "retry") { yield* emit({ - ...buildEventBase({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.id, raw: event, - }), - type: "request.opened", + })), + type: "runtime.warning", payload: { - requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, + message: event.properties.status.message, + detail: event.properties.status, }, }); break; } - case "permission.replied": { - context.pendingPermissions.delete(event.properties.requestID); + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* emit({ - ...buildEventBase({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, raw: event, - }), - type: "request.resolved", + })), + type: "turn.completed", payload: { - requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), + state: "completed", }, }); - break; } + break; + } - case "question.asked": { - context.pendingQuestions.set(event.properties.id, event.properties); + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + updateProviderSession( + context, + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, + ); + if (activeTurnId) { yield* emit({ - ...buildEventBase({ + ...(yield* buildEventBase({ threadId: context.session.threadId, - turnId, - requestId: event.properties.id, + turnId: activeTurnId, raw: event, - }), - type: "user-input.requested", + })), + type: "turn.completed", payload: { - questions: normalizeQuestionRequest(event.properties), + state: "failed", + errorMessage: message, }, }); - break; } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; + } - case "question.replied": { - const request = context.pendingQuestions.get(event.properties.requestID); - context.pendingQuestions.delete(event.properties.requestID); - const answers = Object.fromEntries( - (request?.questions ?? []).map((question, index) => [ - openCodeQuestionId(index, question), - event.properties.answers[index]?.join(", ") ?? "", - ]), - ); - yield* emit({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - }), - type: "user-input.resolved", - payload: { answers }, - }); - break; - } + default: + break; + } + }); + + const startEventPump = Effect.fn("startEventPump")(function* (context: OpenCodeSessionContext) { + // One AbortController per session scope. The finalizer fires when + // the scope closes (explicit stop, unexpected exit, or layer + // shutdown) and cancels the in-flight `event.subscribe` fetch so + // the async iterable unwinds cleanly. + const eventsAbortController = new AbortController(); + yield* Scope.addFinalizer( + context.sessionScope, + Effect.sync(() => eventsAbortController.abort()), + ); - case "question.rejected": { - context.pendingQuestions.delete(event.properties.requestID); - yield* emit({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, + // Fibers forked into `context.sessionScope` are interrupted + // automatically when the scope closes — no bookkeeping required. + yield* Effect.flatMap( + runOpenCodeSdk("event.subscribe", () => + context.client.event.subscribe(undefined, { + signal: eventsAbortController.signal, + }), + ), + (subscription) => + Stream.fromAsyncIterable( + subscription.stream, + (cause) => + new OpenCodeRuntimeError({ + operation: "event.subscribe", + detail: openCodeRuntimeErrorDetail(cause), + cause, }), - type: "user-input.resolved", - payload: { answers: {} }, - }); - break; - } - - case "session.status": { - if (event.properties.status.type === "busy") { - updateProviderSession(context, { status: "running", activeTurnId: turnId }); + ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ).pipe( + Effect.exit, + Effect.flatMap((exit) => + Effect.gen(function* () { + // Expected paths: caller aborted the fetch or the session + // has already been marked stopped. Treat as a clean exit. + if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { + return; } - - if (event.properties.status.type === "retry") { - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), - type: "runtime.warning", - payload: { - message: event.properties.status.message, - detail: event.properties.status, - }, - }); - break; - } - - if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), - type: "turn.completed", - payload: { - state: "completed", - }, - }); - } - break; - } - - case "session.error": { - const message = sessionErrorMessage(event.properties.error); - const activeTurnId = context.activeTurnId; - context.activeTurnId = undefined; - updateProviderSession( - context, - { - status: "error", - lastError: message, - }, - { clearActiveTurnId: true }, - ); - if (activeTurnId) { - yield* emit({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId: activeTurnId, - raw: event, - }), - type: "turn.completed", - payload: { - state: "failed", - errorMessage: message, - }, - }); + if (Exit.isFailure(exit)) { + yield* emitUnexpectedExit( + context, + openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), + ); } - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, raw: event }), - type: "runtime.error", - payload: { - message, - class: "provider_error", - detail: event.properties.error, - }, - }); - break; - } - - default: - break; - } - }); - - const startEventPump = Effect.fn("startEventPump")(function* ( - context: OpenCodeSessionContext, - ) { - // One AbortController per session scope. The finalizer fires when - // the scope closes (explicit stop, unexpected exit, or layer - // shutdown) and cancels the in-flight `event.subscribe` fetch so - // the async iterable unwinds cleanly. - const eventsAbortController = new AbortController(); - yield* Scope.addFinalizer( - context.sessionScope, - Effect.sync(() => eventsAbortController.abort()), - ); + }), + ), + Effect.forkIn(context.sessionScope), + ); - // Fibers forked into `context.sessionScope` are interrupted - // automatically when the scope closes — no bookkeeping required. - yield* Effect.flatMap( - runOpenCodeSdk("event.subscribe", () => - context.client.event.subscribe(undefined, { - signal: eventsAbortController.signal, - }), - ), - (subscription) => - Stream.fromAsyncIterable( - subscription.stream, - (cause) => - new OpenCodeRuntimeError({ - operation: "event.subscribe", - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), - ).pipe( - Effect.exit, - Effect.flatMap((exit) => + if (!context.server.external && context.server.exitCode !== null) { + yield* context.server.exitCode.pipe( + Effect.flatMap((code) => Effect.gen(function* () { - // Expected paths: caller aborted the fetch or the session - // has already been marked stopped. Treat as a clean exit. - if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { + if (yield* Ref.get(context.stopped)) { return; } - if (Exit.isFailure(exit)) { - yield* emitUnexpectedExit( - context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), - ); - } + yield* emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`); }), ), Effect.forkIn(context.sessionScope), ); - - if (!context.server.external && context.server.exitCode !== null) { - yield* context.server.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - if (yield* Ref.get(context.stopped)) { - return; - } - yield* emitUnexpectedExit( - context, - `OpenCode server exited unexpectedly (${code}).`, - ); - }), - ), - Effect.forkIn(context.sessionScope), - ); + } + }); + + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + const binaryPath = openCodeSettings.binaryPath; + const serverUrl = openCodeSettings.serverUrl; + const serverPassword = openCodeSettings.serverPassword; + const directory = input.cwd ?? serverConfig.cwd; + const existing = sessions.get(input.threadId); + if (existing) { + yield* stopOpenCodeContext(existing); + sessions.delete(input.threadId); } - }); - const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( - function* (input) { - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: "Failed to read OpenCode settings.", - cause, + const started = yield* Effect.gen(function* () { + const sessionScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + Effect.gen(function* () { + // The runtime binds the server's lifetime to the Scope.Scope + // we provide below — closing `sessionScope` kills the child + // process automatically. No manual `server.close()` needed. + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath, + serverUrl, + ...(options?.environment ? { environment: options.environment } : {}), + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.external && serverPassword ? { serverPassword } : {}), + }); + const openCodeSession = yield* runOpenCodeSdk("session.create", () => + client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), }), - ), - ); - const binaryPath = settings.providers.opencode.binaryPath; - const serverUrl = settings.providers.opencode.serverUrl; - const serverPassword = settings.providers.opencode.serverPassword; - const directory = input.cwd ?? serverConfig.cwd; - const existing = sessions.get(input.threadId); - if (existing) { - yield* stopOpenCodeContext(existing); - sessions.delete(input.threadId); - } - - const started = yield* Effect.gen(function* () { - const sessionScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - Effect.gen(function* () { - // The runtime binds the server's lifetime to the Scope.Scope - // we provide below — closing `sessionScope` kills the child - // process automatically. No manual `server.close()` needed. - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath, - serverUrl, - }); - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory, - ...(server.external && serverPassword ? { serverPassword } : {}), + ); + if (!openCodeSession.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.create", + detail: "OpenCode session.create returned no session payload.", }); - const openCodeSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - if (!openCodeSession.data) { - return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", - }); - } - return { sessionScope, server, client, openCodeSession: openCodeSession.data }; - }).pipe(Effect.provideService(Scope.Scope, sessionScope)), - ); - if (Exit.isFailure(startedExit)) { - yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); - return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); - } - return startedExit.value; - }); - - // Guard against a concurrent startSession call that may have raced - // and already inserted a session while we were awaiting async work. - const raceWinner = sessions.get(input.threadId); - if (raceWinner) { - // Another call won the race – clean up the session we just created - // (including the remote SDK session) and return the existing one. - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ sessionID: started.openCodeSession.id }), - ).pipe(Effect.ignore); - yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); - return raceWinner.session; + } + return { + sessionScope, + server, + client, + openCodeSession: openCodeSession.data, + }; + }).pipe(Effect.provideService(Scope.Scope, sessionScope)), + ); + if (Exit.isFailure(startedExit)) { + yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); + return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); } + return startedExit.value; + }); - const createdAt = nowIso(); - const session: ProviderSession = { - provider: PROVIDER, - status: "ready", - runtimeMode: input.runtimeMode, - cwd: directory, - ...(input.modelSelection ? { model: input.modelSelection.model } : {}), - threadId: input.threadId, - createdAt, - updatedAt: createdAt, - }; - - const context: OpenCodeSessionContext = { - session, - client: started.client, - server: started.server, - directory, - openCodeSessionId: started.openCodeSession.id, - pendingPermissions: new Map(), - pendingQuestions: new Map(), - partById: new Map(), - emittedTextByPartId: new Map(), - messageRoleById: new Map(), - completedAssistantPartIds: new Set(), - turns: [], - activeTurnId: undefined, - activeAgent: undefined, - activeVariant: undefined, - stopped: yield* Ref.make(false), - sessionScope: started.sessionScope, - }; - sessions.set(input.threadId, context); - yield* startEventPump(context); - - yield* emit({ - ...buildEventBase({ threadId: input.threadId }), - type: "session.started", - payload: { - message: "OpenCode session started", - }, - }); - yield* emit({ - ...buildEventBase({ threadId: input.threadId }), - type: "thread.started", - payload: { - providerThreadId: started.openCodeSession.id, - }, - }); - - return session; - }, - ); - - const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = ensureSessionContext(sessions, input.threadId); - const turnId = TurnId.make(`opencode-turn-${randomUUID()}`); - const modelSelection = - input.modelSelection ?? - (context.session.model - ? { provider: PROVIDER, model: context.session.model } - : undefined); - const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); - if (!parsedModel) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "OpenCode model selection must use the 'provider/model' format.", - }); + // Guard against a concurrent startSession call that may have raced + // and already inserted a session while we were awaiting async work. + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another call won the race – clean up the session we just created + // (including the remote SDK session) and return the existing one. + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSession.id, + }), + ).pipe(Effect.ignore); + yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); + return raceWinner.session; } - const text = input.input?.trim(); - const fileParts = toOpenCodeFileParts({ - attachments: input.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - if ((!text || text.length === 0) && fileParts.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "OpenCode turns require text input or at least one attachment.", - }); - } + const createdAt = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; - const agent = - input.modelSelection?.provider === PROVIDER - ? getModelSelectionStringOptionValue(input.modelSelection, "agent") - : undefined; - const variant = - input.modelSelection?.provider === PROVIDER - ? getModelSelectionStringOptionValue(input.modelSelection, "variant") - : undefined; - - context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; - updateProviderSession( - context, - { - status: "running", - activeTurnId: turnId, - model: modelSelection?.model ?? context.session.model, - }, - { clearLastError: true }, - ); + const context: OpenCodeSessionContext = { + session, + client: started.client, + server: started.server, + directory, + openCodeSessionId: started.openCodeSession.id, + pendingPermissions: new Map(), + pendingQuestions: new Map(), + partById: new Map(), + emittedTextByPartId: new Map(), + messageRoleById: new Map(), + completedAssistantPartIds: new Set(), + turns: [], + activeTurnId: undefined, + activeAgent: undefined, + activeVariant: undefined, + stopped: yield* Ref.make(false), + sessionScope: started.sessionScope, + }; + sessions.set(input.threadId, context); + yield* startEventPump(context); yield* emit({ - ...buildEventBase({ threadId: input.threadId, turnId }), - type: "turn.started", + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), + message: "OpenCode session started", + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { + providerThreadId: started.openCodeSession.id, }, }); - yield* runOpenCodeSdk("session.promptAsync", () => - context.client.session.promptAsync({ - sessionID: context.openCodeSessionId, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + return session; + }, + ); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = ensureSessionContext(sessions, input.threadId); + const turnId = TurnId.make(`opencode-turn-${yield* Random.nextUUIDv4}`); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { instanceId: boundInstanceId, model: context.session.model } + : undefined); + if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, + }); + } + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, }), - ).pipe( - Effect.mapError(toRequestError), - // On failure: clear active-turn state, flip the session back to ready - // with lastError set, emit turn.aborted, then let the typed error - // propagate. We don't need to rebuild the error here — `toRequestError` - // already produced the right shape. - Effect.tapError((requestError) => - Effect.gen(function* () { - context.activeTurnId = undefined; - context.activeAgent = undefined; - context.activeVariant = undefined; - updateProviderSession( - context, - { - status: "ready", - model: modelSelection?.model ?? context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...buildEventBase({ threadId: input.threadId, turnId }), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, - }); - }), - ), - ); + }); + if ((!text || text.length === 0) && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode turns require text input or at least one attachment.", + }); + } + + const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); - return { - threadId: input.threadId, - turnId, - }; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, }); - const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, turnId) { - const context = ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + yield* runOpenCodeSdk("session.promptAsync", () => + context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }), + ).pipe( + Effect.mapError(toRequestError), + // On failure: clear active-turn state, flip the session back to ready + // with lastError set, emit turn.aborted, then let the typed error + // propagate. We don't need to rebuild the error here — `toRequestError` + // already produced the right shape. + Effect.tapError((requestError) => + Effect.gen(function* () { + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); yield* emit({ - ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), + ...(yield* buildEventBase({ + threadId: input.threadId, + turnId, + })), type: "turn.aborted", payload: { - reason: "Interrupted by user.", + reason: requestError.detail, }, }); - } - }, + }), + ), ); - const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( - "respondToRequest", - )(function* (threadId, requestId, decision) { - const context = ensureSessionContext(sessions, threadId); - if (!context.pendingPermissions.has(requestId)) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "permission.reply", - detail: `Unknown pending permission request: ${requestId}`, - }); - } - - yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), - }), - ).pipe(Effect.mapError(toRequestError)); - }); + return { + threadId: input.threadId, + turnId, + }; + }); - const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( - "respondToUserInput", - )(function* (threadId, requestId, answers) { + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { const context = ensureSessionContext(sessions, threadId); - const request = context.pendingQuestions.get(requestId); - if (!request) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "question.reply", - detail: `Unknown pending user-input request: ${requestId}`, - }); - } - - yield* runOpenCodeSdk("question.reply", () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), - }), + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.mapError(toRequestError)); - }); - - const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( - function* (threadId) { - const context = ensureSessionContext(sessions, threadId); - yield* stopOpenCodeContext(context); - sessions.delete(threadId); + if (turnId ?? context.activeTurnId) { yield* emit({ - ...buildEventBase({ threadId }), - type: "session.exited", + ...(yield* buildEventBase({ + threadId, + turnId: turnId ?? context.activeTurnId, + })), + type: "turn.aborted", payload: { - reason: "Session stopped.", - recoverable: false, - exitKind: "graceful", + reason: "Interrupted by user.", }, }); - }, - ); + } + }, + ); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = ensureSessionContext(sessions, threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + ).pipe(Effect.mapError(toRequestError)); + }); + + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = ensureSessionContext(sessions, threadId); + const request = context.pendingQuestions.get(requestId); + if (!request) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } - const listSessions: OpenCodeAdapterShape["listSessions"] = () => - Effect.sync(() => [...sessions.values()].map((context) => context.session)); + yield* runOpenCodeSdk("question.reply", () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + ).pipe(Effect.mapError(toRequestError)); + }); + + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + const context = sessions.get(threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + const stopped = yield* stopOpenCodeContext(context); + sessions.delete(threadId); + if (!stopped) { + return; + } + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); - const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => sessions.has(threadId)); + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); - const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( - function* (threadId) { - const context = ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); - const turns = (messages.data ?? []) - .filter((entry) => entry.info.role === "assistant") - .map((entry) => ({ - id: TurnId.make(entry.info.id), - items: [entry.info, ...entry.parts], - })); + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ + sessionID: context.openCodeSessionId, + }), + ).pipe(Effect.mapError(toRequestError)); - return { - threadId, - turns, - }; - }, - ); + const turns = (messages.data ?? []) + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })); - const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( - function* (threadId, numTurns) { - const context = ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); + return { + threadId, + turns, + }; + }, + ); - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ + sessionID: context.openCodeSessionId, + }), + ).pipe(Effect.mapError(toRequestError)); - return yield* readThread(threadId); - }, - ); + const assistantMessages = (messages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const targetIndex = assistantMessages.length - numTurns - 1; + const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + ...(target ? { messageID: target.info.id } : {}), + }), + ).pipe(Effect.mapError(toRequestError)); - const stopAll: OpenCodeAdapterShape["stopAll"] = () => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - // `stopOpenCodeContext` is typed as never-failing — SDK aborts are - // already `Effect.ignore`'d inside it. `ignoreCause` here also - // swallows defects from throwing finalizers so one bad close can't - // interrupt the sibling fibers. Same pattern as the layer finalizer. - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); - }); + return yield* readThread(threadId); + }, + ); + + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + // `stopOpenCodeContext` is typed as never-failing — SDK aborts are + // already `Effect.ignore`'d inside it. `ignoreCause` here also + // swallows defects from throwing finalizers so one bad close can't + // interrupt the sibling fibers. Same pattern as the layer finalizer. + yield* Effect.forEach( + contexts, + (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + { concurrency: "unbounded", discard: true }, + ); + }); - return { - provider: PROVIDER, - capabilities: { - sessionModelSwitch: "in-session", - }, - startSession, - sendTurn, - interruptTurn, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - readThread, - rollbackThread, - stopAll, - get streamEvents() { - return Stream.fromQueue(runtimeEvents); - }, - } satisfies OpenCodeAdapterShape; - }), - ); + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCodeAdapterShape; + }); } - -export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 75622281c59..7abe0be9816 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -2,22 +2,30 @@ import assert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Schema } from "effect"; import { beforeEach } from "vitest"; +import { OpenCodeSettings } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { OpenCodeRuntime, OpenCodeRuntimeError, type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; -import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; +import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; +/** + * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag + * are deleted. The snapshot-producing logic they wrapped now lives in the + * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which + * drivers call directly when building their per-instance snapshot + * `ServerProviderShape`. Tests mirror that shape: build a settings payload, + * invoke the check, assert on the returned snapshot. + */ + const runtimeMock = { state: { runVersionError: null as Error | null, @@ -90,20 +98,26 @@ beforeEach(() => { runtimeMock.reset(); }); -const makeTestLayer = (settingsOverrides?: Parameters[0]) => - OpenCodeProviderLive.pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), - Layer.provideMerge(NodeServices.layer), - ); - -it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { +const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); + +const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => + Schema.decodeSync(OpenCodeSettings)({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, + }); + +it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, false); @@ -114,8 +128,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, true); @@ -123,33 +136,6 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { }), ); - it.effect("refuses to probe when opencode is older than the required minimum", () => - Effect.gen(function* () { - runtimeMock.state.versionStdout = "opencode 1.4.7\n"; - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; - - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal(snapshot.version, "1.4.7"); - assert.ok(snapshot.message?.includes("1.14.19")); - assert.ok(snapshot.message?.toLowerCase().includes("upgrade")); - }), - ); - - it.effect("refuses to probe when opencode --version output is unparseable", () => - Effect.gen(function* () { - runtimeMock.state.versionStdout = "garbled binary output\n"; - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; - - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal(snapshot.version, null); - assert.ok(snapshot.message?.includes("1.14.19")); - }), - ); - it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { @@ -182,8 +168,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { ], }; - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); assert.ok(model); @@ -191,40 +176,41 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { (descriptor) => descriptor.id === "variant" && descriptor.type === "select", ); assert.ok(variantDescriptor && variantDescriptor.type === "select"); - assert.equal(variantDescriptor.options.find((option) => option.isDefault)?.id, "medium"); + assert.equal( + variantDescriptor.options.find((option) => option.isDefault === true)?.id, + "medium", + ); const agentDescriptor = model.capabilities?.optionDescriptors?.find( (descriptor) => descriptor.id === "agent" && descriptor.type === "select", ); assert.ok(agentDescriptor && agentDescriptor.type === "select"); - assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); + assert.equal( + agentDescriptor.options.find((option) => option.isDefault === true)?.id, + "build", + ); }), ); it.effect("closes the local OpenCode server scope after provider refresh", () => Effect.gen(function* () { - const provider = yield* OpenCodeProvider; - yield* provider.refresh; + yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); assert.equal(runtimeMock.state.closeCalls, 1); }), ); }); -it.layer( - makeTestLayer({ - providers: { - opencode: { - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }, - }, - }), -)("OpenCodeProviderLive with configured server URL", (it) => { +it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { it.effect("surfaces a friendly auth error for configured servers", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("401 Unauthorized"); - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + process.cwd(), + ); assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, true); @@ -240,8 +226,13 @@ it.layer( runtimeMock.state.inventoryError = new Error( "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", ); - const provider = yield* OpenCodeProvider; - const snapshot = yield* provider.refresh; + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + process.cwd(), + ); assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, true); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index b94840015ee..c7487d7d526 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,24 +1,21 @@ -import type { - ModelCapabilities, - OpenCodeSettings, - ServerProvider, - ServerProviderModel, +import { + ProviderDriverKind, + type ModelCapabilities, + type OpenCodeSettings, + type ServerProviderModel, } from "@t3tools/contracts"; -import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; +import { Cause, Data, Effect } from "effect"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { buildServerProvider, nonEmptyTrimmed, parseGenericCliVersion, providerModelsFromSettings, + type ServerProviderDraft, } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; -import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { OpenCodeRuntime, openCodeRuntimeErrorDetail, @@ -26,7 +23,7 @@ import { } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; -const PROVIDER = "opencode" as const; +const PROVIDER = ProviderDriverKind.make("opencode"); const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, @@ -251,7 +248,9 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray left.name.localeCompare(right.name)); } -const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { +export const makePendingOpenCodeProvider = ( + openCodeSettings: OpenCodeSettings, +): ServerProviderDraft => { const checkedAt = new Date().toISOString(); const models = providerModelsFromSettings( [], @@ -262,7 +261,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server if (!openCodeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, @@ -281,7 +279,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server } return buildServerProvider({ - provider: PROVIDER, presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, @@ -296,210 +293,179 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server }); }; -export const OpenCodeProviderLive = Layer.effect( - OpenCodeProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; - - const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (input: { - readonly settings: OpenCodeSettings; - readonly cwd: string; - }): Effect.fn.Return { - const checkedAt = new Date().toISOString(); - const customModels = input.settings.customModels; - const isExternalServer = input.settings.serverUrl.trim().length > 0; - - const fallback = (cause: unknown, version: string | null = null) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - serverUrl: input.settings.serverUrl, - }); - return buildServerProvider({ - provider: PROVIDER, - presentation: OPENCODE_PRESENTATION, - enabled: input.settings.enabled, - checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, - }); - }; - - if (!input.settings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - openCodeRuntime - .runOpenCodeCommand({ - binaryPath: input.settings.binaryPath, - args: ["--version"], - }) - .pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); - } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - - if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - null, - ); - } - if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { - return buildServerProvider({ - provider: PROVIDER, - presentation: OPENCODE_PRESENTATION, - enabled: input.settings.enabled, - checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, - }, - }); - } - } - - const inventoryExit = yield* Effect.exit( - Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: input.settings.binaryPath, - serverUrl: input.settings.serverUrl, - }) - .pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ); - return yield* openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(isExternalServer && input.settings.serverPassword - ? { serverPassword: input.settings.serverPassword } - : {}), - }), - ) - .pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ); - }), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version); - } +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const openCodeRuntime = yield* OpenCodeRuntime; + const checkedAt = new Date().toISOString(); + const customModels = openCodeSettings.customModels; + const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: openCodeSettings.serverUrl, + }); + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings( + [], PROVIDER, customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + openCodeRuntime + .runOpenCodeCommand({ + binaryPath: openCodeSettings.binaryPath, + args: ["--version"], + environment, + }) + .pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + + if (!version) { + return fallback( + new Error( + `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ), + null, ); - const connectedCount = inventoryExit.value.providerList.connected.length; + } + if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ - provider: PROVIDER, presentation: OPENCODE_PRESENTATION, - enabled: true, + enabled: openCodeSettings.enabled, checkedAt, - models, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), probe: { installed: true, version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", - }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, }, }); - }); + } + } - const getProviderSettings = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.opencode), - ); + const inventoryExit = yield* Effect.exit( + Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment, + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ); + return yield* openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(isExternalServer && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ); + }), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } - return yield* makeManagedServerProvider({ - getSettings: getProviderSettings.pipe(Effect.orDie), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.opencode), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: makePendingOpenCodeProvider, - checkProvider: getProviderSettings.pipe( - Effect.flatMap((settings) => - checkOpenCodeProviderStatus({ - settings, - cwd: serverConfig.cwd, - }), - ), - ), - }); - }), -); + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index e0a68f68b63..05735f3a50d 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,24 +1,30 @@ -import type { ProviderKind } from "@t3tools/contracts"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Layer, Stream } from "effect"; +import { Effect, Layer, PubSub, Stream } from "effect"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; -import { ProviderUnsupportedError } from "../Errors.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); + const fakeCodexAdapter: CodexAdapterShape = { - provider: "codex", + provider: CODEX_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), @@ -35,7 +41,7 @@ const fakeCodexAdapter: CodexAdapterShape = { }; const fakeClaudeAdapter: ClaudeAdapterShape = { - provider: "claudeAgent", + provider: CLAUDE_AGENT_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), @@ -52,7 +58,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { }; const fakeOpenCodeAdapter: OpenCodeAdapterShape = { - provider: "opencode", + provider: OPENCODE_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), @@ -69,7 +75,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { }; const fakeCursorAdapter: CursorAdapterShape = { - provider: "cursor", + provider: CURSOR_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), @@ -85,44 +91,94 @@ const fakeCursorAdapter: CursorAdapterShape = { streamEvents: Stream.empty, }; -const layer = it.layer( - Layer.mergeAll( - Layer.provide( - ProviderAdapterRegistryLive, - Layer.mergeAll( - Layer.succeed(CodexAdapter, fakeCodexAdapter), - Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), - Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), - Layer.succeed(CursorAdapter, fakeCursorAdapter), - ), - ), - NodeServices.layer, - ), +// ProviderAdapterRegistryLive is now a facade over ProviderInstanceRegistry — +// it walks `listInstances` once at boot and surfaces the default-instance +// adapter keyed by its driver kind. To test the facade we supply four fake +// instances whose `instanceId === defaultInstanceIdForDriver(driverKind)` so +// they pass the default-instance filter. +const makeFakeInstance = ( + driverKindString: "codex" | "claudeAgent" | "cursor" | "opencode", + adapter: ProviderInstance["adapter"], +): ProviderInstance => { + const driverKind = ProviderDriverKind.make(driverKindString); + return { + instanceId: defaultInstanceIdForDriver(driverKind), + driverKind, + continuationIdentity: { + driverKind, + continuationKey: `${driverKind}:instance:${defaultInstanceIdForDriver(driverKind)}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + getSnapshot: Effect.succeed({} as unknown as ServerProvider), + refresh: Effect.succeed({} as unknown as ServerProvider), + streamChanges: Stream.empty, + }, + adapter, + textGeneration: {} as unknown as TextGenerationShape, + }; +}; + +const fakeInstances: ReadonlyArray = [ + makeFakeInstance("codex", fakeCodexAdapter), + makeFakeInstance("claudeAgent", fakeClaudeAdapter), + makeFakeInstance("opencode", fakeOpenCodeAdapter), + makeFakeInstance("cursor", fakeCursorAdapter), +]; + +const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(fakeInstances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + // Tests never drive changes through this fake; acquire a throwaway + // subscription on an unused PubSub so the shape is satisfied. + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub)), +}); + +const layer = Layer.mergeAll( + Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer), + NodeServices.layer, ); -layer("ProviderAdapterRegistryLive", (it) => { - it.effect("resolves a registered provider adapter", () => +it.layer(layer)("ProviderAdapterRegistryLive", (it) => { + it("resolves adapters and routing metadata from provider instances", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; - const codex = yield* registry.getByProvider("codex"); - const claude = yield* registry.getByProvider("claudeAgent"); - const openCode = yield* registry.getByProvider("opencode"); - const cursor = yield* registry.getByProvider("cursor"); - assert.equal(codex, fakeCodexAdapter); - assert.equal(claude, fakeClaudeAdapter); - assert.equal(openCode, fakeOpenCodeAdapter); - assert.equal(cursor, fakeCursorAdapter); + const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER); - const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "opencode", "cursor"]); - }), - ); + const adapter = yield* registry.getByInstance(claudeInstanceId); + assert.strictEqual(adapter, fakeClaudeAdapter); - it.effect("fails with ProviderUnsupportedError for unknown providers", () => - Effect.gen(function* () { - const registry = yield* ProviderAdapterRegistry; - const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); - assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); - }), - ); + const info = yield* registry.getInstanceInfo(claudeInstanceId); + assert.deepStrictEqual(info, { + instanceId: claudeInstanceId, + driverKind: CLAUDE_AGENT_DRIVER, + displayName: undefined, + accentColor: undefined, + enabled: true, + continuationIdentity: { + driverKind: CLAUDE_AGENT_DRIVER, + continuationKey: "claudeAgent:instance:claudeAgent", + }, + }); + + const instances = yield* registry.listInstances(); + assert.deepStrictEqual(instances, [ + defaultInstanceIdForDriver(CODEX_DRIVER), + claudeInstanceId, + defaultInstanceIdForDriver(OPENCODE_DRIVER), + defaultInstanceIdForDriver(CURSOR_DRIVER), + ]); + + const providers = yield* registry.listProviders(); + assert.deepStrictEqual(providers, [ + CODEX_DRIVER, + CLAUDE_AGENT_DRIVER, + OPENCODE_DRIVER, + CURSOR_DRIVER, + ]); + })); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index dabe5b8581e..f2eeaa1aae8 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -1,59 +1,101 @@ /** - * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. + * ProviderAdapterRegistryLive — facade over `ProviderInstanceRegistry`. * - * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services. - * This layer only performs adapter lookup; it does not route session-scoped - * calls or own provider lifecycle workflows. + * `ProviderAdapterRegistry` historically mapped one `ProviderDriverKind` to one + * adapter via the four `AdapterLive` singleton Layers. The per-instance + * refactor moved adapter construction inside each `ProviderDriver.create()`: + * adapters are now bundled on the `ProviderInstance` that the + * `ProviderInstanceRegistry` owns. + * + * This facade fulfills the `ProviderAdapterRegistryShape` contract by doing + * dynamic look-ups against `ProviderInstanceRegistry` on every call. That + * means settings-driven hot-reload shows up here automatically — adding a + * new instance via settings makes `getByInstance` resolve immediately + * without rebuilding the facade. * * @module ProviderAdapterRegistryLive */ +import { + defaultInstanceIdForDriver, + ProviderInstanceId, + type ProviderDriverKind, +} from "@t3tools/contracts"; import { Effect, Layer } from "effect"; -import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { ProviderUnsupportedError } from "../Errors.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; -import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts"; - -export interface ProviderAdapterRegistryLiveOptions { - readonly adapters?: ReadonlyArray>; -} - -const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* ( - options?: ProviderAdapterRegistryLiveOptions, -) { - const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter); - const adapters = - options?.adapters !== undefined - ? options.adapters - : createBuiltInAdapterList({ - codex: yield* CodexAdapter, - claudeAgent: yield* ClaudeAdapter, - opencode: yield* OpenCodeAdapter, - ...(cursorAdapterOption._tag === "Some" ? { cursor: cursorAdapterOption.value } : {}), - }); - const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); - - const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { - const adapter = byProvider.get(provider); - if (!adapter) { - return Effect.fail(new ProviderUnsupportedError({ provider })); - } - return Effect.succeed(adapter); - }; + +const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { + const registry = yield* ProviderInstanceRegistry; + + const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance === undefined + ? Effect.fail( + new ProviderUnsupportedError({ + provider: instanceId, + }), + ) + : Effect.succeed(instance.adapter), + ), + ); + + const getInstanceInfo: ProviderAdapterRegistryShape["getInstanceInfo"] = (instanceId) => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance === undefined + ? Effect.fail( + new ProviderUnsupportedError({ + provider: instanceId, + }), + ) + : Effect.succeed({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + displayName: instance.displayName, + accentColor: instance.accentColor, + enabled: instance.enabled, + continuationIdentity: instance.continuationIdentity, + }), + ), + ); + + const listInstances: ProviderAdapterRegistryShape["listInstances"] = () => + registry.listInstances.pipe( + Effect.map((instances) => instances.map((instance) => instance.instanceId)), + ); const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => - Effect.sync(() => Array.from(byProvider.keys())); + registry.listInstances.pipe( + Effect.map((instances) => { + const kinds = new Set(); + for (const instance of instances) { + const defaultId = defaultInstanceIdForDriver(instance.driverKind); + if (instance.instanceId === defaultId) { + // Only the default-instance rows show up through the legacy + // shim — custom instances like `codex_personal` have no + // `ProviderDriverKind` equivalent. + kinds.add(instance.driverKind); + } + } + return Array.from(kinds); + }), + ); return { - getByProvider, + getByInstance, + getInstanceInfo, + listInstances, listProviders, + // Proxy directly — the facade has no state of its own; the instance + // registry already coalesces adds/removes/rebuilds into one emission. + streamChanges: registry.streamChanges, + subscribeChanges: registry.subscribeChanges, } satisfies ProviderAdapterRegistryShape; }); @@ -61,3 +103,14 @@ export const ProviderAdapterRegistryLive = Layer.effect( ProviderAdapterRegistry, makeProviderAdapterRegistry(), ); + +// Exposed for tests that want to build a facade over a pre-assembled +// `ProviderInstanceRegistry` without pulling in the whole boot graph. +export { makeProviderAdapterRegistry }; + +// Re-export for consumers that need the accessor shape. The service tag +// itself lives in `Services/ProviderAdapterRegistry.ts`. +export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +// Re-export for consumers (including tests) that construct a +// `ProviderInstanceId` before calling `getByInstance`. +export { ProviderInstanceId }; diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts new file mode 100644 index 00000000000..4a15fdd6852 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderEventLoggers.ts @@ -0,0 +1,83 @@ +/** + * ProviderEventLoggers — single observability service that owns the two + * shared NDJSON streams the provider runtime writes: + * + * - `native` — provider-protocol events as the SDK emits them, written + * from inside each `Adapter` factory. + * - `canonical` — runtime events after `ProviderService` has normalized + * them onto `ProviderRuntimeEvent`. + * + * Why a service tag and not constructor options? + * + * - Adapters are now constructed *inside* drivers (`Driver.create()`), + * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` + * call site where we can hand an `EventNdjsonLogger` in by hand. + * - Multiple driver instances per kind (`codex_personal`, `codex_work`) + * should share one underlying log writer per stream — opening N writers + * against the same rotating file would race the rotation logic. Owning + * the loggers on a single tag keeps that invariant intact. + * - Tests can swap one (or both) loggers with in-memory recorders by + * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of + * juggling per-Layer option threading. + * + * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when + * the target directory cannot be created; we forward that as `undefined` + * rather than failing the boot Layer, matching the previous best-effort + * behavior of `server.ts`. + * + * @module provider/Layers/ProviderEventLoggers + */ +import { Context, Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +export interface ProviderEventLoggersShape { + readonly native: EventNdjsonLogger | undefined; + readonly canonical: EventNdjsonLogger | undefined; +} + +/** + * Shared logger pair for native + canonical provider event streams. + * + * Service value is intentionally a struct of two optional loggers rather + * than two parallel tags. Construction site is one place + * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read + * one tag and pluck the field they need. + */ +export class ProviderEventLoggers extends Context.Service< + ProviderEventLoggers, + ProviderEventLoggersShape +>()("t3/provider/ProviderEventLoggers") {} + +/** + * Constant value used by tests / boot layers that want to opt out of native + * + canonical logging entirely. Keeps the tag non-optional in the type + * system while letting the runtime treat absence as a no-op. + */ +export const NoOpProviderEventLoggers: ProviderEventLoggersShape = { + native: undefined, + canonical: undefined, +}; + +/** + * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. + * If the directory create fails for either stream, the corresponding field + * is `undefined` and writes from that stream become no-ops downstream. + */ +export const ProviderEventLoggersLive = Layer.effect( + ProviderEventLoggers, + Effect.gen(function* () { + const { providerEventLogPath } = yield* ServerConfig; + const native = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "native", + }); + const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "canonical", + }); + return { + native, + canonical, + } satisfies ProviderEventLoggersShape; + }), +); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts new file mode 100644 index 00000000000..b6c44306f93 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -0,0 +1,176 @@ +/** + * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` + * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. + * + * The server still reads two shapes: + * + * 1. `settings.providerInstances` — the new driver-agnostic map the + * registry expects. Keyed by `ProviderInstanceId`, values are + * `ProviderInstanceConfig` envelopes. + * 2. `settings.providers.` — the legacy single-instance-per-driver + * fields (`providers.codex`, `providers.claudeAgent`, …). These are + * the source of truth for every deployment that hasn't been migrated + * yet to an explicit `providerInstances` entry. + * + * This module bridges (2) into (1) and wires the resulting map into a + * mutable registry. For every built-in driver whose id is not already + * present in `providerInstances` (keyed on + * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a + * routing slug), we synthesize an envelope from the legacy field. The + * registry decodes both flavours through the same `configSchema` and ends + * up with one uniform `ProviderInstance` per entry. + * + * Explicit `providerInstances` entries always win — users can already + * override the legacy `providers.` blob by authoring a + * `providerInstances.codex` entry with a matching driver, and we don't + * want the synthesized envelope to silently stomp their config. + * + * Hot-reload + * ---------- + * On layer build we: + * 1. Read the current `ServerSettings` once and use it to seed the + * registry's initial state via `ProviderInstanceRegistryMutableLayer`. + * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that + * subscribes to `ServerSettingsService.streamChanges` and calls + * `ProviderInstanceRegistryMutator.reconcile` on every emission. + * + * Failures inside the watcher are logged and swallowed so a single bad + * settings emission cannot kill the registry. Unknown drivers and invalid + * configs already round-trip through the registry's own "unavailable" + * shadow bucket. + * + * @module provider/Layers/ProviderInstanceRegistryHydration + */ +import { + defaultInstanceIdForDriver, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + ServerSettings, +} from "@t3tools/contracts"; +import { Effect, Layer, Stream } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts"; +import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistryLive.ts"; + +/** + * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. + * + * Strategy: + * 1. Copy all explicit `settings.providerInstances` entries verbatim. + * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key + * is *not* already in the explicit map, synthesize an entry from the + * matching legacy `settings.providers.` blob. + * + * The returned map is the input the registry consumes; pure & exported + * separately so the hydration logic can be exercised by unit tests + * without layering. + */ +export const deriveProviderInstanceConfigMap = ( + settings: ServerSettings, +): ProviderInstanceConfigMap => { + const merged: Record = { ...settings.providerInstances }; + + for (const driver of BUILT_IN_DRIVERS) { + const instanceId = defaultInstanceIdForDriver(driver.driverKind); + if (instanceId in merged) { + // Explicit `providerInstances` entry for this slot — user-authored + // config always wins over the legacy mirror. + continue; + } + + // Only built-in drivers have a legacy mirror; the registry's + // `providers` struct is keyed on the same literal slug as + // `driverKind`. Access is dynamic (the driver kind is a branded string), + // but it's constrained to `keyof settings.providers` by the union of + // built-in driver kinds. + const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; + const legacyConfig = settings.providers[legacyKey]; + if (legacyConfig === undefined) { + continue; + } + + merged[instanceId] = { + driver: driver.driverKind, + config: legacyConfig, + }; + } + + return merged as ProviderInstanceConfigMap; +}; + +/** + * Layer that consumes `ProviderInstanceRegistryMutator` and forks a + * settings-watcher fiber. The fiber's lifetime is tied to the enclosing + * layer scope (process lifetime in production), so it is interrupted on + * shutdown without leaking. + * + * Errors inside the watcher are logged and swallowed — the registry's own + * "unavailable" bucket already absorbs unknown drivers and invalid + * configs, so the only way the watcher could fail is a settings stream + * tear-down, which logs and exits cleanly. + */ +const SettingsWatcherLive: Layer.Layer< + never, + never, + ProviderInstanceRegistryMutator | ServerSettingsService +> = Layer.effectDiscard( + Effect.gen(function* () { + const mutator = yield* ProviderInstanceRegistryMutator; + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.streamChanges.pipe( + Stream.runForEach((next) => + mutator + .reconcile(deriveProviderInstanceConfigMap(next)) + .pipe( + Effect.catchCause((cause) => + Effect.logError("ProviderInstanceRegistry reconcile failed", cause), + ), + ), + ), + Effect.forkScoped, + ); + }), +); + +/** + * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in + * sync with subsequent `streamChanges` emissions. + * + * The Layer's two halves: + * - `ProviderInstanceRegistryMutableLayer` produces the registry + + * mutator from the initial config map. Its scope owns every + * per-instance child scope created during reconcile. + * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber + * in the same scope. + * + * Composing via `Layer.provideMerge` makes the watcher's deps available + * from the mutable layer while still surfacing the registry as an output. + * The mutator tag is technically also exposed; only this module imports + * it, so the visibility leak is harmless in practice. + */ +export const ProviderInstanceRegistryHydrationLive: Layer.Layer< + ProviderInstanceRegistry, + never, + BuiltInDriversEnv | ServerSettingsService +> = Layer.unwrap( + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( + Effect.orElseSucceed(() => undefined), + ); + const initialConfigMap = + initialSettings === undefined + ? ({} as ProviderInstanceConfigMap) + : deriveProviderInstanceConfigMap(initialSettings); + + const mutableLayer = ProviderInstanceRegistryMutableLayer({ + drivers: BUILT_IN_DRIVERS, + configMap: initialConfigMap, + }); + + return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); + }), +) as Layer.Layer; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts new file mode 100644 index 00000000000..2246a2ae478 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -0,0 +1,357 @@ +/** + * Multi-instance validation slices for `ProviderInstanceRegistryLive`. + * + * Two axes of the driver/registry refactor are exercised here: + * + * 1. **Same driver, many instances** — the "multi-instance codex slice" + * describe block below configures two independent `codex` instances and + * asserts each gets its own closures and identity. This is the + * multi-codex capability the refactor exists to unlock. + * + * 2. **Many drivers, one registry** — the "all drivers slice" describe + * block below configures one instance of every shipped driver + * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single + * `ProviderInstanceConfigMap` and asserts the registry boots them all + * without cross-contamination. This proves the driver SPI is uniform + * across every provider — any driver plugs into the registry through + * the same `ProviderDriver` value contract. + * + * Every instance in these tests is configured with `enabled: false` so the + * provider-status checks short-circuit to pending/disabled snapshots + * without trying to spawn real `codex` / `claude` / `agent` / `opencode` + * binaries. That keeps the assertions focused on registry routing + * behaviour rather than the runtime details of each provider. + */ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type ClaudeSettings, + type CodexSettings, + type CursorSettings, + type OpenCodeSettings, + ProviderDriverKind, + type ProviderInstanceConfigMap, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import { CodexDriver } from "../Drivers/CodexDriver.ts"; +import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; + +const makeCodexConfig = (overrides: Partial): CodexSettings => ({ + enabled: false, + binaryPath: "codex", + homePath: "", + shadowHomePath: "", + customModels: [], + ...overrides, +}); + +const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ({ + enabled: false, + binaryPath: "claude", + homePath: "", + customModels: [], + launchArgs: "", + ...overrides, +}); + +const makeCursorConfig = (overrides: Partial): CursorSettings => ({ + enabled: false, + binaryPath: "agent", + apiEndpoint: "", + customModels: [], + ...overrides, +}); + +const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ + enabled: false, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, +}); + +describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { + // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch + // directory. `Layer.merge` just unions requirements, so we have to push + // `NodeServices.layer` through `Layer.provideMerge` to satisfy that + // dependency while still surfacing NodeServices to the test body (the + // codex driver's `create` yields `ChildProcessSpawner` directly). + const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-test", + }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => + Effect.gen(function* () { + const personalId = ProviderInstanceId.make("codex_personal"); + const workId = ProviderInstanceId.make("codex_work"); + const codexDriverKind = ProviderDriverKind.make("codex"); + + const configMap: ProviderInstanceConfigMap = { + [personalId]: { + driver: codexDriverKind, + displayName: "Codex (personal)", + enabled: false, + config: makeCodexConfig({ + binaryPath: "/opt/codex-personal/bin/codex", + homePath: "/home/julius/.codex_personal", + customModels: ["personal-preview"], + }), + }, + [workId]: { + driver: codexDriverKind, + displayName: "Codex (work)", + enabled: false, + config: makeCodexConfig({ + binaryPath: "/opt/codex-work/bin/codex", + homePath: "/home/julius/.codex", + customModels: ["work-preview"], + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instances = yield* registry.listInstances; + expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( + [personalId, workId].toSorted(), + ); + expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); + expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( + ["Codex (personal)", "Codex (work)"].toSorted(), + ); + + // Each instance must be retrievable by id and carry its *own* closures. + const personal = yield* registry.getInstance(personalId); + const work = yield* registry.getInstance(workId); + expect(personal).toBeDefined(); + expect(work).toBeDefined(); + expect(personal!.adapter).not.toBe(work!.adapter); + expect(personal!.textGeneration).not.toBe(work!.textGeneration); + expect(personal!.snapshot).not.toBe(work!.snapshot); + + // Snapshots identify themselves by instanceId + driver — this is + // what makes per-instance routing distinguishable downstream. + const personalSnapshot = yield* personal!.snapshot.getSnapshot; + expect(personalSnapshot.instanceId).toBe(personalId); + expect(personalSnapshot.driver).toBe(codexDriverKind); + expect(personalSnapshot.enabled).toBe(false); + expect(personalSnapshot.continuation?.groupKey).toBe( + "codex:home:/home/julius/.codex_personal", + ); + + const workSnapshot = yield* work!.snapshot.getSnapshot; + expect(workSnapshot.instanceId).toBe(workId); + expect(workSnapshot.driver).toBe(codexDriverKind); + expect(workSnapshot.enabled).toBe(false); + expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + + // Nothing goes to the unavailable bucket — both drivers are registered. + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.live( + "shadows instances whose driver is not registered in this build without failing boot", + () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_main"); + const ghostId = ProviderInstanceId.make("ghost_main"); + + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: makeCodexConfig({}), + }, + [ghostId]: { + driver: ProviderDriverKind.make("ghostDriver"), + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload", preserved: true }, + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instances = yield* registry.listInstances; + expect(instances).toHaveLength(1); + expect(instances[0]!.instanceId).toBe(codexId); + + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toHaveLength(1); + const ghost = unavailable[0]!; + expect(ghost.instanceId).toBe(ghostId); + expect(ghost.driver).toBe("ghostDriver"); + expect(ghost.availability).toBe("unavailable"); + expect(ghost.unavailableReason).toMatch(/ghostDriver/); + }).pipe(Effect.provide(testLayer)), + ); +}); + +describe("ProviderInstanceRegistryLive — all drivers slice", () => { + // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` + // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. + // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via + // `Layer.provide`, so the only external requirement it still exposes is + // `ChildProcessSpawner` — resolved here by piping it through + // `provideMerge(NodeServices.layer)`. + // + // The nested `provideMerge`s read bottom-up: `NodeServices.layer` + // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs + // surfaced; that merged layer then provides `ServerConfig.layerTest`'s + // `FileSystem` dep while keeping everything else surfaced to the test. + const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-all-drivers-test", + }).pipe( + Layer.provideMerge(infraLayer), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + it.live("boots one instance of every shipped driver from a single config map", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_default"); + const claudeId = ProviderInstanceId.make("claude_default"); + const cursorId = ProviderInstanceId.make("cursor_default"); + const openCodeId = ProviderInstanceId.make("opencode_default"); + + const codexDriverKind = ProviderDriverKind.make("codex"); + const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); + const cursorDriverKind = ProviderDriverKind.make("cursor"); + const openCodeDriverKind = ProviderDriverKind.make("opencode"); + + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: codexDriverKind, + displayName: "Codex", + enabled: false, + config: makeCodexConfig({ homePath: "/home/julius/.codex" }), + }, + [claudeId]: { + driver: claudeDriverKind, + displayName: "Claude", + enabled: false, + config: makeClaudeConfig({ + homePath: "/home/julius/.claude-work", + launchArgs: "--verbose", + }), + }, + [cursorId]: { + driver: cursorDriverKind, + displayName: "Cursor", + enabled: false, + config: makeCursorConfig({}), + }, + [openCodeId]: { + driver: openCodeDriverKind, + displayName: "OpenCode", + enabled: false, + config: makeOpenCodeConfig({}), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], + configMap, + }); + + // Every configured instance must materialize — none downgraded to a + // shadow snapshot, because every driver in the map is registered. + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toEqual([]); + + const instances = yield* registry.listInstances; + expect(instances).toHaveLength(4); + expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( + [codexId, claudeId, cursorId, openCodeId].toSorted(), + ); + + // Instance lookup by id resolves each instance to its own bundle — + // this is how rest-of-server routes turn/session calls in the new + // model. Each driver's bundle carries its advertised `driverKind`. + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + const cursor = yield* registry.getInstance(cursorId); + const openCode = yield* registry.getInstance(openCodeId); + expect(codex?.driverKind).toBe(codexDriverKind); + expect(claude?.driverKind).toBe(claudeDriverKind); + expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(codex?.displayName).toBe("Codex"); + expect(claude?.displayName).toBe("Claude"); + expect(cursor?.displayName).toBe("Cursor"); + expect(openCode?.displayName).toBe("OpenCode"); + + // Every instance owns its own set of closures — no sharing across + // drivers. `adapter` / `textGeneration` / `snapshot` are all + // distinct references even when two instances happen to share a + // trait (e.g. Cursor + others all use a stub-or-real + // `textGeneration`; they must still be different object values). + const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; + expect(new Set(adapters).size).toBe(adapters.length); + const textGenerations = [ + codex!.textGeneration, + claude!.textGeneration, + cursor!.textGeneration, + openCode!.textGeneration, + ]; + expect(new Set(textGenerations).size).toBe(textGenerations.length); + const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; + expect(new Set(snapshots).size).toBe(snapshots.length); + + // Snapshots identify themselves by `instanceId` + `driver` so + // downstream aggregation in `ProviderRegistry` can tell instances + // apart even when two share a driver. With `enabled: false`, the + // check short-circuits and we get a disabled/pending snapshot back + // — that's enough signal to validate the stamping wrapper without + // spawning real binaries. + const codexSnapshot = yield* codex!.snapshot.getSnapshot; + expect(codexSnapshot.instanceId).toBe(codexId); + expect(codexSnapshot.driver).toBe(codexDriverKind); + expect(codexSnapshot.enabled).toBe(false); + expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + + const claudeSnapshot = yield* claude!.snapshot.getSnapshot; + expect(claudeSnapshot.instanceId).toBe(claudeId); + expect(claudeSnapshot.driver).toBe(claudeDriverKind); + expect(claudeSnapshot.enabled).toBe(false); + expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + + const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; + expect(cursorSnapshot.instanceId).toBe(cursorId); + expect(cursorSnapshot.driver).toBe(cursorDriverKind); + expect(cursorSnapshot.enabled).toBe(false); + expect(cursorSnapshot.continuation?.groupKey).toBe( + `${cursorDriverKind}:instance:${cursorId}`, + ); + + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; + expect(openCodeSnapshot.instanceId).toBe(openCodeId); + expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); + expect(openCodeSnapshot.enabled).toBe(false); + expect(openCodeSnapshot.continuation?.groupKey).toBe( + `${openCodeDriverKind}:instance:${openCodeId}`, + ); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts new file mode 100644 index 00000000000..63f687f55b6 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -0,0 +1,434 @@ +/** + * ProviderInstanceRegistryLive — runtime implementation of + * `ProviderInstanceRegistry` plus its sibling mutator. + * + * Materializes every entry in a `ProviderInstanceConfigMap`: + * + * - When the entry's `driver` matches a registered driver, the registry + * decodes the opaque `config` envelope through `driver.configSchema` + * and calls `driver.create()` inside a fresh child scope. The + * resulting `ProviderInstance` is stored keyed by instance id, + * alongside its scope so the entry can be torn down independently. + * - When the entry's `driver` is unknown to this build (fork, rollback, + * in-flight PR branch), the registry emits an `"unavailable"` shadow + * `ServerProvider` snapshot instead of failing. This is what makes + * downgrades and fork-hopping safe per the + * `forward/backward compatibility invariant` in + * `packages/contracts/src/providerInstance.ts`. + * - When the entry's config fails schema decode, the registry logs and + * emits a shadow snapshot with the schema detail — same bucket as an + * unknown driver. + * + * Unlike the pre-Slice-D layer, the registry now holds mutable state + * (`Ref`s + `PubSub`) and exposes an internal mutator + * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a + * fresh config map against the live state, tearing down removed instances + * and building new ones without disturbing unaffected instances. + * + * Every live instance runs inside its own child `Scope`. The registry's + * own scope owns all child scopes via finalizers, so closing the registry + * tears every instance down in reverse order; closing a single instance + * (via `reconcile` removing it) leaves the rest untouched. + * + * @module provider/Layers/ProviderInstanceRegistryLive + */ +import { + defaultInstanceIdForDriver, + ProviderInstanceId, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + type ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; +import { Context, Effect, Equal, Exit, Layer, PubSub, Ref, Schema, Scope, Stream } from "effect"; + +import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "../Services/ProviderInstanceRegistry.ts"; +import { + ProviderInstanceRegistryMutator, + type ProviderInstanceRegistryMutatorShape, +} from "../Services/ProviderInstanceRegistryMutator.ts"; +import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; + +/** + * Live registry entry: the materialized `ProviderInstance` + the fresh + * child scope its `create` effect ran in + the original `entry` envelope + * so `reconcile` can cheaply detect "no-op" updates. + */ +interface LiveEntry { + readonly instance: ProviderInstance; + readonly scope: Scope.Closeable; + readonly entry: ProviderInstanceConfig; +} + +/** + * Internal state shared between the public registry service and the + * mutator service. Both services are thin shells around these refs. + */ +interface RegistryState { + readonly entries: Ref.Ref>; + readonly unavailable: Ref.Ref>; + readonly changes: PubSub.PubSub; +} + +/** + * Structural equality on `ProviderInstanceConfig` envelopes. Used by + * `reconcile` to skip rebuilds when settings arrive unchanged. Config + * payloads are opaque `unknown` at the envelope layer; `Equal.equals` + * falls back to structural equality for plain records, which matches how + * the schema decode output is constructed. + */ +const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => + Equal.equals(a, b); + +const decodedConfigEnabled = (config: unknown): boolean | undefined => { + if (!config || typeof config !== "object" || globalThis.Array.isArray(config)) { + return undefined; + } + const enabled = (config as { readonly enabled?: unknown }).enabled; + return typeof enabled === "boolean" ? enabled : undefined; +}; + +/** + * Build one live entry from a raw config envelope. Returns either a + * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and + * undefined entry — callers dispatch to the appropriate Ref bucket. + */ +const buildEntry = (input: { + readonly driversById: ReadonlyMap>; + readonly parentScope: Scope.Scope; + readonly instanceId: ProviderInstanceId; + readonly rawInstanceId: string; + readonly entry: ProviderInstanceConfig; +}): Effect.Effect< + | { readonly kind: "live"; readonly live: LiveEntry } + | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, + never, + R +> => + Effect.gen(function* () { + const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const driver = driversById.get(entry.driver); + if (!driver) { + return { + kind: "unavailable" as const, + snapshot: buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' is not registered in this build.`, + }), + }; + } + + const decoder = Schema.decodeUnknownEffect(driver.configSchema); + const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); + if (decodeResult._tag === "Failure") { + const issue = decodeResult.failure; + const detail = issue.message ?? String(issue); + yield* Effect.logError("Failed to decode provider instance config", { + instanceId: rawInstanceId, + driver: entry.driver, + detail, + }); + return { + kind: "unavailable" as const, + snapshot: buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, + }), + }; + } + + const typedConfig = decodeResult.success; + const childScope = yield* Scope.make(); + // Attach the child scope to the registry's parent scope: if the + // registry scope closes, each surviving instance's child scope is + // closed through this finalizer. `reconcile` manually closes the + // child scope on remove/replace; subsequent close via the parent's + // finalizer is a no-op because `Scope.close` is idempotent. + yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); + + const createResult = yield* driver + .create({ + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + environment: entry.environment ?? [], + enabled: entry.enabled ?? decodedConfigEnabled(typedConfig) ?? true, + config: typedConfig, + }) + .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); + if (createResult._tag === "Failure") { + yield* Effect.logError("Failed to create provider instance", { + instanceId: rawInstanceId, + driver: entry.driver, + detail: createResult.failure.detail, + }); + yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); + return { + kind: "unavailable" as const, + snapshot: buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + }), + }; + } + + return { + kind: "live" as const, + live: { + instance: createResult.success, + scope: childScope, + entry, + }, + }; + }); + +/** + * Reconcile-only implementation of the mutator. Exposed to the hydration + * layer; never called directly by the rest of the server. + */ +const makeReconcile = (input: { + readonly state: RegistryState; + readonly driversById: ReadonlyMap>; + readonly parentScope: Scope.Scope; +}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { + const { state, driversById, parentScope } = input; + return (configMap: ProviderInstanceConfigMap) => + Effect.gen(function* () { + const previousEntries = yield* Ref.get(state.entries); + const previousUnavailable = yield* Ref.get(state.unavailable); + const nextRaw = Object.entries(configMap); + const nextKeys = new Set( + nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), + ); + + // 1. Close scopes for instances that disappeared or whose config + // changed. Do this BEFORE creating replacements so ids map 1-to-1 + // to live scopes at all times. + const removedIds: Array = []; + const replacedIds = new Set(); + for (const [instanceId, live] of previousEntries) { + if (!nextKeys.has(instanceId)) { + removedIds.push(instanceId); + continue; + } + const nextEntry = configMap[instanceId]; + if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { + replacedIds.add(instanceId); + } + } + for (const id of [...removedIds, ...replacedIds]) { + const live = previousEntries.get(id); + if (live) { + yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); + } + } + + // 2. Build additions and replacements. Walk `nextRaw` so the final + // entry order follows settings-author order. + const builtEntries = new Map(); + const builtUnavailable = new Map(); + let orderChanged = false; + const previousOrder = [...previousEntries.keys()]; + const nextOrder: Array = []; + + for (const [rawInstanceId, entry] of nextRaw) { + const instanceId = ProviderInstanceId.make(rawInstanceId); + nextOrder.push(instanceId); + + const existing = previousEntries.get(instanceId); + if (existing !== undefined && !replacedIds.has(instanceId)) { + // No-op update: keep the existing live entry and scope. + builtEntries.set(instanceId, existing); + continue; + } + + const result = yield* buildEntry({ + driversById, + parentScope, + instanceId, + rawInstanceId, + entry, + }); + if (result.kind === "live") { + builtEntries.set(instanceId, result.live); + } else { + builtUnavailable.set(instanceId, result.snapshot); + } + } + + if (previousOrder.length === nextOrder.length) { + for (let i = 0; i < previousOrder.length; i++) { + if (previousOrder[i] !== nextOrder[i]) { + orderChanged = true; + break; + } + } + } else { + orderChanged = true; + } + + const entriesChanged = + orderChanged || + removedIds.length > 0 || + replacedIds.size > 0 || + builtEntries.size !== previousEntries.size; + const unavailableChanged = + builtUnavailable.size !== previousUnavailable.size || + [...builtUnavailable].some(([id, snapshot]) => { + const prev = previousUnavailable.get(id); + return prev === undefined || !Equal.equals(prev, snapshot); + }) || + [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); + + yield* Ref.set(state.entries, builtEntries); + yield* Ref.set(state.unavailable, builtUnavailable); + + if (entriesChanged || unavailableChanged) { + yield* PubSub.publish(state.changes, undefined); + } + }); +}; + +/** + * Build the registry's runtime state from a concrete configMap. Returns a + * record containing: + * + * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose + * under `ProviderInstanceRegistry`. + * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose + * under `ProviderInstanceRegistryMutator`. + * - `reconcile`: the raw reconcile function, provided for convenience so + * boot-time layers can hydrate an initial map before publishing the + * services. + * + * The scope that this effect runs in owns every per-instance child scope + * created during `reconcile`. Closing that scope closes every live + * instance. + */ +export const makeProviderInstanceRegistry = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Effect.Effect< + { + readonly registry: ProviderInstanceRegistryShape; + readonly mutator: ProviderInstanceRegistryMutatorShape; + }, + never, + R | Scope.Scope +> => + Effect.gen(function* () { + const driversById = new Map>( + input.drivers.map((driver) => [driver.driverKind, driver]), + ); + + // Capture the enclosing scope so per-instance child scopes can be + // attached to it at `reconcile` time. Without this, `reconcile` + // called later (e.g. from the hydration layer) would attach child + // scopes to the *caller's* scope instead of the registry's. + const parentScope = yield* Scope.Scope; + + // Capture the driver R context at construction time so `reconcile` + // can be invoked later without re-providing driver dependencies. + // The service tag's declared `reconcile: Effect` hides R from + // consumers — we materialize that here. + const driverContext = yield* Effect.context(); + + const entries = yield* Ref.make>(new Map()); + const unavailable = yield* Ref.make>(new Map()); + const changes = yield* PubSub.unbounded(); + yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); + + const state: RegistryState = { entries, unavailable, changes }; + const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const reconcile: ProviderInstanceRegistryMutatorShape["reconcile"] = (configMap) => + reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); + + // Hydrate the initial configMap synchronously so callers can read + // `listInstances` immediately after this effect completes. + yield* reconcile(input.configMap); + + const registry: ProviderInstanceRegistryShape = { + getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), + listInstances: Ref.get(entries).pipe( + Effect.map( + (map) => + Array.from(map.values(), (live) => live.instance) as ReadonlyArray, + ), + ), + listUnavailable: Ref.get(unavailable).pipe( + Effect.map((map) => Array.from(map.values()) as ReadonlyArray), + ), + // Getters: each read constructs a fresh Stream / Effect descriptor + // so multiple consumers don't share a single already-started + // Channel or subscription. Matches the pattern `ProviderRegistry` + // uses for its own `streamChanges`. + get streamChanges() { + return Stream.fromPubSub(changes); + }, + // Synchronous subscribe — callers that need to consume changes + // from a forked fibre must acquire the subscription in their own + // fibre first (via `yield* registry.subscribeChanges`) and only + // then fork a consumer loop on `Stream.fromSubscription(...)` / + // `PubSub.take(...)`. See the shape docs for the race this avoids. + get subscribeChanges() { + return PubSub.subscribe(changes); + }, + }; + + const mutator: ProviderInstanceRegistryMutatorShape = { reconcile }; + + return { registry, mutator }; + }); + +/** + * Assemble a `ProviderInstanceRegistry` Layer bound to a fixed set of + * drivers and a pre-resolved `ProviderInstanceConfigMap`. Used by tests + * that want explicit control over the registry's source-of-truth without + * wiring up the settings watcher. + * + * Only exposes the public registry tag — hot-reload consumers should use + * `ProviderInstanceRegistryMutableLayer` (below) or the hydration layer. + */ +export const ProviderInstanceRegistryLayer = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Layer.Layer => + Layer.effect( + ProviderInstanceRegistry, + makeProviderInstanceRegistry(input).pipe(Effect.map((built) => built.registry)), + ) as Layer.Layer; + +/** + * Layer variant that also exposes the mutator tag. Consumed by + * `ProviderInstanceRegistryHydrationLive` to reconcile on settings + * changes. Tests that exercise the mutator directly can pair this Layer + * with a test-local `ServerSettingsService`. + */ +export const ProviderInstanceRegistryMutableLayer = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Layer.Layer => + Layer.effectContext( + makeProviderInstanceRegistry(input).pipe( + Effect.map(({ registry, mutator }) => + Context.make(ProviderInstanceRegistry, registry).pipe( + Context.add(ProviderInstanceRegistryMutator, mutator), + ), + ), + ), + ) as Layer.Layer; + +export { defaultInstanceIdForDriver }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1df9da67743..75f9c42936b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,11 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, it, assert } from "@effect/vitest"; +import { describe, it, assert, live } from "@effect/vitest"; import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect"; import * as CodexErrors from "effect-codex-app-server/errors"; import { + ClaudeSettings, + CodexSettings, DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, ServerSettings, type ServerProvider, + type ServerProviderSlashCommand, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import * as PlatformError from "effect/PlatformError"; @@ -14,7 +19,10 @@ import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; +import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; +import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { haveProvidersChanged, mergeProviderSnapshot, @@ -22,8 +30,16 @@ import { } from "./ProviderRegistry.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); +const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ + enabled: false, +}); + process.env.T3CODE_CURSOR_ENABLED = "1"; // ── Test helpers ──────────────────────────────────────────────────── @@ -54,6 +70,27 @@ function booleanDescriptor(id: string, label: string) { }; } +type TestClaudeCapabilities = { + readonly email: string | undefined; + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly slashCommands: ReadonlyArray; +}; + +function claudeCapabilities(overrides: Partial = {}) { + return () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + slashCommands: [], + ...overrides, + }); +} + +const noClaudeCapabilities = () => + Effect.sync(() => undefined as TestClaudeCapabilities | undefined); + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -71,7 +108,11 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { } function mockSpawnerLayer( - handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, ) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -82,6 +123,33 @@ function mockSpawnerLayer( ); } +function recordingMockSpawnerLayer( + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, +) { + const commands: Array<{ + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv | undefined; + }> = []; + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { + args: ReadonlyArray; + options?: { + readonly env?: NodeJS.ProcessEnv; + }; + }; + commands.push({ args: cmd.args, env: cmd.options?.env }); + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); + return { layer, commands }; +} + function mockCommandSpawnerLayer( handler: ( command: string, @@ -91,7 +159,10 @@ function mockCommandSpawnerLayer( return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { command: string; args: ReadonlyArray }; + const cmd = command as unknown as { + command: string; + args: ReadonlyArray; + }; return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); }), ); @@ -181,7 +252,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( describe("checkCodexProviderStatus", () => { it.effect("uses the app-server account and model list for provider status", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => Effect.succeed( makeCodexProbeSnapshot({ skills: [ @@ -196,14 +267,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - - assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); assert.strictEqual(status.version, "1.0.0"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "chatgpt"); assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); assert.deepStrictEqual(status.models, [ { slug: "gpt-live-codex", @@ -226,7 +296,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -250,7 +320,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( "returns ready with unknown auth when app-server does not require OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -268,7 +338,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -288,7 +358,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns unavailable when codex is missing", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => Effect.fail( new CodexErrors.CodexAppServerSpawnError({ command: "codex app-server", @@ -296,7 +366,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); @@ -312,7 +381,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it("treats equal provider snapshots as unchanged", () => { const providers = [ { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), status: "ready", enabled: true, installed: true, @@ -324,7 +394,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( skills: [], }, { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), status: "warning", enabled: true, installed: true, @@ -342,7 +413,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it("preserves previously discovered provider models when a refresh returns none", () => { const previousProvider = { - provider: "cursor", + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), status: "ready", enabled: true, installed: true, @@ -381,7 +453,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { - provider: "cursor", + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), status: "ready", enabled: true, installed: true, @@ -427,41 +500,345 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ]); }); - it.effect("probes enabled providers in the background during registry startup", () => + it.effect("returns the cached provider list when a manual refresh fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const cachedProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + getSnapshot: Effect.succeed(cachedProvider), + refresh: Effect.die(new Error("simulated refresh failure")), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-refresh-failure-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + + assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ + cachedProvider, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("keeps consuming registry changes after one sync fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const claudeProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:01:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const makeInstance = (provider: ServerProvider): ProviderInstance => ({ + instanceId: provider.instanceId, + driverKind: provider.driver, + continuationIdentity: { + driverKind: provider.driver, + continuationKey: `${provider.driver}:instance:${provider.instanceId}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const codexInstance = makeInstance(codexProvider); + const claudeInstance = makeInstance(claudeProvider); + const changes = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([codexInstance]); + const failNextList = yield* Ref.make(false); + const wait = (millis: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, millis))); + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), + ), + ), + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); + } + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-sync-failure-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); + + yield* Ref.set(failNextList, true); + yield* PubSub.publish(changes, undefined); + + yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); + yield* PubSub.publish(changes, undefined); + + let providers = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && + !providers.some((provider) => provider.instanceId === claudeInstanceId); + attempt += 1 + ) { + yield* wait(10); + providers = yield* registry.getProviders; + } + + assert.deepStrictEqual( + providers.map((provider) => provider.instanceId).toSorted(), + [codexInstanceId, claudeInstanceId].toSorted(), + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // This test intentionally avoids `mockCommandSpawnerLayer` so the real + // `probeCodexAppServerProvider` path runs — including the full + // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // We point `binaryPath` at a name that cannot exist on any machine so + // the real `ChildProcessSpawner` deterministically returns ENOENT; the + // probe wraps that as `CodexAppServerSpawnError` and + // `checkCodexProviderStatus` turns it into the user-visible "not + // installed" error snapshot. If the aggregator's `syncLiveSources` + // breaks — the `codex_personal`-never-probes bug we are guarding + // against — that snapshot never lands in `getProviders` and the + // assertions below fail. + it.effect("propagates real Codex probe failures to the aggregator at boot", () => Effect.gen(function* () { - let spawnCount = 0; + const missingBinary = `t3code_codex_missing_${process.pid}_${Date.now()}`; const serverSettings = yield* makeMutableServerSettingsService( Schema.decodeSync(ServerSettings)( deepMerge(DEFAULT_SERVER_SETTINGS, { providers: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. codex: { enabled: false }, + claudeAgent: { enabled: false }, cursor: { enabled: false }, + opencode: { enabled: false }, }, + // `providerInstances` keys are branded `ProviderInstanceId`; + // the branded index signature rejects plain string literals + // at the TS level even though the runtime schema happily + // accepts + decodes them. Cast the patch to `unknown` so + // the `Schema.decodeSync` below does the real validation. + providerInstances: { + // Matches the shape the user had in `.t3/dev/settings.json` + // when the bug was reported: a custom enabled Codex instance + // pointing at a binary the server has to actually spawn. + codex_personal: { + driver: "codex", + displayName: "Codex Personal", + enabled: true, + config: { + binaryPath: missingBinary, + homePath: `/tmp/${missingBinary}_home`, + }, + }, + } as unknown as ContractServerSettings["providerInstances"], }), ), ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + // NO spawner mock — `ChildProcessSpawner` is supplied by the + // outer `NodeServices.layer` on `it.layer(...)` and will + // genuinely spawn a subprocess. The missing-binary ENOENT is + // what exercises the same failure mode as a misconfigured + // production `binaryPath`. + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const codexPersonal = providers.find( + (provider) => provider.instanceId === "codex_personal", + ); + assert.notStrictEqual( + codexPersonal, + undefined, + `Expected the aggregator to know about codex_personal; instead saw: ${providers + .map((provider) => provider.instanceId) + .join(", ")}`, + ); + assert.strictEqual( + codexPersonal?.status, + "error", + "Real Codex probe against a missing binary should surface as 'error' in the aggregator", + ); + assert.strictEqual(codexPersonal?.installed, false); + assert.strictEqual( + codexPersonal?.message, + "Codex CLI (`codex`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // Guards the second half of the reported bug: changing + // `providers.codex.binaryPath` in settings must tear down the live + // instance and rebuild it so a fresh probe runs with the new binary. + // This test drives the real settings stream → registry reconcile → + // aggregator sync pipeline and asserts that `getProviders` reflects + // the new probe's outcome. If `syncLiveSources` stops awaiting the + // rebuilt instance's refresh (previous bug mode), the aggregator + // keeps the old snapshot and this test fails. + // + // `live` (imported from `@effect/vitest`) is used instead of + // `it.effect` so real timers coordinate the fibres that drive the + // settings → reconcile → sync pipeline. Under `it.effect`'s + // TestClock, `Effect.sleep` blocks until `TestClock.adjust`, which + // would require this test to reach into the internals of the + // reconcile pipeline to advance it step by step. + // + // The nested `it` handed to `it.layer(…, (it) => …)` is the + // `MethodsNonLive` variant and therefore lacks `.live`; the + // top-level `live` export from `@effect/vitest` is the equivalent. + live("re-probes when settings change the codex binaryPath", () => + Effect.gen(function* () { + const firstMissing = `t3code_codex_first_${process.pid}_${Date.now()}`; + const secondMissing = `t3code_codex_second_${process.pid}_${Date.now()}`; + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + codex: { enabled: true, binaryPath: firstMissing }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - spawnCount += 1; - const joined = args.join(" "); - if (joined === "--version") { - return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; - } - if (joined === "auth status") { - return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", }), ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + // `it.live` does not inherit layers from the outer `it.layer` + // wrapper, so provide `NodeServices.layer` inline. This is the + // same real `ChildProcessSpawner` + `FileSystem` + `Path` + // services that production uses. + Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( Scope.provide(scope), @@ -469,24 +846,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; - assert.strictEqual(spawnCount > 0, true); + // Boot-time probe: the default codex instance is enabled with + // `firstMissing`, so the real spawner yields ENOENT and the + // snapshot should be `status: "error"`. What *distinguishes* + // the two probe runs is `checkedAt` — each probe stamps a + // fresh DateTime, so we capture it and assert it advances + // after the settings mutation. + const initialProviders = yield* registry.getProviders; + const initialCodex = initialProviders.find( + (provider) => provider.instanceId === "codex", + ); + assert.strictEqual(initialCodex?.status, "error"); + assert.strictEqual(initialCodex?.installed, false); + const initialCheckedAt = initialCodex?.checkedAt; + assert.notStrictEqual(initialCheckedAt, undefined); + + // Drive a settings change. The Hydration layer's + // `SettingsWatcherLive` consumes this via `streamChanges`, + // calls `reconcile`, which rebuilds the codex instance (the + // envelope changed because `binaryPath` differs → `entryEqual` + // is false). The registry's `Stream.runForEach( + // instanceRegistry.streamChanges, () => syncLiveSources)` + // fires `syncLiveSources`, which subscribes + awaits a fresh + // refresh on the rebuilt instance. + yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: true, binaryPath: secondMissing }, + }, + }); + + // Poll with real timers (via `it.live`) until `checkedAt` + // advances or we hit a generous 3-second ceiling. Anything + // slower than that is a regression — the real probe fails + // fast on ENOENT, and the reconcile + sync pipeline is + // purely in-process. const refreshed = yield* Effect.gen(function* () { - for (let remainingAttempts = 50; remainingAttempts > 0; remainingAttempts -= 1) { + for (let attempts = 0; attempts < 60; attempts += 1) { const providers = yield* registry.getProviders; - const claudeProvider = providers.find( - (provider) => provider.provider === "claudeAgent", - ); - if (claudeProvider?.status === "ready") { + const codex = providers.find((provider) => provider.instanceId === "codex"); + if (codex !== undefined && codex.checkedAt !== initialCheckedAt) { return providers; } - yield* Effect.sleep("10 millis"); + yield* Effect.sleep("50 millis"); } return yield* registry.getProviders; }); - assert.strictEqual( - refreshed.find((provider) => provider.provider === "claudeAgent")?.status, - "ready", + + const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); + assert.notStrictEqual( + reprobedCodex?.checkedAt, + initialCheckedAt, + "Expected a fresh probe after settings change, got the stale snapshot", ); + assert.strictEqual(reprobedCodex?.status, "error"); + assert.strictEqual(reprobedCodex?.installed, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("includes unavailable instance snapshots in getProviders", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + providerInstances: { + ghost_main: { + driver: "ghostDriver", + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload" }, + }, + } as unknown as ContractServerSettings["providerInstances"], + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + + assert.notStrictEqual(ghost, undefined); + assert.strictEqual(ghost?.driver, "ghostDriver"); + assert.strictEqual(ghost?.availability, "unavailable"); + assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); }).pipe(Effect.provide(runtimeServices)); }), ); @@ -513,12 +979,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { if (command === "agent") { @@ -526,10 +995,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( } const joined = args.join(" "); if (joined === "--version") { - return { stdout: `${command} 1.0.0\n`, stderr: "", code: 0 }; + return { + stdout: `${command} 1.0.0\n`, + stderr: "", + code: 0, + }; } if (joined === "auth status") { - return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + return { + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }; } throw new Error(`Unexpected args: ${command} ${joined}`); }), @@ -545,12 +1022,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; const providers = yield* registry.getProviders; - const cursorProvider = providers.find((provider) => provider.provider === "cursor"); - - assert.deepStrictEqual( - providers.map((provider) => provider.provider), - ["codex", "claudeAgent", "opencode", "cursor"], + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), ); + + assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "claudeAgent", + "codex", + "cursor", + "opencode", + ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); assert.strictEqual( @@ -564,20 +1045,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { - const serverSettingsLayer = ServerSettingsService.layerTest({ - providers: { - codex: { - enabled: false, - }, - }, - }); - - const status = yield* checkCodexProviderStatus().pipe( - Effect.provide( - Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), - ), + const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); - assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.enabled, false); assert.strictEqual(status.status, "disabled"); assert.strictEqual(status.installed, false); @@ -591,8 +1061,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( describe("checkClaudeProviderStatus", () => { it.effect("returns ready when claude is installed and authenticated", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); assert.strictEqual(status.auth.status, "authenticated"); @@ -617,7 +1089,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); if (!opus47) { assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); @@ -655,7 +1130,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); assert.strictEqual( status.models.some((model) => model.slug === "claude-opus-4-7"), false, @@ -683,8 +1161,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "maxplan" }), + ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "maxplan"); @@ -706,18 +1186,120 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("does not duplicate Claude in full subscription labels", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max Subscription", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max Subscription"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in provider-prefixed subscription names", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns claude auth email from initialization result", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "claude@example.com" }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "claude@example.com"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("runs Claude status probes with the configured Claude HOME", () => { + const claudeHome = "/tmp/t3code-claude-home"; + const recorded = recordingMockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + homePath: claudeHome, + }, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.deepStrictEqual( + recorded.commands.map((command) => command.env?.HOME), + [claudeHome], + ); + }).pipe(Effect.provide(recorded.layer)); + }); + it.effect("includes probed claude slash commands in the provider snapshot", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( - () => Effect.succeed("maxplan"), - () => - Effect.succeed([ + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ { name: "review", description: "Review a pull request", input: { hint: "pr-or-branch" }, }, - ]), + ], + }), ); assert.deepStrictEqual(status.slashCommands, [ @@ -747,9 +1329,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("deduplicates probed claude slash commands by name", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( - () => Effect.succeed("maxplan"), - () => - Effect.succeed([ + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ { name: "ui", description: "Explore and refine UI", @@ -758,7 +1341,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( name: "ui", input: { hint: "component-or-screen" }, }, - ]), + ], + }), ); assert.deepStrictEqual(status.slashCommands, [ @@ -787,8 +1371,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "apiKey"); @@ -812,8 +1398,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns unavailable when claude is missing", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); @@ -826,8 +1414,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns error when version check fails with non-zero exit code", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); }).pipe( @@ -835,33 +1425,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") - return { stdout: "", stderr: "Something went wrong", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when auth status reports not logged in", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Claude is not authenticated. Run `claude auth login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") return { - stdout: '{"loggedIn":false}\n', - stderr: "", + stdout: "", + stderr: "Something went wrong", code: 1, }; throw new Error(`Unexpected args: ${joined}`); @@ -870,36 +1436,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); - it.effect("returns unauthenticated when output includes 'not logged in'", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unauthenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when auth status command is unsupported", () => + it.effect("returns warning when the Claude initialization result is unavailable", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noClaudeCapabilities, + ); assert.strictEqual(status.status, "warning"); assert.strictEqual(status.installed, true); assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, - "Claude Agent authentication status command is unavailable in this version of Claude.", + "Could not verify Claude authentication status from initialization result.", ); }).pipe( Effect.provide( @@ -907,52 +1455,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; if (joined === "auth status") - return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; throw new Error(`Unexpected args: ${joined}`); }), ), ), ); }); - - // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── - - describe("parseClaudeAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.auth.status, "authenticated"); - }); - - it("JSON with loggedIn=true is authenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.auth.status, "authenticated"); - }); - - it("JSON with loggedIn=false is unauthenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.auth.status, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"ok":true}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.auth.status, "unknown"); - }); - }); }, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index ddd1701a94f..4f586d881e3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -1,39 +1,63 @@ /** - * ProviderRegistryLive - Aggregates provider-specific snapshot services. + * ProviderRegistryLive — aggregates per-instance snapshot streams into a + * single materialized list. + * + * Historically this Layer composed four per-kind Live Layers + * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a + * `ServerProviderShape`. Those Lives were deleted during the driver / + * instance refactor — every driver now carries its `snapshot: ServerProviderShape` + * bundled onto the `ProviderInstance` the registry produces. + * + * Each configured instance (including multi-instance setups like + * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, + * keyed by `instanceId`. Instances whose driver is unavailable or whose + * config failed to decode are merged from `instanceRegistry.listUnavailable` + * as shadow snapshots so the UI can render their exact unavailable reason. + * + * Cache paths on disk are now keyed by `instanceId`. Because + * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing + * `.json` files remain the on-disk location for that driver's default + * instance. Identity-less legacy cache contents are ignored and replaced by + * the first live refresh. * * @module ProviderRegistryLive */ -import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; -import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; +import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../../config.ts"; -import { ClaudeProviderLive } from "./ClaudeProvider.ts"; -import { CodexProviderLive } from "./CodexProvider.ts"; -import { CursorProviderLive } from "./CursorProvider.ts"; -import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; -import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; -import { CodexProvider } from "../Services/CodexProvider.ts"; -import { CursorProvider } from "../Services/CursorProvider.ts"; -import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; -import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { hydrateCachedProvider, - PROVIDER_CACHE_IDS, + isCachedProviderCorrelated, orderProviderSnapshots, readProviderStatusCache, resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; -import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( providerSources: ReadonlyArray, ): Effect.Effect> => - Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, { - concurrency: "unbounded", - }); + Effect.forEach( + providerSources, + (providerSource) => + providerSource.getSnapshot.pipe( + Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), + ), + { + concurrency: "unbounded", + }, + ); const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; @@ -77,63 +101,123 @@ export const haveProvidersChanged = ( nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); -const ProviderRegistryLiveBase = Layer.effect( +const correlateSnapshotWithSource = ( + source: ProviderSnapshotSource, + snapshot: ServerProvider, +): Effect.Effect => { + if (snapshot.instanceId !== source.instanceId) { + return Effect.die( + new Error( + `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, + ), + ); + } + if (snapshot.driver !== source.driverKind) { + return Effect.die( + new Error( + `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, + ), + ); + } + return Effect.succeed(snapshot); +}; + +/** + * Key a snapshot for aggregation and persistence. Snapshot sources + * must be correlated by instance id before reaching this map; missing + * identities are defects, not runtime routing fallbacks. + */ +const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { + return provider.instanceId; +}; + +// Project a live `ProviderInstance` into the aggregator's consumption +// shape. Each call re-captures the instance's `snapshot` closures, so +// after `ProviderInstanceRegistry` rebuilds an instance (e.g. because +// its settings changed), a fresh source rides the new PubSub instead +// of a closed one. +const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + getSnapshot: instance.snapshot.getSnapshot, + refresh: instance.snapshot.refresh, + streamChanges: instance.snapshot.streamChanges, +}); + +export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { - const codexProvider = yield* CodexProvider; - const claudeProvider = yield* ClaudeProvider; - const openCodeProvider = yield* OpenCodeProvider; + const instanceRegistry = yield* ProviderInstanceRegistry; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const cursorProvider = yield* CursorProvider; - - const providerSources = createBuiltInProviderSources({ - codex: codexProvider, - claudeAgent: claudeProvider, - opencode: openCodeProvider, - cursor: cursorProvider, - }) satisfies ReadonlyArray; - const activeProviders = PROVIDER_CACHE_IDS; + // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for + // coalesced updates across every instance. const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const fallbackProviders = yield* loadProviders(providerSources); - const cachePathByProvider = new Map( - activeProviders.map( - (provider) => - [ - provider, - resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - provider, - }), - ] as const, - ), - ); - const fallbackByProvider = new Map( - fallbackProviders.map((provider) => [provider.provider, provider] as const), - ); + + // Boot-only: hydrate `providersRef` from the on-disk per-instance + // cache so the UI has something to render during the first refresh. + // Instances added post-boot skip this path; their first entry in + // `providersRef` comes from the reactive `syncLiveSources` pass + // below. + const bootInstances = yield* instanceRegistry.listInstances; + const bootSources = bootInstances.map(buildSnapshotSource); + const fallbackProviders = yield* loadProviders(bootSources); + const fallbackByInstance = new Map(); + for (let index = 0; index < fallbackProviders.length; index++) { + const provider = fallbackProviders[index]; + const source = bootSources[index]; + if (provider === undefined || source === undefined) { + continue; + } + fallbackByInstance.set(source.instanceId, provider); + } const cachedProviders = yield* Effect.forEach( - activeProviders, - (provider) => { - const filePath = cachePathByProvider.get(provider)!; - const fallbackProvider = fallbackByProvider.get(provider)!; - return readProviderStatusCache(filePath).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.map((cachedProvider) => - cachedProvider === undefined - ? undefined - : hydrateCachedProvider({ - cachedProvider, - fallbackProvider, - }), - ), - ); - }, + bootSources, + (source) => + Effect.gen(function* () { + // One cache file per configured instance. For the default + // instance of a built-in kind the path equals `.json` — + // identical to the legacy filename. We still require the cache + // payload to carry matching instance id + driver kind; old + // identity-less payloads are discarded and the awaited refresh + // below repopulates the cache. + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: source.instanceId, + }).pipe(Effect.provideService(Path.Path, path)); + const fallbackProvider = fallbackByInstance.get(source.instanceId); + if (fallbackProvider === undefined) { + return undefined; + } + return yield* readProviderStatusCache(filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.flatMap((cachedProvider) => { + if (cachedProvider === undefined) { + return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); + } + const correlation = { + cachedProvider, + fallbackProvider, + } as const; + if (!isCachedProviderCorrelated(correlation)) { + return Effect.logWarning("provider status cache identity mismatch, ignoring", { + path: filePath, + instanceId: source.instanceId, + cachedInstanceId: cachedProvider.instanceId ?? null, + driver: source.driverKind, + cachedDriver: cachedProvider.driver ?? null, + }).pipe(Effect.as(undefined as ServerProvider | undefined)); + } + return Effect.succeed(hydrateCachedProvider(correlation)); + }), + ); + }), { concurrency: "unbounded" }, ).pipe( Effect.map((providers) => @@ -144,34 +228,64 @@ const ProviderRegistryLiveBase = Layer.effect( ); const providersRef = yield* Ref.make>(cachedProviders); + // Live-source registry — the dynamic counterpart to the boot-time + // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` + // reference is used for identity equality so "no-op" reconciles + // (settings unchanged) skip re-subscribing + re-probing. + const liveSubsRef = yield* Ref.make>( + new Map(), + ); + // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't + // interleave two passes clobbering each other's fiber bookkeeping. + const syncSemaphore = yield* Semaphore.make(1); + + const getLiveSources: Effect.Effect> = Ref.get( + liveSubsRef, + ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); + const persistProvider = (provider: ServerProvider) => - writeProviderStatusCache({ - filePath: cachePathByProvider.get(provider.provider)!, - provider, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.tapError(Effect.logError), - Effect.ignore, - ); + Effect.gen(function* () { + // Persist every instance — the file name is the instance id, so + // multi-instance setups (e.g. `codex_personal`, `codex_work`) each + // get their own cache. We resolve the path fresh so snapshots + // produced by newly-added instances post-boot still land on disk + // without the aggregator holding a stale `cachePathByInstance` + // entry. + const key = snapshotInstanceKey(provider); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: key, + }).pipe(Effect.provideService(Path.Path, path)); + yield* writeProviderStatusCache({ filePath, provider }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + }); const upsertProviders = Effect.fn("upsertProviders")(function* ( nextProviders: ReadonlyArray, options?: { readonly publish?: boolean; + readonly persist?: boolean; + readonly replace?: boolean; }, ) { const [previousProviders, providers] = yield* Ref.modify( providersRef, (previousProviders) => { const mergedProviders = new Map( - previousProviders.map((provider) => [provider.provider, provider] as const), + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), ); for (const provider of nextProviders) { + const key = snapshotInstanceKey(provider); mergedProviders.set( - provider.provider, - mergeProviderSnapshot(mergedProviders.get(provider.provider), provider), + key, + options?.replace === true + ? provider + : mergeProviderSnapshot(mergedProviders.get(key), provider), ); } @@ -181,10 +295,12 @@ const ProviderRegistryLiveBase = Layer.effect( ); if (haveProvidersChanged(previousProviders, providers)) { - yield* Effect.forEach(nextProviders, persistProvider, { - concurrency: "unbounded", - discard: true, - }); + if (options?.persist !== false) { + yield* Effect.forEach(nextProviders, persistProvider, { + concurrency: "unbounded", + discard: true, + }); + } if (options?.publish !== false) { yield* PubSub.publish(changesPubSub, providers); } @@ -202,64 +318,246 @@ const ProviderRegistryLiveBase = Layer.effect( return yield* upsertProviders([provider], options); }); - const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { - if (provider) { - const providerSource = providerSources.find((candidate) => candidate.provider === provider); - if (!providerSource) { - return yield* Ref.get(providersRef); - } - return yield* providerSource.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ); - } - - return yield* Effect.forEach( - providerSources, - (providerSource) => providerSource.refresh.pipe(Effect.flatMap(syncProvider)), - { - concurrency: "unbounded", - discard: true, - }, - ).pipe(Effect.andThen(Ref.get(providersRef))); + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( + providerSource: ProviderSnapshotSource, + ) { + return yield* providerSource.refresh.pipe( + Effect.flatMap((nextProvider) => + correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), + ), + ), + ); }); - yield* Effect.forEach( - providerSources, - (providerSource) => - Stream.runForEach(providerSource.streamChanges, (provider) => syncProvider(provider)).pipe( - Effect.forkScoped, - ), - { + const refreshAll = Effect.fn("refreshAll")(function* () { + const sources = yield* getLiveSources; + return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { concurrency: "unbounded", discard: true, - }, + }).pipe(Effect.andThen(Ref.get(providersRef))); + }); + + const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { + if (provider === undefined) { + return yield* refreshAll(); + } + // Kind-scoped refreshes target the default instance for that driver. + const defaultInstanceId = defaultInstanceIdForDriver(provider); + const sources = yield* getLiveSources; + const providerSource = sources.find( + (candidate) => candidate.instanceId === defaultInstanceId, + ); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* refreshOneSource(providerSource); + }); + + const refreshInstance = Effect.fn("refreshInstance")(function* ( + instanceId: ProviderInstanceId, + ) { + const sources = yield* getLiveSources; + const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* refreshOneSource(providerSource); + }); + + /** + * Diff the aggregator's live-source set against the current + * `ProviderInstanceRegistry` and: + * - subscribe to each newly-added or rebuilt instance's + * `streamChanges` (so periodic + enrichment refreshes land in + * `providersRef`); + * - force-refresh each newly-added/rebuilt instance and feed the + * result directly into `providersRef`, bypassing the PubSub + * attachment race that otherwise drops the initial probe; + * - prune `providersRef` of instances that no longer exist. + * + * Initial refreshes are awaited in parallel rather than forked, so + * callers (layer build; `streamChanges` watcher) see fully-probed + * state on return. This matters for layer build in particular: + * consumers reading `getProviders` immediately after layer build + * expect the probe to have already landed. + * + * Per-instance subscription fibers are not tracked explicitly. When + * a rebuilt instance's old child scope closes, its PubSub shuts + * down and our `Stream.runForEach` fiber exits naturally. + */ + const syncLiveSources = syncSemaphore.withPermits(1)( + Effect.gen(function* () { + const instances = yield* instanceRegistry.listInstances; + const unavailableProviders = yield* instanceRegistry.listUnavailable; + const nextByInstance = new Map( + instances.map((instance) => [instance.instanceId, instance] as const), + ); + const knownInstanceIds = new Set(nextByInstance.keys()); + for (const provider of unavailableProviders) { + knownInstanceIds.add(snapshotInstanceKey(provider)); + } + const previousSubs = yield* Ref.get(liveSubsRef); + + // Carry over subscriptions for instances whose identity is + // unchanged (reconcile treated them as no-op). Instances that + // disappeared, or were rebuilt with a different reference, + // fall through to the "newly-added" branch below. + const carriedOver = new Map(); + for (const [instanceId, previousInstance] of previousSubs) { + const nextInstance = nextByInstance.get(instanceId); + if (nextInstance !== undefined && nextInstance === previousInstance) { + carriedOver.set(instanceId, previousInstance); + } + } + + // Collect new/rebuilt instances in `nextByInstance` insertion + // order (which preserves settings-author order). + const newlyAdded: Array = []; + for (const [instanceId, instance] of nextByInstance) { + if (carriedOver.has(instanceId)) { + continue; + } + newlyAdded.push([instanceId, instance] as const); + } + + // Fork long-lived subscriptions to each new/rebuilt instance's + // change stream BEFORE kicking off refreshes — if the driver's + // own initial probe (line 140 in `makeManagedServerProvider`) + // wins the refreshSemaphore race, its PubSub publish must land + // in an active subscriber or the result is dropped. + for (const [, instance] of newlyAdded) { + const source = buildSnapshotSource(instance); + yield* Stream.runForEach(source.streamChanges, (provider) => + correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), + ).pipe(Effect.forkScoped); + } + + // Force-refresh every new/rebuilt instance in parallel and wait + // for them all to complete. The refresh's result is piped + // directly into `syncProvider`, so `providersRef` is populated + // deterministically by the time this block returns — regardless + // of PubSub subscription timing. Failures are logged and + // swallowed so one bad driver can't wedge the whole registry. + yield* Effect.forEach( + newlyAdded, + ([, instance]) => + refreshOneSource(buildSnapshotSource(instance)).pipe(Effect.ignoreCause({ log: true })), + { concurrency: "unbounded", discard: true }, + ); + yield* upsertProviders(unavailableProviders, { + persist: false, + replace: true, + }); + + const nextSubs = new Map(carriedOver); + for (const [instanceId, instance] of newlyAdded) { + nextSubs.set(instanceId, instance); + } + yield* Ref.set(liveSubsRef, nextSubs); + + // Drop aggregator state for instances that have disappeared — + // otherwise the UI would keep rendering ghosts. + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = orderProviderSnapshots( + previousProviders.filter((provider) => + knownInstanceIds.has(snapshotInstanceKey(provider)), + ), + ); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + }), ); - yield* loadProviders(providerSources).pipe( - Effect.flatMap((providers) => upsertProviders(providers, { publish: false })), + const syncLiveSourcesAndContinue = syncLiveSources.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logError( + "provider registry instance sync failed; keeping subscription alive", + { + cause: Cause.pretty(cause), + }, + ); + }), ); + // Seed `providersRef` with the boot-time fallback snapshots so + // consumers calling `getProviders` immediately after layer build see + // a populated list — even before the first `syncLiveSources` refresh + // resolves. Cached snapshots (already in `providersRef`) merge with + // these via `upsertProviders` so on-disk state wins where present + // and pending fallbacks fill the gaps. + yield* upsertProviders(fallbackProviders, { publish: false }); + // Subscribe to registry mutations BEFORE running the initial sync. + // `subscribeChanges` acquires the dequeue synchronously in this + // fibre; the subscription is active the instant this `yield*` + // returns. Forking the consumer loop later cannot lose a publish + // because no publish can reach a not-yet-subscribed dequeue. + // + // (Contrast with the pre-fix code that did + // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. + // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, + // and `forkScoped` only schedules the fibre — so a reconcile that + // published between "fibre scheduled" and "fibre starts running" + // was dropped, which made any settings change that replaced an + // instance never propagate to the aggregator's `providersRef`.) + // Subscribe to registry mutations BEFORE running the initial sync. + // `subscribeChanges` acquires the `PubSub.Subscription` synchronously + // in this fibre; the subscription is registered with the PubSub the + // instant this `yield*` returns, so any subsequent publish is + // buffered in the subscription regardless of when the consumer + // fibre below actually starts running. + // + // (Contrast with the pre-fix code that did + // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. + // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, + // which defers `PubSub.subscribe` to stream start. `forkScoped` only + // schedules the consumer fibre — so a reconcile that published + // between "fibre scheduled" and "fibre starts running + subscribes" + // was dropped, which made any settings change that replaced an + // instance never propagate to the aggregator's `providersRef`.) + const instanceChanges = yield* instanceRegistry.subscribeChanges; + // Initial sync: subscribe + kick off refreshes for every instance + // present at boot. Run synchronously so consumers pulling immediately + // after the layer build see the correct aggregator state. + yield* syncLiveSources; + // React to registry mutations — instance added / removed / rebuilt. + // `Stream.fromSubscription` builds a stream over the pre-acquired + // subscription rather than subscribing on stream start, which is + // what closes the race. + yield* Stream.runForEach( + Stream.fromSubscription(instanceChanges), + () => syncLiveSourcesAndContinue, + ).pipe(Effect.forkScoped); + + const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( + cause: Cause.Cause, + ) { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.interrupt; + } + yield* Effect.logError("provider registry refresh failed; preserving cached providers", { + cause: Cause.pretty(cause), + }); + return yield* Ref.get(providersRef); + }); + return { getProviders: Ref.get(providersRef), - refresh: (provider?: ProviderKind) => - refresh(provider).pipe( - Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => [] as ReadonlyArray), - ), + refresh: (provider?: ProviderDriverKind) => + refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance: (instanceId: ProviderInstanceId) => + refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, } satisfies ProviderRegistryShape; }), ); - -export const ProviderRegistryLive = Layer.unwrap( - Effect.sync(() => - ProviderRegistryLiveBase.pipe( - Layer.provideMerge(CursorProviderLive), - Layer.provideMerge(CodexProviderLive), - Layer.provideMerge(ClaudeProviderLive), - Layer.provideMerge(OpenCodeProviderLive), - Layer.provideMerge(OpenCodeRuntimeLive), - ), - ), -); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 09606ba35a7..7e771251437 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -12,7 +12,8 @@ import type { import { ApprovalRequestId, EventId, - type ProviderKind, + ProviderDriverKind, + ProviderInstanceId, ProviderSessionStartInput, ThreadId, TurnId, @@ -20,20 +21,25 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, vi } from "@effect/vitest"; -import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; +import { Effect, Exit, Fiber, Layer, Metric, Option, PubSub, Ref, Scope, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { + ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { + ProviderAdapterRegistry, + type ProviderAdapterRegistryShape, +} from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; @@ -44,6 +50,7 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettingsService.layerTest(); @@ -51,11 +58,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make const asEventId = (value: string): EventId => EventId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +const codexInstanceId = ProviderInstanceId.make("codex"); +const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent"); +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -65,7 +77,7 @@ type LegacyProviderRuntimeEvent = { readonly [key: string]: unknown; }; -function makeFakeCodexAdapter(provider: ProviderKind = "codex") { +function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { const sessions = new Map(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -74,10 +86,15 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const now = new Date().toISOString(); const session: ProviderSession = { provider, + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), status: "ready", runtimeMode: input.runtimeMode, threadId: input.threadId, - resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` }, + resumeCursor: input.resumeCursor ?? { + opaque: `resume-${String(input.threadId)}`, + }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -245,19 +262,13 @@ const hasMetricSnapshot = ( function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); - const claude = makeFakeCodexAdapter("claudeAgent"); - const cursor = makeFakeCodexAdapter("cursor"); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : provider === "claudeAgent" - ? Effect.succeed(claude.adapter) - : provider === "cursor" - ? Effect.succeed(cursor.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeAgent", "cursor"]), - }; + const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + [ProviderDriverKind.make("claudeAgent")]: claude.adapter, + [ProviderDriverKind.make("cursor")]: cursor.adapter, + }); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( @@ -272,6 +283,7 @@ function makeProviderServiceLayer() { Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ), directoryLayer, @@ -288,27 +300,76 @@ function makeProviderServiceLayer() { }; } +it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + codex.stopAll.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: String(CODEX_DRIVER), + method: "stopAll", + detail: "simulated stopAll failure", + }), + ), + ); + const registry = makeAdapterRegistryMock({ + [CODEX_DRIVER]: codex.adapter, + }); + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provideMerge(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ), + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ); + const scope = yield* Scope.make(); + const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + yield* ProviderService; + }).pipe(Effect.provide(runtimeServices)); + const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); + + assert.equal(Exit.isSuccess(closeExit), true); + assert.equal(codex.stopAll.mock.calls.length, 1); + }), +); + it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); - const claude = makeFakeCodexAdapter("claudeAgent"); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : provider === "claudeAgent" - ? Effect.succeed(claude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeAgent"]), + const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const registryBase = makeAdapterRegistryMock({ + [CODEX_DRIVER]: codex.adapter, + [CLAUDE_AGENT_DRIVER]: claude.adapter, + }); + const registry: ProviderAdapterRegistryShape = { + ...registryBase, + getInstanceInfo: (instanceId) => + instanceId === claudeAgentInstanceId + ? Effect.succeed({ + instanceId, + driverKind: CLAUDE_AGENT_DRIVER, + displayName: undefined, + enabled: false, + continuationIdentity: { + driverKind: CLAUDE_AGENT_DRIVER, + continuationKey: "claudeAgent:instance:claudeAgent", + }, + }) + : registryBase.getInstanceInfo(instanceId), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const serverSettingsLayer = ServerSettingsService.layerTest({ - providers: { - claudeAgent: { - enabled: false, - }, - }, - }); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -316,15 +377,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () const providerLayer = makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(serverSettingsLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const failure = yield* Effect.flip( Effect.gen(function* () { const provider = yield* ProviderService; return yield* provider.startSession(asThreadId("thread-disabled"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-disabled"), runtimeMode: "full-access", }); @@ -332,11 +395,149 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () ); assert.instanceOf(failure, ProviderValidationError); - assert.include(failure.issue, "Provider 'claudeAgent' is disabled in T3 Code settings."); + assert.include(failure.issue, "Provider instance 'claudeAgent' is disabled"); assert.equal(claude.startSession.mock.calls.length, 0); }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect( + "ProviderServiceLive allows enabled custom instances when legacy driver is disabled", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_personal"); + const driverKind = CODEX_DRIVER; + const codex = makeFakeCodexAdapter(); + const unsupported = () => + new ProviderUnsupportedError({ + provider: driverKind, + }); + const registry: ProviderAdapterRegistryShape = { + getByInstance: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed(codex.adapter) + : Effect.fail(unsupported()), + getInstanceInfo: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed({ + instanceId, + driverKind, + displayName: "Codex Personal", + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: "codex:/Users/example/.codex", + }, + }) + : Effect.fail(unsupported()), + listInstances: () => Effect.succeed([instanceId]), + listProviders: () => Effect.succeed([driverKind] as const), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + codex: { + enabled: false, + }, + }, + }); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(serverSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + const session = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-enabled-custom"), { + provider: driverKind, + providerInstanceId: instanceId, + threadId: asThreadId("thread-enabled-custom"), + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + assert.equal(session.providerInstanceId, instanceId); + assert.equal(codex.startSession.mock.calls.length, 1); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("ProviderServiceLive rejects new sessions for disabled custom instances", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_personal"); + const driverKind = ProviderDriverKind.make("codex"); + const codex = makeFakeCodexAdapter(); + const unsupported = () => + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make("codex"), + }); + const registry: ProviderAdapterRegistryShape = { + getByInstance: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed(codex.adapter) + : Effect.fail(unsupported()), + getInstanceInfo: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed({ + instanceId, + driverKind, + displayName: "Codex Personal", + enabled: false, + continuationIdentity: { + driverKind, + continuationKey: "codex:/Users/example/.codex", + }, + }) + : Effect.fail(unsupported()), + listInstances: () => Effect.succeed([instanceId]), + listProviders: () => Effect.succeed([CODEX_DRIVER] as const), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + const failure = yield* Effect.flip( + Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-disabled-instance"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId: asThreadId("thread-disabled-instance"), + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Provider instance 'codex_personal' is disabled"); + assert.equal(codex.startSession.mock.calls.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), +); + const routing = makeProviderServiceLayer(); it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => @@ -344,13 +545,9 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se const codex = makeFakeCodexAdapter(); const canonicalEvents: ProviderRuntimeEvent[] = []; const canonicalThreadIds: Array = []; - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + }); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -370,6 +567,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); yield* Effect.gen(function* () { @@ -377,7 +575,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se yield* sleep(10); codex.emit({ eventId: asEventId("evt-canonical-thread-segment"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-canonical-thread-segment"), createdAt: new Date().toISOString(), type: "turn.completed", @@ -400,13 +598,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const dbPath = path.join(tempDir, "orchestration.sqlite"); const codex = makeFakeCodexAdapter(); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + }); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( @@ -417,7 +611,8 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: ThreadId.make("thread-stale"), }); }).pipe(Effect.provide(directoryLayer)); @@ -427,6 +622,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); yield* Effect.gen(function* () { @@ -441,7 +637,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const runtime = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") }); + return yield* repository.getByThreadId({ + threadId: asThreadId("thread-stale"), + }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(runtime), true); @@ -471,13 +669,9 @@ it.effect( ); const firstCodex = makeFakeCodexAdapter(); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(firstCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: firstCodex.adapter, + }); const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), @@ -487,6 +681,7 @@ it.effect( Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const updatedResumeCursor = { threadId: asThreadId("thread-1"), @@ -499,7 +694,8 @@ it.effect( const provider = yield* ProviderService; const threadId = asThreadId("thread-1"); const session = yield* provider.startSession(threadId, { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: "/tmp/project", runtimeMode: "full-access", threadId, @@ -515,7 +711,9 @@ it.effect( const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getByThreadId({ threadId: startedSession.threadId }); + return yield* repository.getByThreadId({ + threadId: startedSession.threadId, + }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { @@ -524,13 +722,9 @@ it.effect( } const secondCodex = makeFakeCodexAdapter(); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(secondCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: secondCodex.adapter, + }); const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -539,6 +733,7 @@ it.effect( Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); secondCodex.startSession.mockClear(); @@ -582,7 +777,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project", runtimeMode: "full-access", @@ -667,7 +863,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project", runtimeMode: "full-access", @@ -708,7 +905,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const runtimeRepository = yield* ProviderSessionRuntimeRepository; const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-reap-preserve"), cwd: "/tmp/project-reap-preserve", runtimeMode: "full-access", @@ -758,7 +956,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-claude"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude"), cwd: "/tmp/project-claude", runtimeMode: "full-access", @@ -769,20 +968,57 @@ routing.layer("ProviderServiceLive routing", (it) => { const startInput = routing.claude.startSession.mock.calls[0]?.[0]; assert.equal(typeof startInput === "object" && startInput !== null, true); if (startInput && typeof startInput === "object") { - const startPayload = startInput as { provider?: string; cwd?: string }; + const startPayload = startInput as { + provider?: string; + providerInstanceId?: ProviderInstanceId; + cwd?: string; + }; assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId); assert.equal(startPayload.cwd, "/tmp/project-claude"); } }), ); + it.effect("dies when an active session conflicts with its persisted binding", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-binding-mismatch"); + + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-binding-mismatch", + runtimeMode: "full-access", + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + runtimeMode: "full-access", + }); + + const exit = yield* Effect.exit(provider.listSessions()); + assert.equal(Exit.hasDies(exit), true); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + }), + ); + it.effect("stops stale sessions in other providers after a successful replacement start", () => Effect.gen(function* () { const provider = yield* ProviderService; const threadId = asThreadId("thread-provider-replacement"); const codexSession = yield* provider.startSession(threadId, { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId, cwd: "/tmp/project-provider-replacement", runtimeMode: "full-access", @@ -792,7 +1028,8 @@ routing.layer("ProviderServiceLive routing", (it) => { routing.claude.stopSession.mockClear(); const claudeSession = yield* provider.startSession(threadId, { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId, cwd: "/tmp/project-provider-replacement", runtimeMode: "full-access", @@ -818,7 +1055,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project-send-turn", runtimeMode: "full-access", @@ -858,12 +1096,15 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-send-turn"), cwd: "/tmp/project-claude-send-turn", - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "max" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -892,7 +1133,9 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); assert.deepEqual( startPayload.modelSelection, - createModelSelection("claudeAgent", "claude-opus-4-6", [{ id: "effort", value: "max" }]), + createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + { id: "effort", value: "max" }, + ]), ); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); @@ -906,12 +1149,14 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); yield* provider.startSession(asThreadId("thread-2"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-2"), runtimeMode: "full-access", }); @@ -931,7 +1176,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const threadId = asThreadId("thread-runtime-status"); const session = yield* provider.startSession(threadId, { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId, runtimeMode: "full-access", }); @@ -977,14 +1223,10 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(persistenceLayer), ); - const firstClaude = makeFakeCodexAdapter("claudeAgent"); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "claudeAgent" - ? Effect.succeed(firstClaude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["claudeAgent"]), - }; + const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, + }); const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -993,12 +1235,14 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const initial = yield* Effect.gen(function* () { const provider = yield* ProviderService; return yield* provider.startSession(asThreadId("thread-claude-start"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-start"), cwd: "/tmp/project-claude-start", runtimeMode: "full-access", @@ -1010,14 +1254,10 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* provider.listSessions(); }).pipe(Effect.provide(firstProviderLayer)); - const secondClaude = makeFakeCodexAdapter("claudeAgent"); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "claudeAgent" - ? Effect.succeed(secondClaude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["claudeAgent"]), - }; + const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, + }); const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -1026,6 +1266,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); secondClaude.startSession.mockClear(); @@ -1033,7 +1274,8 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.startSession(initial.threadId, { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: initial.threadId, cwd: "/tmp/project-claude-start", runtimeMode: "full-access", @@ -1071,14 +1313,10 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(persistenceLayer), ); - const firstClaude = makeFakeCodexAdapter("claudeAgent"); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "claudeAgent" - ? Effect.succeed(firstClaude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["claudeAgent"]), - }; + const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, + }); const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -1087,26 +1325,24 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const initial = yield* Effect.gen(function* () { const provider = yield* ProviderService; return yield* provider.startSession(asThreadId("thread-claude-cwd"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-cwd"), cwd: "/tmp/project-claude-cwd", runtimeMode: "full-access", }); }).pipe(Effect.provide(firstProviderLayer)); - const secondClaude = makeFakeCodexAdapter("claudeAgent"); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "claudeAgent" - ? Effect.succeed(secondClaude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["claudeAgent"]), - }; + const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, + }); const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -1115,6 +1351,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); secondClaude.startSession.mockClear(); @@ -1122,7 +1359,8 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.startSession(initial.threadId, { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: initial.threadId, runtimeMode: "full-access", }); @@ -1155,7 +1393,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -1169,7 +1408,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const completedEvent: LegacyProviderRuntimeEvent = { type: "turn.completed", eventId: asEventId("evt-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1186,6 +1425,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { events.some((entry) => entry.type === "turn.completed"), true, ); + assert.equal( + events.some( + (entry) => + entry.type === "turn.completed" && entry.providerInstanceId === codexInstanceId, + ), + true, + ); }), ); @@ -1193,7 +1439,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-seq"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-seq"), runtimeMode: "full-access", }); @@ -1208,7 +1455,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { fanout.codex.emit({ type: "tool.started", eventId: asEventId("evt-seq-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1218,7 +1465,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { fanout.codex.emit({ type: "tool.completed", eventId: asEventId("evt-seq-2"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1228,7 +1475,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { fanout.codex.emit({ type: "turn.completed", eventId: asEventId("evt-seq-3"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1248,7 +1495,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -1273,7 +1521,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { { type: "tool.completed", eventId: asEventId("evt-ordered-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1284,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { { type: "message.delta", eventId: asEventId("evt-ordered-2"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1293,7 +1541,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { { type: "turn.completed", eventId: asEventId("evt-ordered-3"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), threadId: session.threadId, turnId: asTurnId("turn-1"), @@ -1320,7 +1568,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-metrics"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-metrics"), cwd: "/tmp/project", runtimeMode: "full-access", @@ -1349,7 +1598,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "interrupt", outcome: "success", }), @@ -1357,7 +1606,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "approval-response", outcome: "success", }), @@ -1365,7 +1614,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "user-input-response", outcome: "success", }), @@ -1373,7 +1622,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "rollback", outcome: "success", }), @@ -1381,7 +1630,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_sessions_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "stop", outcome: "success", }), @@ -1397,7 +1646,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-send-metrics"), cwd: "/tmp/project-send-metrics", runtimeMode: "full-access", @@ -1413,7 +1663,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "send", outcome: "success", }), @@ -1421,7 +1671,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turn_duration", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "send", }), true, @@ -1432,6 +1682,50 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects session starts without an explicit provider instance id", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + validation.codex.startSession.mockClear(); + const failure = yield* Effect.flip( + provider.startSession(asThreadId("thread-missing-instance-id"), { + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-missing-instance-id"), + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Provider instance id is required for provider 'codex'."); + assert.equal(validation.codex.startSession.mock.calls.length, 0); + }), + ); + + it.effect("rejects mismatched provider kind and provider instance id", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + validation.codex.startSession.mockClear(); + validation.claude.startSession.mockClear(); + const failure = yield* Effect.flip( + provider.startSession(asThreadId("thread-instance-mismatch"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-instance-mismatch"), + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include( + failure.issue, + "Provider instance 'claudeAgent' belongs to driver 'claudeAgent', not 'codex'.", + ); + assert.equal(validation.codex.startSession.mock.calls.length, 0); + assert.equal(validation.claude.startSession.mock.calls.length, 0); + }), + ); + it.effect("returns ProviderValidationError for invalid input payloads", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -1466,7 +1760,7 @@ validation.layer("ProviderServiceLive validation", (it) => { Effect.sync(() => { const now = new Date().toISOString(); return { - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", threadId: input.threadId, runtimeMode: input.runtimeMode, @@ -1478,7 +1772,8 @@ validation.layer("ProviderServiceLive validation", (it) => { ); const session = yield* provider.startSession(asThreadId("thread-missing"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-missing"), cwd: "/tmp/project", runtimeMode: "full-access", diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 94630d3bca9..05b4e72ec18 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,10 +19,12 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + type ProviderInstanceId, + type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import { Effect, Layer, Option, PubSub, Schema, SchemaIssue, Stream } from "effect"; +import { Cause, Effect, Layer, Option, PubSub, Ref, Schema, SchemaIssue, Stream } from "effect"; import { increment, @@ -34,19 +36,24 @@ import { providerTurnMetricAttributes, withMetrics, } from "../../observability/Metrics.ts"; -import { ProviderValidationError } from "../Errors.ts"; +import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, } from "../Services/ProviderSessionDirectory.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +/** + * Hook for tests that want to override the canonical event logger pulled + * from `ProviderEventLoggers`. Production wiring leaves this undefined and + * reads the logger off the tag. + */ export interface ProviderServiceLiveOptions { - readonly canonicalEventLogPath?: string; readonly canonicalEventLogger?: EventNdjsonLogger; } @@ -141,18 +148,53 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +const dieOnMissingBindingInstanceId = ( + operation: string, + payload: { + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly provider?: ProviderDriverKind | undefined; + }, +): ProviderInstanceId => { + if (payload.providerInstanceId !== undefined) { + return payload.providerInstanceId; + } + throw new Error( + payload.provider + ? `${operation}: provider instance id is required for provider '${payload.provider}'.` + : `${operation}: provider instance id is required.`, + ); +}; + +const correlateRuntimeEventWithInstance = ( + source: { + readonly instanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + }, + event: ProviderRuntimeEvent, +): ProviderRuntimeEvent => { + if (event.provider !== source.provider) { + throw new Error( + `ProviderService.streamEvents: provider instance '${source.instanceId}' is backed by driver '${source.provider}' but emitted driver '${event.provider}'.`, + ); + } + if (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) { + throw new Error( + `ProviderService.streamEvents: provider instance '${source.instanceId}' emitted event for instance '${event.providerInstanceId}'.`, + ); + } + return { ...event, providerInstanceId: source.instanceId }; +}; + const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService); - const serverSettings = yield* ServerSettingsService; - const canonicalEventLogger = - options?.canonicalEventLogger ?? - (options?.canonicalEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.canonicalEventLogPath, { - stream: "canonical", - }) - : undefined); + const eventLoggers = yield* ProviderEventLoggers; + // Options-provided logger wins (test overrides); otherwise we take whatever + // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical + // log writer is attached", which downstream code already handles as a + // no-op. + const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; @@ -169,6 +211,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.asVoid, ); + const requireBindingInstanceId = ( + operation: string, + payload: { + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly provider?: ProviderDriverKind | undefined; + }, + ): Effect.Effect => + payload.providerInstanceId !== undefined + ? Effect.succeed(payload.providerInstanceId) + : Effect.fail( + toValidationError( + operation, + payload.provider + ? `Provider instance id is required for provider '${payload.provider}'.` + : "Provider instance id is required.", + ), + ); + const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, @@ -178,38 +238,106 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly lastRuntimeEventAt?: string; }, ) => - directory.upsert({ - threadId, - provider: session.provider, - runtimeMode: session.runtimeMode, - status: toRuntimeStatus(session), - ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session, extra), + Effect.gen(function* () { + const providerInstanceId = yield* requireBindingInstanceId( + "ProviderService.upsertSessionBinding", + session, + ); + yield* directory.upsert({ + threadId, + provider: session.provider, + providerInstanceId, + runtimeMode: session.runtimeMode, + status: toRuntimeStatus(session), + ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), + runtimePayload: toRuntimePayloadFromSession(session, extra), + }); }); - const providers = yield* registry.listProviders(); - const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); - const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - increment(providerRuntimeEventsTotal, { - provider: event.provider, - eventType: event.type, - }).pipe(Effect.andThen(publishRuntimeEvent(event))); + const processRuntimeEvent = ( + source: { + readonly instanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + }, + event: ProviderRuntimeEvent, + ): Effect.Effect => + Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( + Effect.flatMap((canonicalEvent) => + increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + ), + ); + + // `subscribedAdapters` is our source-of-truth for "which instance adapters + // are currently wired into the runtime event bus". It both tracks the set + // of live subscriptions (so `reconcileInstanceSubscriptions` can diff and + // fork only the *new* or *rebuilt* ones) and serves as the dynamic adapter + // list consumed by `stopStaleSessionsForThread`, `listSessions`, and + // `runStopAll` — replacing the pre-Slice-D startup snapshot so hot-added + // instances become visible to those call sites as soon as settings edits + // land. + const subscribedAdapters = yield* Ref.make( + new Map>(), + ); - yield* Effect.forEach(adapters, (adapter) => - Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), - ).pipe(Effect.asVoid); + const getAdapterEntries = Ref.get(subscribedAdapters).pipe( + Effect.map((map) => Array.from(map.entries())), + ); + + // Rebuild the map of id → adapter from the registry and fork a new event + // subscription for every instance that is either brand new or whose adapter + // identity changed (indicating the underlying `ProviderInstance` was torn + // down and rebuilt by `ProviderInstanceRegistry.reconcile`). Orphaned + // fibers for removed/replaced instances exit on their own because their + // adapter's `streamEvents` source terminates when the old scope closes. + const reconcileInstanceSubscriptions = Effect.gen(function* () { + const previous = yield* Ref.get(subscribedAdapters); + const currentIds = yield* registry.listInstances(); + const next = new Map>(); + for (const id of currentIds) { + const adapterOption = yield* registry + .getByInstance(id) + .pipe(Effect.tapError(Effect.logWarning), Effect.option); + if (Option.isNone(adapterOption)) continue; + const adapter = adapterOption.value; + next.set(id, adapter); + if (previous.get(id) !== adapter) { + yield* Stream.runForEach(adapter.streamEvents, (event) => + processRuntimeEvent( + { + instanceId: id, + provider: adapter.provider, + }, + event, + ), + ).pipe(Effect.forkScoped); + } + } + yield* Ref.set(subscribedAdapters, next); + }); + + const instanceChanges = yield* registry.subscribeChanges; + yield* reconcileInstanceSubscriptions; + yield* Stream.runForEach( + Stream.fromSubscription(instanceChanges), + () => reconcileInstanceSubscriptions, + ).pipe(Effect.forkScoped); const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { readonly binding: ProviderRuntimeBinding; readonly operation: string; }) { + const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); yield* Effect.annotateCurrentSpan({ "provider.operation": "recover-session", "provider.kind": input.binding.provider, + "provider.instance_id": bindingInstanceId, "provider.thread_id": input.binding.threadId, }); return yield* Effect.gen(function* () { - const adapter = yield* registry.getByProvider(input.binding.provider); + const adapter = yield* registry.getByInstance(bindingInstanceId); const hasResumeCursor = input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); @@ -219,7 +347,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (session) => session.threadId === input.binding.threadId, ); if (existing) { - yield* upsertSessionBinding(existing, input.binding.threadId); + yield* upsertSessionBinding( + { ...existing, providerInstanceId: bindingInstanceId }, + input.binding.threadId, + ); yield* analytics.record("provider.session.recovered", { provider: existing.provider, strategy: "adopt-existing", @@ -242,6 +373,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, + providerInstanceId: bindingInstanceId, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), @@ -254,7 +386,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - yield* upsertSessionBinding(resumed, input.binding.threadId); + yield* upsertSessionBinding( + { ...resumed, providerInstanceId: bindingInstanceId }, + input.binding.threadId, + ); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, strategy: "resume-thread", @@ -284,29 +419,49 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, ); } - const adapter = yield* registry.getByProvider(binding.provider); + const instanceId = yield* requireBindingInstanceId(input.operation, binding); + const adapter = yield* registry.getByInstance(instanceId); const hasRequestedSession = yield* adapter.hasSession(input.threadId); if (hasRequestedSession) { - return { adapter, threadId: input.threadId, isActive: true } as const; + return { + adapter, + instanceId, + threadId: input.threadId, + isActive: true, + } as const; } if (!input.allowRecovery) { - return { adapter, threadId: input.threadId, isActive: false } as const; + return { + adapter, + instanceId, + threadId: input.threadId, + isActive: false, + } as const; } - const recovered = yield* recoverSessionForThread({ binding, operation: input.operation }); - return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; + const recovered = yield* recoverSessionForThread({ + binding, + operation: input.operation, + }); + return { + adapter: recovered.adapter, + instanceId, + threadId: input.threadId, + isActive: true, + } as const; }); const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { readonly threadId: ThreadId; - readonly currentProvider: ProviderSession["provider"]; + readonly currentInstanceId: ProviderInstanceId; }) { + const currentAdapters = yield* getAdapterEntries; yield* Effect.forEach( - adapters, - (adapter) => - adapter.provider === input.currentProvider + currentAdapters, + ([instanceId, adapter]) => + instanceId === input.currentInstanceId ? Effect.void : Effect.gen(function* () { const hasSession = yield* adapter.hasSession(input.threadId); @@ -341,63 +496,72 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - threadId, - provider: parsed.provider ?? "codex", - }; + const resolvedInstanceId = yield* requireBindingInstanceId( + "ProviderService.startSession", + parsed, + ); + let metricProvider = parsed.provider ?? String(resolvedInstanceId); yield* Effect.annotateCurrentSpan({ "provider.operation": "start-session", - "provider.kind": input.provider, + "provider.instance_id": resolvedInstanceId, "provider.thread_id": threadId, - "provider.runtime_mode": input.runtimeMode, + "provider.runtime_mode": parsed.runtimeMode, }); return yield* Effect.gen(function* () { - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError((error) => - toValidationError( - "ProviderService.startSession", - `Failed to load provider settings: ${error.message}`, - error, - ), - ), - ); - if (!settings.providers[input.provider].enabled) { + const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); + const resolvedProvider = instanceInfo.driverKind; + metricProvider = resolvedProvider; + if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { return yield* toValidationError( "ProviderService.startSession", - `Provider '${input.provider}' is disabled in T3 Code settings.`, + `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, + ); + } + const input = { + ...parsed, + threadId, + provider: resolvedProvider, + }; + if (!instanceInfo.enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); const effectiveResumeCursor = input.resumeCursor ?? - (persistedBinding?.provider === input.provider + (persistedBinding?.providerInstanceId === resolvedInstanceId ? persistedBinding.resumeCursor : undefined); const effectiveCwd = input.cwd ?? - (persistedBinding?.provider === input.provider + (persistedBinding?.providerInstanceId === resolvedInstanceId ? readPersistedCwd(persistedBinding.runtimePayload) : undefined); yield* Effect.annotateCurrentSpan({ + "provider.kind": resolvedProvider, "provider.resume_cursor.source": input.resumeCursor !== undefined ? "request" - : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider + : effectiveResumeCursor !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId ? "persisted" : "none", "provider.resume_cursor.present": effectiveResumeCursor !== undefined, "provider.cwd.source": input.cwd !== undefined ? "request" - : effectiveCwd !== undefined && persistedBinding?.provider === input.provider + : effectiveCwd !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId ? "persisted" : "none", "provider.cwd.effective": effectiveCwd ?? "", }); - const adapter = yield* registry.getByProvider(input.provider); + const adapter = yield* registry.getByInstance(resolvedInstanceId); const session = yield* adapter.startSession({ ...input, + providerInstanceId: resolvedInstanceId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), }); @@ -408,31 +572,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, ); } + const sessionWithInstance = { + ...session, + providerInstanceId: resolvedInstanceId, + }; yield* stopStaleSessionsForThread({ threadId, - currentProvider: adapter.provider, + currentInstanceId: resolvedInstanceId, }); - yield* upsertSessionBinding(session, threadId, { + yield* upsertSessionBinding(sessionWithInstance, threadId, { modelSelection: input.modelSelection, }); yield* analytics.record("provider.session.started", { - provider: session.provider, + provider: sessionWithInstance.provider, runtimeMode: input.runtimeMode, - hasResumeCursor: session.resumeCursor !== undefined, + hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, hasModel: typeof input.modelSelection?.model === "string" && input.modelSelection.model.trim().length > 0, }); - return session; + return sessionWithInstance; }).pipe( withMetrics({ counter: providerSessionsTotal, - attributes: providerMetricAttributes(input.provider, { - operation: "start", - }), + attributes: () => + providerMetricAttributes(metricProvider, { + operation: "start", + }), }), ); }, @@ -479,6 +648,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { @@ -647,6 +817,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, status: "stopped", runtimePayload: { activeTurnId: null, @@ -669,8 +840,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")( function* () { - const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => - adapter.listSessions(), + const currentAdapters = yield* getAdapterEntries; + const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => + adapter.listSessions().pipe( + Effect.map((sessions) => + sessions.map((session) => ({ + ...session, + providerInstanceId: instanceId, + })), + ), + ), ); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); const persistedBindings = yield* directory.listThreadIds().pipe( @@ -694,29 +873,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } } - return activeSessions.map((session) => { + const sessions: ProviderSession[] = []; + for (const session of activeSessions) { const binding = bindingsByThreadId.get(session.threadId); if (!binding) { - return session; + sessions.push(session); + continue; } const overrides: { resumeCursor?: ProviderSession["resumeCursor"]; runtimeMode?: ProviderSession["runtimeMode"]; + providerInstanceId?: ProviderSession["providerInstanceId"]; } = {}; + overrides.providerInstanceId = dieOnMissingBindingInstanceId( + "ProviderService.listSessions", + binding, + ); + if (binding.provider !== session.provider) { + return yield* Effect.die( + new Error( + `ProviderService.listSessions: thread '${session.threadId}' is active on provider '${session.provider}' but persisted binding names provider '${binding.provider}'.`, + ), + ); + } + if (overrides.providerInstanceId !== session.providerInstanceId) { + return yield* Effect.die( + new Error( + `ProviderService.listSessions: thread '${session.threadId}' is active on provider instance '${session.providerInstanceId}' but persisted binding names '${overrides.providerInstanceId}'.`, + ), + ); + } if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { overrides.resumeCursor = binding.resumeCursor; } if (binding.runtimeMode !== undefined) { overrides.runtimeMode = binding.runtimeMode; } - return Object.assign({}, session, overrides); - }); + sessions.push(Object.assign({}, session, overrides)); + } + return sessions; }, ); - const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => - registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); + const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) => + registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); + + const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) => + registry.getInstanceInfo(instanceId); const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn( "rollbackConversation", @@ -761,8 +965,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); - const activeSessions = yield* Effect.forEach(adapters, (adapter) => - adapter.listSessions(), + const currentAdapters = yield* getAdapterEntries; + const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => + adapter.listSessions().pipe( + Effect.map((sessions) => + sessions.map((session) => ({ + ...session, + providerInstanceId: instanceId, + })), + ), + ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => upsertSessionBinding(session, session.threadId, { @@ -770,23 +982,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( lastRuntimeEventAt: new Date().toISOString(), }), ).pipe(Effect.asVoid); - yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); - yield* Effect.forEach(threadIds, (threadId) => - directory.getProvider(threadId).pipe( - Effect.flatMap((provider) => - directory.upsert({ - threadId, - provider, - status: "stopped", - runtimePayload: { - activeTurnId: null, - lastRuntimeEvent: "provider.stopAll", - lastRuntimeEventAt: new Date().toISOString(), - }, - }), - ), - ), - ).pipe(Effect.asVoid); + yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(bindings, (binding) => { + const providerInstanceId = dieOnMissingBindingInstanceId("ProviderService.stopAll", binding); + return directory.upsert({ + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt: new Date().toISOString(), + }, + }); + }).pipe(Effect.asVoid); yield* analytics.record("provider.sessions.stopped_all", { sessionCount: threadIds.length, }); @@ -794,8 +1005,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* Effect.addFinalizer(() => - Effect.catch(runStopAll(), (cause) => - Effect.logWarning("failed to stop provider service", { cause }), + runStopAll().pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), + ), ), ); @@ -808,6 +1021,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( stopSession, listSessions, getCapabilities, + getInstanceInfo, rollbackConversation, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 35bdec1e37d..28a168c43ad 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; @@ -38,7 +38,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const initialThreadId = ThreadId.make("thread-1"); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: initialThreadId, }); @@ -47,7 +47,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const resolvedBinding = yield* directory.getBinding(initialThreadId); assertSome(resolvedBinding, { threadId: initialThreadId, - provider: "codex", + provider: ProviderDriverKind.make("codex"), }); if (Option.isSome(resolvedBinding)) { assert.equal(resolvedBinding.value.threadId, initialThreadId); @@ -56,7 +56,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const nextThreadId = ThreadId.make("thread-2"); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -85,7 +85,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadId = ThreadId.make("thread-runtime"); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, status: "starting", resumeCursor: { @@ -98,7 +98,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, status: "running", runtimePayload: { @@ -133,6 +133,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* runtimeRepository.upsert({ threadId: newerThreadId, providerName: "codex", + providerInstanceId: null, adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -148,6 +149,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* runtimeRepository.upsert({ threadId: olderThreadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -165,7 +167,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.deepEqual(bindings, [ { threadId: olderThreadId, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -179,7 +181,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, { threadId: newerThreadId, - provider: "codex", + provider: ProviderDriverKind.make("codex"), adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -203,6 +205,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* runtimeRepository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -212,7 +215,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, }); @@ -235,7 +238,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -249,7 +252,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const resolvedBinding = yield* directory.getBinding(threadId); assertSome(resolvedBinding, { threadId, - provider: "codex", + provider: ProviderDriverKind.make("codex"), }); if (Option.isSome(resolvedBinding)) { assert.equal(resolvedBinding.value.threadId, threadId); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 4cb7147180a..e1479082f2f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,4 +1,4 @@ -import { ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option, Schema } from "effect"; import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; @@ -20,11 +20,11 @@ function toPersistenceError(operation: string) { }); } -function decodeProviderKind( +function decodeProviderDriverKind( providerName: string, operation: string, -): Effect.Effect { - return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe( +): Effect.Effect { + return Schema.decodeUnknownEffect(ProviderDriverKind)(providerName).pipe( Effect.mapError( (cause) => new ProviderSessionDirectoryPersistenceError({ @@ -57,12 +57,17 @@ function toRuntimeBinding( runtime: ProviderSessionRuntime, operation: string, ): Effect.Effect { - return decodeProviderKind(runtime.providerName, operation).pipe( + return decodeProviderDriverKind(runtime.providerName, operation).pipe( Effect.map( (provider) => ({ threadId: runtime.threadId, provider, + // Migration boundary only: rows written before the instance split + // have a null provider_instance_id. Promote them as they leave + // persistence so hot routing code never has to infer an instance + // from a driver kind. + providerInstanceId: runtime.providerInstanceId ?? defaultInstanceIdForDriver(provider), adapterKey: runtime.adapterKey, runtimeMode: runtime.runtimeMode, status: runtime.status, @@ -108,10 +113,19 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const now = new Date().toISOString(); const providerChanged = existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; + const providerInstanceId = + binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); + if (providerInstanceId === null || providerInstanceId === undefined) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "providerInstanceId is required for provider session runtime bindings.", + }); + } yield* repository .upsert({ threadId: resolvedThreadId, providerName: binding.provider, + providerInstanceId, adapterKey: binding.adapterKey ?? (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 45199a02b2a..91e1a9aef97 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + TurnId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -17,7 +23,7 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; const defaultModelSelection = { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; @@ -140,6 +146,19 @@ describe("ProviderSessionReaper", () => { stopSession, listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => { + const driverKind = ProviderDriverKind.make(String(instanceId)); + return Effect.succeed({ + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: `${driverKind}:instance:${instanceId}`, + }, + }); + }, rollbackConversation: () => unsupported(), streamEvents: Stream.empty, }; @@ -197,6 +216,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -244,6 +264,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -290,6 +311,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -336,6 +358,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "stopped", @@ -404,6 +427,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: failedThreadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -418,6 +442,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: reapedThreadId, providerName: "codex", + providerInstanceId: null, adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -483,6 +508,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: defectThreadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -497,6 +523,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: reapedThreadId, providerName: "codex", + providerInstanceId: null, adapterKey: "codex", runtimeMode: "full-access", status: "running", diff --git a/apps/server/src/provider/Layers/scopedSafeTeardown.test.ts b/apps/server/src/provider/Layers/scopedSafeTeardown.test.ts new file mode 100644 index 00000000000..ebbc379b7db --- /dev/null +++ b/apps/server/src/provider/Layers/scopedSafeTeardown.test.ts @@ -0,0 +1,94 @@ +import { it } from "@effect/vitest"; +import { Cause, Effect, Exit } from "effect"; +import { describe, expect } from "vitest"; + +import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; + +describe("scopedSafeTeardown", () => { + it.effect("returns the body's value when teardown is clean", () => + Effect.gen(function* () { + const finalizers: string[] = []; + const wrapped = Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + finalizers.push("clean"); + }), + ); + return "body-ok"; + }).pipe(scopedSafeTeardown("test")); + + const value = yield* wrapped; + expect(value).toBe("body-ok"); + expect(finalizers).toEqual(["clean"]); + }), + ); + + it.effect("preserves body success when a finalizer dies", () => + // The production failure mode: `Layer.build(...)` registers a finalizer + // that kills a subprocess; if the kill fails, the defect would otherwise + // override a successful probe body. + Effect.gen(function* () { + const finalizers: string[] = []; + const wrapped = Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + finalizers.push("ran-before-die"); + }), + ); + yield* Effect.addFinalizer(() => + Effect.die(new Error("simulated subprocess kill failure")), + ); + return "body-ok"; + }).pipe(scopedSafeTeardown("test")); + + const value = yield* wrapped; + expect(value).toBe("body-ok"); + // The clean finalizer still ran; teardown defect was logged + swallowed. + expect(finalizers).toEqual(["ran-before-die"]); + }), + ); + + it.effect("preserves typed body failures even when teardown is clean", () => + Effect.gen(function* () { + class BodyError { + readonly _tag = "BodyError" as const; + } + const wrapped = Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.void); + return yield* Effect.fail(new BodyError()); + }).pipe(scopedSafeTeardown("test")); + + const exit = yield* Effect.exit(wrapped); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Body's typed failure should surface, not a defect. + const squashed = Cause.squash(exit.cause); + expect(squashed).toBeInstanceOf(BodyError); + } + }), + ); + + it.effect("prefers the body's typed failure over a teardown defect", () => + // Even when both the body fails AND teardown defects, the body's typed + // failure is what callers see. This matters because `Effect.result` / + // `.pipe(Effect.exit)` in callers expects a typed Failure, not a Die. + Effect.gen(function* () { + class BodyError { + readonly _tag = "BodyError" as const; + } + const wrapped = Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.die(new Error("simulated subprocess kill failure")), + ); + return yield* Effect.fail(new BodyError()); + }).pipe(scopedSafeTeardown("test")); + + const exit = yield* Effect.exit(wrapped); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const squashed = Cause.squash(exit.cause); + expect(squashed).toBeInstanceOf(BodyError); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/scopedSafeTeardown.ts b/apps/server/src/provider/Layers/scopedSafeTeardown.ts new file mode 100644 index 00000000000..688374590e7 --- /dev/null +++ b/apps/server/src/provider/Layers/scopedSafeTeardown.ts @@ -0,0 +1,61 @@ +/** + * scopedSafeTeardown — run a scope-requiring effect so that finalizer + * failures during scope close cannot override the body's Exit. + * + * Motivation + * ---------- + * The obvious pattern is `body.pipe(Effect.scoped)`: provide a fresh + * Scope, run the body, close the scope with the body's Exit. If a + * finalizer (e.g. `ChildProcess.kill` from an effect-codex-app-server + * spawn) dies during that close, the combined Exit becomes the + * finalizer's defect — even when the body already succeeded. + * + * Concretely this bit us in the Codex provider probe: a successful + * `initialize` → `account/read` → `skills/list` → `model/list` + * round-trip produced a `CodexAppServerProviderSnapshot`, but the + * `Layer.build(CodexClient.layerCommand(...))` finalizer then failed to + * kill the `codex app-server` subprocess with a `PlatformError`. The + * defect bubbled past `Effect.result` in `checkCodexProviderStatus`, + * died `refreshOneSource`, and `providersRef` never saw the snapshot. + * + * Strategy + * -------- + * 1. Make a fresh scope manually. + * 2. Run the body against that scope, capturing its Exit via + * `Effect.exit`. + * 3. Close the scope, catching any cause (typed failure *or* defect) + * with a log. + * 4. Replay the captured Exit so typed body failures still surface and + * successes still return their value. + * + * The helper deliberately logs teardown causes at `Warning` level — + * silently swallowing them is dangerous because they usually indicate a + * real bug in a downstream Layer's finalizer. + * + * @module provider/Layers/scopedSafeTeardown + */ +import { Effect, Exit, Scope } from "effect"; + +/** + * Run `effect` with a freshly made `Scope.Scope`, guaranteeing that + * teardown failures cannot override the body's Exit. + * + * Shape matches `Effect.scoped`: takes an effect whose env includes + * `Scope.Scope`, returns one whose env excludes it. + * + * @param label Short label for the warning log emitted when teardown + * fails. Use something like `"codex-probe"`. + */ +export const scopedSafeTeardown = + (label: string) => + (effect: Effect.Effect): Effect.Effect> => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const bodyExit = yield* effect.pipe(Effect.provideService(Scope.Scope, scope), Effect.exit); + yield* Scope.close(scope, Exit.void).pipe( + Effect.catchCause((cause) => + Effect.logWarning(`${label} teardown errored; preserving body result`, cause), + ), + ); + return yield* bodyExit; + }) as Effect.Effect>; diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts new file mode 100644 index 00000000000..bedfd61194e --- /dev/null +++ b/apps/server/src/provider/ProviderDriver.ts @@ -0,0 +1,167 @@ +/** + * ProviderDriver / ProviderInstance — driver SPI as plain values. + * + * `ProviderDriver` is a record, not a Context.Service. The thing it produces + * (`ProviderInstance`) is also a record — three captured closures + * (`snapshot`, `adapter`, `textGeneration`), an id, and a driver kind. There + * are intentionally no per-driver Context tags because tags are + * singleton-per-runtime and we need many instances of the same driver. + * + * The only Effect service involved is `ProviderInstanceRegistry`, which + * owns the live `Map` and is itself a + * singleton. + * + * Driver factories are functions of `(typed config, env)` where: + * - `typed config` is decoded once by the registry via `configSchema`, + * so drivers never deal with raw `unknown`. + * - `env` flows through Effect's R channel. Each driver declares the + * subset of infrastructure services it needs (FileSystem, + * ChildProcessSpawner, …) on its `create` return type; the registry + * layer's R is the union of those, and the runtime layer satisfies it. + * + * @module provider/ProviderDriver + */ +import type { + ProviderDriverKind, + ProviderInstanceEnvironment, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type { Effect, Schema, Scope } from "effect"; + +import type { TextGenerationShape } from "../git/Services/TextGeneration.ts"; +import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; +import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; + +/** + * Static metadata advertised by a driver. Used for default presentation + * and (later) settings UI. Doesn't need to be Effect-typed because nothing + * about it is dynamic — drivers are registered at startup. + */ +export interface ProviderDriverMetadata { + /** Human-readable name for the driver itself (e.g. "Codex"). */ + readonly displayName: string; + /** + * Whether the driver may be instantiated more than once concurrently. + * Defaults to `true`. Set to `false` for drivers that wrap a global + * resource (e.g. a single desktop app socket) — the registry then + * rejects multi-instance configurations with a clear error. + */ + readonly supportsMultipleInstances?: boolean; +} + +/** + * One materialized provider instance. Held by the registry, looked up by + * `instanceId`, torn down by closing the scope it was created in. + * + * The three "shape" fields are captured closures owned by this instance — + * stopping one instance cannot affect another, and starting a second + * instance of the same driver does not reach into the first instance's + * state. + */ +export interface ProviderInstance { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly continuationIdentity: ProviderContinuationIdentity; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly enabled: boolean; + readonly snapshot: ServerProviderShape; + readonly adapter: ProviderAdapterShape; + readonly textGeneration: TextGenerationShape; +} + +export interface ProviderContinuationIdentity { + readonly driverKind: ProviderDriverKind; + readonly continuationKey: string; +} + +export function defaultProviderContinuationIdentity(input: { + readonly driverKind: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; +}): ProviderContinuationIdentity { + return { + driverKind: input.driverKind, + continuationKey: `${input.driverKind}:instance:${input.instanceId}`, + }; +} + +/** + * Inputs the registry passes to a driver's `create` function. + * + * `config` is the typed payload — already decoded by the registry through + * `driver.configSchema`. Drivers never decode their own raw envelope. + */ +export interface ProviderDriverCreateInput { + readonly instanceId: ProviderInstanceId; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly environment: ProviderInstanceEnvironment; + readonly enabled: boolean; + readonly config: Config; +} + +/** + * Driver SPI — registered as a plain value, not a Layer. + * + * `Config` is whatever the driver decoded from + * `ProviderInstanceConfig.config`. `R` is the union of infrastructure + * services the driver depends on; the registry layer aggregates `R` across + * all registered drivers and the runtime supplies them. + * + * `create` is responsible for *all* per-instance state — process handles, + * pubsub topics, refs, file watchers — and must release them when its + * scope closes. Two calls to `create` with different `instanceId` / + * `config` MUST yield instances with no shared mutable state. + */ +export interface ProviderDriver { + readonly driverKind: ProviderDriverKind; + readonly metadata: ProviderDriverMetadata; + /** + * Decoder for the opaque `ProviderInstanceConfig.config` envelope. The + * registry runs this exactly once per (re)load of an instance; a decode + * failure is surfaced as `ProviderDriverError` and downgraded to an + * unavailable shadow snapshot. + * + * The `Encoded` parameter is intentionally left as `unknown` (not + * `Config`) so schemas with `withDecodingDefault` / transformations — where + * the encoded shape differs from the decoded shape — satisfy the SPI + * without casts. The registry only ever decodes `unknown` envelopes here, + * so the precise encoded type is irrelevant at this boundary. + * + * Using `Codec` rather than `Schema` pins `DecodingServices = never` — if + * we used `Schema`, the erased `any` in `AnyProviderDriver` would + * widen `DecodingServices` to `unknown` and poison the R channel of every + * caller of `decodeUnknownEffect`. + */ + readonly configSchema: Schema.Codec; + /** + * Default config payload used when the legacy + * `ServerSettings.providers.` entry is empty or when the driver + * is auto-bootstrapped without user configuration. Returning a typed + * default keeps the migration path simple — no special-casing needed + * to construct a "blank" instance. + */ + readonly defaultConfig: () => Config; + /** + * Materialize one instance. The returned effect runs in a scope owned + * by the registry; closing that scope releases every resource the + * driver opened. Failures become unavailable shadow snapshots — the + * driver MUST NOT throw defects. + */ + readonly create: ( + input: ProviderDriverCreateInput, + ) => Effect.Effect; +} + +/** + * Heterogeneous-array convenience: the registry stores drivers as + * `ReadonlyArray>` where `R` is the union of all + * registered drivers' env requirements. + */ +// `any` here intentionally erases the per-driver Config; the registry +// already decoded it before invoking `create`, so downstream code never +// needs the original `Config` type. Using `unknown` instead would force +// `create` callers into casts since `unknown` is not assignable to a +// concrete `Config` from inside the driver body. +export type AnyProviderDriver = ProviderDriver; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts new file mode 100644 index 00000000000..f37b328b150 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; + +describe("mergeProviderInstanceEnvironment", () => { + it("overrides inherited environment values and preserves empty strings", () => { + expect( + mergeProviderInstanceEnvironment( + [ + { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, + { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, + ], + { ANTHROPIC_API_KEY: "inherited", PATH: "/bin" }, + ), + ).toMatchObject({ + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "", + PATH: "/bin", + }); + }); +}); diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts new file mode 100644 index 00000000000..e469253604e --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -0,0 +1,16 @@ +import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; + +export function mergeProviderInstanceEnvironment( + environment: ProviderInstanceEnvironment | undefined, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + if (!environment || environment.length === 0) { + return baseEnv; + } + + const next: NodeJS.ProcessEnv = { ...baseEnv }; + for (const variable of environment) { + next[variable.name] = variable.value; + } + return next; +} diff --git a/apps/server/src/provider/Services/ClaudeAdapter.ts b/apps/server/src/provider/Services/ClaudeAdapter.ts index e8c33bd8e40..ed9bd7081bc 100644 --- a/apps/server/src/provider/Services/ClaudeAdapter.ts +++ b/apps/server/src/provider/Services/ClaudeAdapter.ts @@ -1,30 +1,19 @@ /** - * ClaudeAdapter - Claude Agent implementation of the generic provider adapter contract. + * ClaudeAdapter — shape type for the Claude provider adapter. * - * This service owns Claude runtime/session semantics and emits canonical - * provider runtime events. It does not perform cross-provider routing, shared - * event fan-out, or checkpoint orchestration. - * - * Uses Effect `Context.Service` for dependency injection and returns the - * shared provider-adapter error channel with `provider: "claudeAgent"` context. + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/ClaudeDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. * * @module ClaudeAdapter */ -import { Context } from "effect"; - import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; /** - * ClaudeAdapterShape - Service API for the Claude Agent provider adapter. - */ -export interface ClaudeAdapterShape extends ProviderAdapterShape { - readonly provider: "claudeAgent"; -} - -/** - * ClaudeAdapter - Service tag for Claude Agent provider adapter operations. + * ClaudeAdapterShape — per-instance Claude adapter contract. Carries + * a branded driver kind as the nominal discriminant. */ -export class ClaudeAdapter extends Context.Service()( - "t3/provider/Services/ClaudeAdapter", -) {} +export interface ClaudeAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts deleted file mode 100644 index 7e21ac56d9e..00000000000 --- a/apps/server/src/provider/Services/ClaudeProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider.ts"; - -export interface ClaudeProviderShape extends ServerProviderShape {} - -export class ClaudeProvider extends Context.Service()( - "t3/provider/Services/ClaudeProvider", -) {} diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index e7a5508c9c7..33fe0fa12be 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -1,30 +1,19 @@ /** - * CodexAdapter - Codex implementation of the generic provider adapter contract. + * CodexAdapter — shape type for the Codex provider adapter. * - * This service owns Codex app-server process / JSON-RPC semantics and emits - * Codex provider events. It does not perform cross-provider routing, shared - * event fan-out, or checkpoint orchestration. - * - * Uses Effect `Context.Service` for dependency injection and returns the - * shared provider-adapter error channel with `provider: "codex"` context. + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/CodexDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. * * @module CodexAdapter */ -import { Context } from "effect"; - import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; /** - * CodexAdapterShape - Service API for the Codex provider adapter. - */ -export interface CodexAdapterShape extends ProviderAdapterShape { - readonly provider: "codex"; -} - -/** - * CodexAdapter - Service tag for Codex provider adapter operations. + * CodexAdapterShape — per-instance Codex adapter contract. Carries + * a branded driver kind as the nominal discriminant. */ -export class CodexAdapter extends Context.Service()( - "t3/provider/Services/CodexAdapter", -) {} +export interface CodexAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts deleted file mode 100644 index e116f1a761b..00000000000 --- a/apps/server/src/provider/Services/CodexProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider.ts"; - -export interface CodexProviderShape extends ServerProviderShape {} - -export class CodexProvider extends Context.Service()( - "t3/provider/Services/CodexProvider", -) {} diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts index f1edb316198..83581f0a454 100644 --- a/apps/server/src/provider/Services/CursorAdapter.ts +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -1,12 +1,19 @@ -import { Context } from "effect"; - +/** + * CursorAdapter — shape type for the Cursor provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/CursorDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module CursorAdapter + */ import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; -export interface CursorAdapterShape extends ProviderAdapterShape { - readonly provider: "cursor"; -} - -export class CursorAdapter extends Context.Service()( - "t3/provider/Services/CursorAdapter", -) {} +/** + * CursorAdapterShape — per-instance Cursor adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface CursorAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/CursorProvider.ts b/apps/server/src/provider/Services/CursorProvider.ts deleted file mode 100644 index aa70994f5e9..00000000000 --- a/apps/server/src/provider/Services/CursorProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider.ts"; - -export interface CursorProviderShape extends ServerProviderShape {} - -export class CursorProvider extends Context.Service()( - "t3/provider/Services/CursorProvider", -) {} diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts index ad5660022bf..e3ad97904d1 100644 --- a/apps/server/src/provider/Services/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -1,12 +1,19 @@ -import { Context } from "effect"; - +/** + * OpenCodeAdapter — shape type for the OpenCode provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/OpenCodeDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module OpenCodeAdapter + */ import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; -export interface OpenCodeAdapterShape extends ProviderAdapterShape { - readonly provider: "opencode"; -} - -export class OpenCodeAdapter extends Context.Service()( - "t3/provider/Services/OpenCodeAdapter", -) {} +/** + * OpenCodeAdapterShape — per-instance OpenCode adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface OpenCodeAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/OpenCodeProvider.ts b/apps/server/src/provider/Services/OpenCodeProvider.ts deleted file mode 100644 index a799830eec4..00000000000 --- a/apps/server/src/provider/Services/OpenCodeProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider.ts"; - -export interface OpenCodeProviderShape extends ServerProviderShape {} - -export class OpenCodeProvider extends Context.Service()( - "t3/provider/Services/OpenCodeProvider", -) {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 91155764f1c..dd1be738721 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -10,7 +10,7 @@ import type { ApprovalRequestId, ProviderApprovalDecision, - ProviderKind, + ProviderDriverKind, ProviderUserInputAnswers, ProviderRuntimeEvent, ProviderSendTurnInput, @@ -46,7 +46,7 @@ export interface ProviderAdapterShape { /** * Provider kind implemented by this adapter. */ - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly capabilities: ProviderAdapterCapabilities; /** diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts index b8e9d4b21c3..1161487d5a3 100644 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -1,34 +1,91 @@ /** * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. * - * Maps a provider kind to the concrete adapter service (Codex, Claude, etc). - * It does not own session lifecycle or routing rules; `ProviderService` uses - * this registry together with `ProviderSessionDirectory`. + * Maps a `ProviderInstanceId` (the new per-instance routing key) or a + * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete + * adapter service (Codex, Claude, etc). It does not own session lifecycle + * or routing rules; `ProviderService` uses this registry together with + * `ProviderSessionDirectory`. + * + * During the driver/instance migration this tag exposes both flavours: + * + * - `getByInstance` / `listInstances` — new per-instance routing. Callers + * that already know an `instanceId` (threads, sessions, events) + * should prefer these. + * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D + * behaviour. New code should not grow additional callers of the kind-keyed + * methods; they exist so the settings UI, WS refresh RPC, and a handful + * of legacy persisted rows can still be routed during the rollout. * * @module ProviderAdapterRegistry */ -import type { ProviderKind } from "@t3tools/contracts"; +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { Context } from "effect"; -import type { Effect } from "effect"; +import type { Effect, PubSub, Scope, Stream } from "effect"; import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; +import type { ProviderContinuationIdentity } from "../ProviderDriver.ts"; + +export interface ProviderInstanceRoutingInfo { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly enabled: boolean; + readonly continuationIdentity: ProviderContinuationIdentity; +} /** - * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind. + * ProviderAdapterRegistryShape - Service API for adapter lookup. */ export interface ProviderAdapterRegistryShape { /** - * Resolve the adapter for a provider kind. + * Resolve the adapter for a specific instance id. Returns + * `ProviderUnsupportedError` if no such instance is currently registered + * (which covers "never configured" *and* "configured but the driver is + * unavailable in this build" — both surface the same failure to callers + * that expect a working adapter). */ - readonly getByProvider: ( - provider: ProviderKind, + readonly getByInstance: ( + instanceId: ProviderInstanceId, ) => Effect.Effect, ProviderUnsupportedError>; + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** + * List all live instance ids. Excludes unavailable/shadow instances — + * callers of this method want something they can pass to `getByInstance`. + */ + readonly listInstances: () => Effect.Effect>; + /** - * List provider kinds currently registered. + * Legacy: list provider kinds whose default instance is currently + * registered. + * + * @deprecated Prefer `listInstances`. Retained for migration-era call + * sites that iterate providers to build UI/metrics. */ - readonly listProviders: () => Effect.Effect>; + readonly listProviders: () => Effect.Effect>; + + /** + * Change notification stream mirroring `ProviderInstanceRegistry.streamChanges`. + * Emits one `void` tick whenever the set of live instances changes + * (instance added, removed, or rebuilt after a settings edit). Consumers + * that fan out `adapter.streamEvents` per instance — e.g. `ProviderService`'s + * runtime event bus — re-pull `listInstances` on each tick and fork new + * subscriptions for instances they haven't seen yet. + */ + readonly streamChanges: Stream.Stream; + + /** + * Acquire a change subscription synchronously in the caller's current fiber. + * Consumers that must avoid missing a publish between initial reconciliation + * and watcher startup should use this, then fork `Stream.fromSubscription`. + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } /** @@ -38,5 +95,3 @@ export class ProviderAdapterRegistry extends Context.Service< ProviderAdapterRegistry, ProviderAdapterRegistryShape >()("t3/provider/Services/ProviderAdapterRegistry") {} - -// Dummy comment for workflow testing. diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistry.ts b/apps/server/src/provider/Services/ProviderInstanceRegistry.ts new file mode 100644 index 00000000000..f642475a243 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderInstanceRegistry.ts @@ -0,0 +1,84 @@ +/** + * ProviderInstanceRegistry — the single Effect service in the new model. + * + * Owns a `Map` produced by running + * registered driver factories against `ServerSettings.providerInstances`. + * The registry watches settings; when an instance's config changes (or + * the entry disappears), the registry tears down the affected instance's + * scope and rebuilds — that's the entire hot-reload story. + * + * What rest-of-server reads from here: + * - `getInstance(instanceId)` — for routing turn/session calls. + * - `listInstances` — for snapshot aggregation in `ProviderRegistry`. + * - `listUnavailable` — `ServerProvider` shadows for instances whose + * driver is not registered in this build (rollback / fork tolerance). + * - `streamChanges` — coalesced "registry mutated" pings so consumers + * can re-pull lists or re-broadcast. + * + * @module provider/Services/ProviderInstanceRegistry + */ +import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect, PubSub, Scope, Stream } from "effect"; + +import type { ProviderInstance } from "../ProviderDriver.ts"; + +export interface ProviderInstanceRegistryShape { + /** + * Look up one instance by id. Returns `undefined` (not Option) when the + * id is unknown — callers branch on falsy and emit + * `ProviderInstanceNotFoundError`. + */ + readonly getInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + /** + * Every available (driver-registered, successfully created) instance, + * in stable settings-author order. + */ + readonly listInstances: Effect.Effect>; + /** + * Wire-shape shadow snapshots for instances whose driver is unknown to + * this build (or whose config failed to decode). Suitable for merging + * directly into `ProviderRegistry` output. + */ + readonly listUnavailable: Effect.Effect>; + /** + * Push notification stream emitted whenever the registry's contents + * change — instance added, removed, or rebuilt. The payload is `void` + * because consumers always want to re-pull `listInstances` / + * `listUnavailable` together. + * + * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the + * stream starts running, forking a consumer via + * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next + * publish — the forked fiber may not have subscribed yet when the + * publish lands. Hot-reload consumers that must not miss a publish + * should use `subscribeChanges` below instead, which acquires the + * subscription synchronously in the caller's fiber before the consumer + * loop is forked. + */ + readonly streamChanges: Stream.Stream; + /** + * Acquire a subscription to the registry's change channel synchronously + * in the caller's fiber. Returns a `PubSub.Subscription` whose + * lifetime is scoped to the provided `Scope` (the subscription is + * released when the scope closes). Consumers typically `yield*` this + * in the same fiber that forks their consumer loop, then drain with + * `PubSub.take(subscription)` inside `Effect.forever`. Because the + * subscription is registered with the PubSub before this `yield*` + * returns, no subsequent publish can land in a gap. + * + * This exists because the `ProviderInstanceRegistry` publishes on a + * PubSub and `Stream.fromPubSub` defers subscription until the stream + * starts executing — a consumer that `forkScoped`s the stream + * consumption can miss a publish that lands in the narrow window + * between "fiber scheduled" and "fiber starts running". + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; +} + +export class ProviderInstanceRegistry extends Context.Service< + ProviderInstanceRegistry, + ProviderInstanceRegistryShape +>()("t3/provider/Services/ProviderInstanceRegistry") {} diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts new file mode 100644 index 00000000000..ff861f961c7 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts @@ -0,0 +1,52 @@ +/** + * ProviderInstanceRegistryMutator — internal handle used by the hydration + * layer to reconcile the live registry with a fresh + * `ProviderInstanceConfigMap`. + * + * Kept separate from the public `ProviderInstanceRegistry` service tag so + * downstream consumers (drivers, reactors, `ProviderService`) can only read + * from the registry. Only the hydration layer — which watches + * `ServerSettingsService.streamChanges` and applies diffs — imports this + * tag. + * + * The mutator exposes a single entry point, `reconcile(configMap)`, which: + * + * 1. Diffs the incoming map against the live one keyed by instance id. + * 2. Closes the per-instance `Scope` of every removed or replaced entry + * (tearing down adapter processes, refresh fibres, temp files) BEFORE + * creating the replacement — `reconcile` guarantees "at most one live + * instance per id" at all times. + * 3. Opens a fresh child `Scope` for every added or replaced entry, runs + * the driver's `create`, and stores the resulting `ProviderInstance` + * plus its scope. + * 4. Publishes one `void` tick on the registry's `streamChanges` PubSub at + * the end of the batch — consumers re-pull `listInstances` / + * `listUnavailable`. + * + * `reconcile` is idempotent: calling it with an unchanged config map is a + * no-op (no scope churn, no pubsub emission). + * + * @module provider/Services/ProviderInstanceRegistryMutator + */ +import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface ProviderInstanceRegistryMutatorShape { + /** + * Bring the live registry in line with the supplied config map. See + * module docs for the add / remove / replace semantics. + * + * The effect never fails: individual driver `create` failures are + * captured as "unavailable" shadow snapshots inside the registry, the + * same way boot-time failures are handled by + * `makeProviderInstanceRegistry`. This keeps settings-watcher loops from + * erroring out on a single bad entry. + */ + readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; +} + +export class ProviderInstanceRegistryMutator extends Context.Service< + ProviderInstanceRegistryMutator, + ProviderInstanceRegistryMutatorShape +>()("t3/provider/Services/ProviderInstanceRegistryMutator") {} diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 2e04fa253b0..13a87bd873b 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -6,23 +6,42 @@ * * @module ProviderRegistry */ -import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect, Stream } from "effect"; export interface ProviderRegistryShape { /** - * Read the latest provider snapshots. + * Read the latest provider snapshots for every configured instance. + * Multiple snapshots may share the same `provider` kind (multiple + * instances of the same driver) and disambiguate via `instanceId`. */ readonly getProviders: Effect.Effect>; /** - * Refresh all providers, or a single provider when specified. + * Refresh all providers, or the default instance of the specified + * kind when supplied. + * + * Retained for back-compat with legacy call sites (WS refresh RPC, + * orchestration metrics). New code should prefer `refreshInstance`. + * + * @deprecated prefer `refreshInstance` for new call sites. */ - readonly refresh: (provider?: ProviderKind) => Effect.Effect>; + readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect>; /** - * Stream of provider snapshot updates. + * Refresh the specific configured instance. Returns the updated snapshot + * list. When the instance id is unknown the call resolves with the + * currently cached list (no error) — matching the legacy `refresh` shim + * behaviour so transport layers don't have to special-case unknowns. + */ + readonly refreshInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + + /** + * Stream of provider snapshot updates — one emission per aggregated + * change. The array contains the full current state. */ readonly streamChanges: Stream.Stream>; } diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 1e461fcd1c6..17a64689b49 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -13,7 +13,7 @@ */ import type { ProviderInterruptTurnInput, - ProviderKind, + ProviderInstanceId, ProviderRespondToRequestInput, ProviderRespondToUserInputInput, ProviderRuntimeEvent, @@ -29,6 +29,7 @@ import type { Effect, Stream } from "effect"; import type { ProviderServiceError } from "../Errors.ts"; import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; +import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; /** * ProviderServiceShape - Service API for provider session and turn orchestration. @@ -85,12 +86,16 @@ export interface ProviderServiceShape { readonly listSessions: () => Effect.Effect>; /** - * Read static capabilities for a provider adapter. + * Read capabilities for the adapter bound to a configured provider instance. */ readonly getCapabilities: ( - provider: ProviderKind, + instanceId: ProviderInstanceId, ) => Effect.Effect; + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + /** * Roll back provider conversation state by a number of turns. */ diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index bee7a1b3736..99ffb800f90 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,5 +1,6 @@ import type { - ProviderKind, + ProviderInstanceId, + ProviderDriverKind, ProviderSessionRuntimeStatus, RuntimeMode, ThreadId, @@ -14,7 +15,13 @@ import type { export interface ProviderRuntimeBinding { readonly threadId: ThreadId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; + /** + * Routing key for the configured provider instance that owns this + * session. The persistence layer promotes legacy null rows before + * exposing bindings; runtime callers must not infer this from `provider`. + */ + readonly providerInstanceId?: ProviderInstanceId; readonly adapterKey?: string; readonly status?: ProviderSessionRuntimeStatus; readonly resumeCursor?: unknown | null; @@ -39,7 +46,7 @@ export interface ProviderSessionDirectoryShape { readonly getProvider: ( threadId: ThreadId, - ) => Effect.Effect; + ) => Effect.Effect; readonly getBinding: ( threadId: ThreadId, diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index 7457713e0af..a7fcdc4c827 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import * as EffectAcpErrors from "effect-acp/errors"; +import { ProviderDriverKind } from "@t3tools/contracts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; @@ -12,7 +13,7 @@ describe("AcpAdapterSupport", () => { it("maps ACP request errors to provider adapter request errors", () => { const error = mapAcpToAdapterError( - "cursor", + ProviderDriverKind.make("cursor"), "thread-1" as never, "session/prompt", new EffectAcpErrors.AcpRequestError({ diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index 914bb7e8c31..499ebd2e707 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -1,6 +1,6 @@ import { type ProviderApprovalDecision, - type ProviderKind, + type ProviderDriverKind, type ThreadId, } from "@t3tools/contracts"; import { Schema } from "effect"; @@ -13,7 +13,7 @@ import { } from "../Errors.ts"; export function mapAcpToAdapterError( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, method: string, error: EffectAcpErrors.AcpError, diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 79b51f585b1..713d0668928 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -1,4 +1,4 @@ -import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; +import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { @@ -30,7 +30,7 @@ describe("AcpCoreRuntimeEvents", () => { expect( makeAcpRequestOpenedEvent({ stamp, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId: "thread-1" as never, turnId, requestId: RuntimeRequestId.make("request-1"), @@ -52,7 +52,7 @@ describe("AcpCoreRuntimeEvents", () => { expect( makeAcpRequestResolvedEvent({ stamp, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId: "thread-1" as never, turnId, requestId: RuntimeRequestId.make("request-1"), @@ -75,7 +75,7 @@ describe("AcpCoreRuntimeEvents", () => { expect( makeAcpPlanUpdatedEvent({ stamp, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId: "thread-1" as never, turnId, payload: { @@ -95,7 +95,7 @@ describe("AcpCoreRuntimeEvents", () => { expect( makeAcpToolCallEvent({ stamp, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId: "thread-1" as never, turnId, toolCall: { @@ -119,7 +119,7 @@ describe("AcpCoreRuntimeEvents", () => { expect( makeAcpContentDeltaEvent({ stamp, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId: "thread-1" as never, turnId, itemId: "assistant:session-1:segment:0", @@ -137,7 +137,7 @@ describe("AcpCoreRuntimeEvents", () => { expect( makeAcpAssistantItemEvent({ stamp, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId: "thread-1" as never, turnId, itemId: "assistant:session-1:segment:0", diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index 0c0f06cc622..c93e61dc37b 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -4,7 +4,7 @@ import { type CanonicalRequestType, type EventId, type ProviderApprovalDecision, - type ProviderKind, + type ProviderDriverKind, type ProviderRuntimeEvent, type RuntimeRequestId, type ThreadId, @@ -78,7 +78,7 @@ function runtimeItemStatusFromAcpToolStatus( export function makeAcpRequestOpenedEvent(input: { readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly requestId: RuntimeRequestId; @@ -111,7 +111,7 @@ export function makeAcpRequestOpenedEvent(input: { export function makeAcpRequestResolvedEvent(input: { readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly requestId: RuntimeRequestId; @@ -134,7 +134,7 @@ export function makeAcpRequestResolvedEvent(input: { export function makeAcpPlanUpdatedEvent(input: { readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly payload: AcpPlanUpdate; @@ -159,7 +159,7 @@ export function makeAcpPlanUpdatedEvent(input: { export function makeAcpToolCallEvent(input: { readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly toolCall: AcpToolCallState; @@ -193,7 +193,7 @@ export function makeAcpToolCallEvent(input: { export function makeAcpAssistantItemEvent(input: { readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly itemId: string; @@ -215,7 +215,7 @@ export function makeAcpAssistantItemEvent(input: { export function makeAcpContentDeltaEvent(input: { readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly itemId?: string; diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 2fb3f4e8335..3d44aac0a47 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -1,4 +1,4 @@ -import type { ProviderKind, ThreadId } from "@t3tools/contracts"; +import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { Cause, Effect } from "effect"; import type * as EffectAcpProtocol from "effect-acp/protocol"; @@ -7,7 +7,7 @@ import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpS function writeNativeAcpLog(input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly kind: "request" | "protocol"; readonly payload: unknown; @@ -44,7 +44,7 @@ function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { export function makeAcpNativeLoggers(input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly threadId: ThreadId; }): Pick { return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index d32e70cb4ce..b4cf6656086 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -21,7 +21,7 @@ export interface AcpSpawnInput { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string; - readonly env?: Readonly>; + readonly env?: NodeJS.ProcessEnv; } export interface AcpSessionRuntimeOptions { diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 25e342f0d04..3e405dd7ff3 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -23,6 +23,7 @@ export interface CursorAcpRuntimeInput extends Omit< > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; } export interface CursorAcpModelSelectionErrorContext { @@ -34,6 +35,7 @@ export interface CursorAcpModelSelectionErrorContext { export function buildCursorAcpSpawnInput( cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, cwd: string, + environment?: NodeJS.ProcessEnv, ): AcpSpawnInput { return { command: cursorSettings?.binaryPath || "agent", @@ -42,6 +44,7 @@ export function buildCursorAcpSpawnInput( "acp", ], cwd, + ...(environment ? { env: environment } : {}), }; } @@ -52,7 +55,7 @@ export const makeCursorAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd), + spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe( diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts new file mode 100644 index 00000000000..5af56dc6b0e --- /dev/null +++ b/apps/server/src/provider/builtInDrivers.ts @@ -0,0 +1,50 @@ +/** + * BUILT_IN_DRIVERS — the static set of `ProviderDriver`s this build ships + * with. + * + * Every driver that the server knows how to instantiate from settings is + * listed here. The `ProviderInstanceRegistry` iterates this array when + * resolving `providerInstances` entries; anything not in the array surfaces + * as an `"unavailable"` shadow snapshot at runtime (see + * `buildUnavailableProviderSnapshot`). + * + * Adding a new first-party driver means: + * 1. implement `ProviderDriver` in a sibling `Drivers/Driver.ts`, + * 2. add it to this array, + * 3. ensure the runtime layer satisfies its declared `R`. + * + * The aggregated `BuiltInDriversEnv` type is the union of every driver's + * env requirement — the registry layer's `R` is this type, and the runtime + * layer (ChildProcessSpawner, FileSystem, Path, ServerConfig, + * OpenCodeRuntime, …) must satisfy it. + * + * @module provider/builtInDrivers + */ +import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; +import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import type { AnyProviderDriver } from "./ProviderDriver.ts"; + +/** + * Union of infrastructure services required to construct any built-in + * driver. The registry layer declares `R = BuiltInDriversEnv`; the runtime + * layer must provide every service in this union. + */ +export type BuiltInDriversEnv = + | ClaudeDriverEnv + | CodexDriverEnv + | CursorDriverEnv + | OpenCodeDriverEnv; + +/** + * Ordered list of built-in drivers. Order matters only for tie-breaking in + * UI presentation — the registry itself is keyed by `driverKind`, so + * iteration order has no functional effect on instance lookup. + */ +export const BUILT_IN_DRIVERS: ReadonlyArray> = [ + CodexDriver, + ClaudeDriver, + CursorDriver, + OpenCodeDriver, +]; diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 1fd10a85d9d..ee25b6d0184 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -1,49 +1,17 @@ -import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import type { Stream } from "effect"; -import type { ProviderAdapterError } from "./Errors.ts"; -import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; export type ProviderSnapshotSource = { - readonly provider: ProviderKind; + /** + * Routing key — uniquely identifies this instance in the aggregated + * snapshot list. Two different snapshot sources may share the same + * driver kind (multiple instances of the same driver). + */ + readonly instanceId: ProviderInstanceId; + /** Driver implementation kind. */ + readonly driverKind: ProviderDriverKind; readonly getSnapshot: ServerProviderShape["getSnapshot"]; readonly refresh: ServerProviderShape["refresh"]; readonly streamChanges: Stream.Stream; }; - -type BuiltInProviderServiceMap = Record; -type BuiltInAdapterMap = { - readonly codex: ProviderAdapterShape; - readonly claudeAgent: ProviderAdapterShape; - readonly opencode: ProviderAdapterShape; - readonly cursor?: ProviderAdapterShape; -}; - -export const BUILT_IN_PROVIDER_ORDER = [ - "codex", - "claudeAgent", - "opencode", - "cursor", -] as const satisfies ReadonlyArray; - -export function createBuiltInProviderSources( - services: BuiltInProviderServiceMap, -): ReadonlyArray { - return BUILT_IN_PROVIDER_ORDER.map((provider) => ({ - provider, - getSnapshot: services[provider].getSnapshot, - refresh: services[provider].refresh, - streamChanges: services[provider].streamChanges, - })); -} - -export function createBuiltInAdapterList( - adapters: BuiltInAdapterMap, -): ReadonlyArray> { - return [ - adapters.codex, - adapters.claudeAgent, - adapters.opencode, - ...(adapters.cursor ? [adapters.cursor] : []), - ]; -} diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5d50d12e372..ff664763804 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,5 +1,5 @@ import { describe, it, assert } from "@effect/vitest"; -import type { ServerProvider } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; @@ -21,7 +21,8 @@ interface TestSettings { } const initialSnapshot: ServerProvider = { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), enabled: true, installed: true, version: null, @@ -35,7 +36,8 @@ const initialSnapshot: ServerProvider = { }; const refreshedSnapshot: ServerProvider = { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), enabled: true, installed: true, version: "1.0.0", diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 086584893c1..3269c712a98 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -109,6 +109,7 @@ export interface OpenCodeRuntimeShape { */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -121,6 +122,7 @@ export interface OpenCodeRuntimeShape { readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly serverUrl?: string | null; + readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -128,6 +130,7 @@ export interface OpenCodeRuntimeShape { readonly runOpenCodeCommand: (input: { readonly binaryPath: string; readonly args: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; @@ -274,7 +277,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner.spawn( ChildProcess.make(input.binaryPath, [...input.args], { shell: process.platform === "win32", - env: process.env, + env: input.environment ?? process.env, }), ); const [stdout, stderr, code] = yield* Effect.all( @@ -332,7 +335,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ChildProcess.make(input.binaryPath, args, { detached: process.platform !== "win32", env: { - ...process.env, + ...(input.environment ?? process.env), OPENCODE_CONFIG_CONTENT: JSON.stringify({}), }, }), @@ -472,6 +475,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { return startOpenCodeServerProcess({ binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index f990afb2d61..449dca8fc5a 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ModelCapabilities } from "@t3tools/contracts"; +import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { providerModelsFromSettings } from "./providerSnapshot.ts"; @@ -27,7 +27,7 @@ describe("providerModelsFromSettings", () => { it("applies the provided capabilities to custom models", () => { const models = providerModelsFromSettings( [], - "opencode", + ProviderDriverKind.make("opencode"), ["openai/gpt-5"], OPENCODE_CUSTOM_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index f1f03074852..af0c91274c3 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + ProviderDriverKind, ModelCapabilities, ServerProvider, ServerProviderAuth, @@ -36,6 +37,8 @@ export interface ServerProviderPresentation { readonly showInteractionModeToggle?: boolean; } +export type ServerProviderDraft = Omit; + export function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -110,7 +113,7 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - provider: ServerProvider["provider"], + provider: ProviderDriverKind, customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { @@ -179,7 +182,6 @@ export function buildBooleanOptionDescriptor(input: { } export function buildServerProvider(input: { - provider: ServerProvider["provider"]; presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; @@ -187,9 +189,8 @@ export function buildServerProvider(input: { slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; -}): ServerProvider { +}): ServerProviderDraft { return { - provider: input.provider, displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), ...(typeof input.presentation.showInteractionModeToggle === "boolean" diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 5a81341f6d5..8986ba48f29 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -1,23 +1,33 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { ServerProvider } from "@t3tools/contracts"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem } from "effect"; import { hydrateCachedProvider, + isCachedProviderCorrelated, readProviderStatusCache, resolveProviderStatusCachePath, writeProviderStatusCache, } from "./providerStatusCache.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const makeProvider = ( - provider: ServerProvider["provider"], + provider: ProviderDriverKind, overrides?: Partial, ): ServerProvider => ({ - provider, + instanceId: defaultInstanceIdForDriver(provider), + driver: provider, enabled: true, installed: true, version: "1.0.0", @@ -35,26 +45,26 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); - const codexProvider = makeProvider("codex"); - const claudeProvider = makeProvider("claudeAgent", { + const codexProvider = makeProvider(CODEX_DRIVER); + const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, { status: "warning", auth: { status: "unknown" }, }); - const openCodeProvider = makeProvider("opencode", { + const openCodeProvider = makeProvider(OPENCODE_DRIVER, { status: "warning", auth: { status: "unknown", type: "opencode" }, }); - const codexPath = resolveProviderStatusCachePath({ + const codexPath = yield* resolveProviderStatusCachePath({ cacheDir: tempDir, - provider: "codex", + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), }); - const claudePath = resolveProviderStatusCachePath({ + const claudePath = yield* resolveProviderStatusCachePath({ cacheDir: tempDir, - provider: "claudeAgent", + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("claudeAgent")), }); - const openCodePath = resolveProviderStatusCachePath({ + const openCodePath = yield* resolveProviderStatusCachePath({ cacheDir: tempDir, - provider: "opencode", + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("opencode")), }); yield* writeProviderStatusCache({ @@ -77,7 +87,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); it("hydrates cached provider status while preserving current settings-derived models", () => { - const cachedCodex = makeProvider("codex", { + const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", models: [ { @@ -97,7 +107,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { }, ], }); - const fallbackCodex = makeProvider("codex", { + const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [ { slug: "gpt-5.4", @@ -138,11 +148,11 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { }); it("ignores stale cached enabled state when the provider is now disabled", () => { - const cachedCodex = makeProvider("codex", { + const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", message: "Cached ready status", }); - const disabledFallback = makeProvider("codex", { + const disabledFallback = makeProvider(CODEX_DRIVER, { enabled: false, installed: false, version: null, @@ -159,4 +169,68 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { disabledFallback, ); }); + + it("rejects cached snapshots that are not correlated to the fallback instance", () => { + const fallbackCodex = makeProvider(CODEX_DRIVER, { + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const legacyCachedCodex = { + provider: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T12:00:00.000Z", + models: [ + { + slug: "cached-legacy-model", + name: "Cached Legacy Model", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + slashCommands: [], + skills: [], + } as unknown as ServerProvider; + const mismatchedCachedCodex = makeProvider(CODEX_DRIVER, { + instanceId: ProviderInstanceId.make("codex_personal"), + }); + + assert.strictEqual( + isCachedProviderCorrelated({ + cachedProvider: legacyCachedCodex, + fallbackProvider: fallbackCodex, + }), + false, + ); + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: legacyCachedCodex, + fallbackProvider: fallbackCodex, + }), + fallbackCodex, + ); + assert.strictEqual( + isCachedProviderCorrelated({ + cachedProvider: mismatchedCachedCodex, + fallbackProvider: fallbackCodex, + }), + false, + ); + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: mismatchedCachedCodex, + fallbackProvider: fallbackCodex, + }), + fallbackCodex, + ); + }); }); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index fdb31eecbed..d6c051fd560 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,25 +1,17 @@ -import * as nodePath from "node:path"; -import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; -import { Cause, Effect, FileSystem, Schema } from "effect"; +import { + type ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, + ServerProvider as ServerProviderSchema, +} from "@t3tools/contracts"; +import { Cause, Effect, FileSystem, Path, Schema } from "effect"; import { writeFileStringAtomically } from "../atomicWrite.ts"; -export const PROVIDER_CACHE_IDS = [ - "codex", - "claudeAgent", - "opencode", - "cursor", -] as const satisfies ReadonlyArray; - const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), ); -const providerOrderRank = (provider: ServerProvider["provider"]): number => { - const rank = PROVIDER_CACHE_IDS.indexOf(provider); - return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; -}; - const mergeProviderModels = ( fallbackModels: ReadonlyArray, cachedModels: ReadonlyArray, @@ -32,13 +24,27 @@ export const orderProviderSnapshots = ( providers: ReadonlyArray, ): ReadonlyArray => [...providers].toSorted( - (left, right) => providerOrderRank(left.provider) - providerOrderRank(right.provider), + (left, right) => + (left.displayName ?? "").localeCompare(right.displayName ?? "") || + left.driver.localeCompare(right.driver) || + left.instanceId.localeCompare(right.instanceId), ); +export const isCachedProviderCorrelated = (input: { + readonly cachedProvider: ServerProvider; + readonly fallbackProvider: ServerProvider; +}): boolean => + input.cachedProvider.instanceId === input.fallbackProvider.instanceId && + input.cachedProvider.driver === input.fallbackProvider.driver; + export const hydrateCachedProvider = (input: { readonly cachedProvider: ServerProvider; readonly fallbackProvider: ServerProvider; }): ServerProvider => { + if (!isCachedProviderCorrelated(input)) { + return input.fallbackProvider; + } + if ( !input.fallbackProvider.enabled || input.cachedProvider.enabled !== input.fallbackProvider.enabled @@ -64,10 +70,46 @@ export const hydrateCachedProvider = (input: { : hydratedProvider; }; -export const resolveProviderStatusCachePath = (input: { +/** + * Resolve the on-disk cache path for a provider instance snapshot. + * + * File naming: `/.json`. For the default instance of + * a built-in kind this equals the legacy `.json` path (because + * `defaultInstanceIdForDriver(kind).toString() === kind`), so existing + * cached snapshots remain readable without any rename step. + * + * Non-default instances (e.g. `codex_personal`) land in their own files and + * never collide with other instances. + * + * Cache contents must still carry matching `instanceId` + `driver` identity + * before hydration. The filename alone is not trusted as a routing key. + */ +export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( + function* (input: { + readonly cacheDir: string; + readonly instanceId: ProviderInstanceId; + }): Effect.fn.Return { + const path = yield* Path.Path; + return path.join(input.cacheDir, `${input.instanceId}.json`); + }, +); + +/** + * Legacy kind-keyed path resolver retained for callers that still think in + * terms of `ProviderDriverKind`. Prefer `resolveProviderStatusCachePath` with an + * `instanceId`; new code should route through the instance registry. + * + * @deprecated use `resolveProviderStatusCachePath` with an instance id. + */ +export const resolveLegacyProviderStatusCachePath = Effect.fn( + "resolveLegacyProviderStatusCachePath", +)(function* (input: { readonly cacheDir: string; - readonly provider: ServerProvider["provider"]; -}) => nodePath.join(input.cacheDir, `${input.provider}.json`); + readonly provider: ProviderDriverKind; +}): Effect.fn.Return { + const path = yield* Path.Path; + return path.join(input.cacheDir, `${input.provider}.json`); +}); export const readProviderStatusCache = (filePath: string) => Effect.gen(function* () { diff --git a/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts new file mode 100644 index 00000000000..9a4f107db8b --- /dev/null +++ b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts @@ -0,0 +1,95 @@ +/** + * Test helpers for constructing a `ProviderAdapterRegistryShape` mock from a + * kind-keyed adapter map. + * + * Tests historically assembled a `registry` object with only `getByProvider` + * + `listProviders` populated. Slice D grew the shape with `getByInstance` + * and `listInstances`; this helper fills both in from a single kind-keyed + * input so individual fixtures can stay concise. + * + * Non-default instance ids (e.g. `codex_personal`) are not addressable via + * the shim returned here — the legacy test fixtures only ever had + * single-instance-per-driver data anyway. + * + * @module provider/testUtils/providerAdapterRegistryMock + */ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { Effect, PubSub, Record, Result, Stream } from "effect"; + +import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import type { ProviderAdapterRegistryShape } from "../Services/ProviderAdapterRegistry.ts"; + +export type KindAdapterMap = Partial< + Record> +>; + +/** + * Build a `ProviderAdapterRegistryShape` from a kind-keyed adapter map. + * Every adapter present in the map is addressable via both the legacy + * `getByProvider(kind)` path and the new `getByInstance(id)` path (where + * `id = defaultInstanceIdForDriver(kind)`). + */ +export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapterRegistryShape => { + const byInstanceId = new Map>(); + for (const [kind, adapter] of Object.entries(adapters)) { + if (!adapter) continue; + const driverKind = ProviderDriverKind.make(kind); + byInstanceId.set(defaultInstanceIdForDriver(driverKind), adapter); + } + + const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => { + const adapter = byInstanceId.get(instanceId); + return adapter + ? Effect.succeed(adapter) + : Effect.fail( + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make(instanceId), + }), + ); + }; + + return { + getByInstance, + getInstanceInfo: (instanceId) => { + const adapter = byInstanceId.get(instanceId); + if (!adapter) { + return Effect.fail( + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make(instanceId), + }), + ); + } + return Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make(adapter.provider), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make(adapter.provider), + continuationKey: `${adapter.provider}:instance:${instanceId}`, + }, + }); + }, + listInstances: () => Effect.succeed(Array.from(byInstanceId.keys())), + listProviders: () => + Effect.succeed( + Record.keys( + Record.filterMap(adapters, (adapter, kind) => + adapter !== undefined ? Result.succeed(kind) : Result.failVoid, + ), + ), + ), + // Static test fixtures don't reload; an empty stream is enough to + // satisfy the shape. Tests exercising hot-reload build their own + // stream via the real `ProviderInstanceRegistry`. + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +}; diff --git a/apps/server/src/provider/unavailableProviderSnapshot.ts b/apps/server/src/provider/unavailableProviderSnapshot.ts new file mode 100644 index 00000000000..97a532a9d98 --- /dev/null +++ b/apps/server/src/provider/unavailableProviderSnapshot.ts @@ -0,0 +1,72 @@ +/** + * Helpers for synthesizing "unavailable" `ServerProvider` snapshots. + * + * When `ServerSettings.providerInstances` (or persisted thread/session + * state) references a driver this build does not ship — typical after a + * downgrade from a fork or a feature-branch test session — the runtime + * needs to surface the entry to the UI without crashing. This module + * produces shadow snapshots that satisfy `ServerProvider`'s wire shape + * while signalling unavailability. + * + * @module unavailableProviderSnapshot + */ +import { + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; + +import { buildServerProvider } from "./providerSnapshot.ts"; + +export interface UnavailableProviderSnapshotInput { + readonly driverKind: ProviderDriverKind | string; + readonly instanceId: ProviderInstanceId; + readonly displayName?: string | undefined; + readonly accentColor?: string | undefined; + readonly reason: string; + /** + * Optional override for `checkedAt`. Defaulted to `new Date()` so callers + * (notably tests) don't have to pass it. + */ + readonly checkedAt?: string; +} + +/** + * Produce a `ServerProvider` snapshot representing a configured instance + * whose driver the running build does not implement. The result is safe + * to broadcast over the wire and is structured so the web UI can render + * a "missing driver" affordance without special-casing. + */ +export function buildUnavailableProviderSnapshot( + input: UnavailableProviderSnapshotInput, +): ServerProvider { + const checkedAt = input.checkedAt ?? new Date().toISOString(); + const displayName = input.displayName?.trim() || (input.driverKind as string); + + const base = buildServerProvider({ + presentation: { displayName }, + enabled: false, + checkedAt, + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: input.reason, + }, + }); + + return { + ...base, + instanceId: input.instanceId, + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + driver: + typeof input.driverKind === "string" + ? ProviderDriverKind.make(input.driverKind) + : input.driverKind, + availability: "unavailable", + unavailableReason: input.reason, + }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 47e159d3036..e699ad8339c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -17,6 +17,8 @@ import { type OrchestrationEvent, ORCHESTRATION_WS_METHODS, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ResolvedKeybindingRule, ThreadId, WS_METHODS, @@ -110,7 +112,7 @@ const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; const testEnvironmentDescriptor = { @@ -1845,7 +1847,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const providers = [ { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), enabled: true, installed: true, version: "1.0.0", @@ -1915,7 +1918,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const nextProviders = [ { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), enabled: true, installed: true, version: "1.0.0", @@ -2164,7 +2168,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workspaceRoot: missingWorkspaceRoot, createWorkspaceRootIfMissing: true, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt: new Date().toISOString(), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f94bbb34b5b..85f2d84ad28 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,22 +16,20 @@ import { OpenLive } from "./open.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; -import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; -import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; -import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts"; -import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; +import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; import { GitCoreLive } from "./git/Layers/GitCore.ts"; import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; -import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration.ts"; +import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts"; +import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import { GitManagerLive } from "./git/Layers/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; @@ -144,41 +142,15 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); -const ProviderLayerLive = Layer.unwrap( - Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig; - const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "native", - }); - const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "canonical", - }); - const codexAdapterLayer = makeCodexAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const claudeAdapterLayer = makeClaudeAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const openCodeAdapterLayer = makeOpenCodeAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const cursorAdapterLayer = makeCursorAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(codexAdapterLayer), - Layer.provide(claudeAdapterLayer), - Layer.provide(openCodeAdapterLayer), - Layer.provide(cursorAdapterLayer), - Layer.provideMerge(ProviderSessionDirectoryLayerLive), - ); - return makeProviderServiceLive( - canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe( - Layer.provide(adapterRegistryLayer), - Layer.provideMerge(ProviderSessionDirectoryLayerLive), - ); - }), +// `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter +// by looking up the default `ProviderInstance` per driver in the instance +// registry. Adapter construction itself moved inside each driver's +// `create()`; `ProviderEventLoggersLive` owns the shared native/canonical +// NDJSON writers and is provided at the outer runtime layer so both +// `ProviderService` and the per-instance drivers read the same logger pair. +const ProviderLayerLive = ProviderServiceLive.pipe( + Layer.provide(ProviderAdapterRegistryLive), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); @@ -187,7 +159,7 @@ const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), + Layer.provideMerge(TextGenerationLive), ); const GitLayerLive = Layer.empty.pipe( @@ -233,6 +205,24 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), + // The instance registry is the new routing keystone — text generation, + // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` + // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; + // `providerInstances` hydration merges `settings.providers.` + // with explicit `providerInstances` entries on boot. + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // Shared native/canonical NDJSON writers used by both the per-instance + // drivers (native stream, written from inside each `Adapter`) and + // `ProviderService` (canonical stream, written after event normalization). + // Provided once at the runtime level so every consumer sees the same + // logger instances. + Layer.provideMerge(ProviderEventLoggersLive), + // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old + // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but + // the rewritten registry reads snapshots off the instance registry and + // no longer transitively provides it. Exposing it at the runtime level + // keeps a single Live for all opencode consumers. + Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 836b71c7eb4..91b4b215c10 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect"; @@ -21,8 +21,8 @@ import { it("uses the canonical Codex default for auto-bootstrapped model selection", () => { assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }); }); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 99728f681f4..1f164860a6f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,9 +1,10 @@ import { CommandId, - DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, type ModelSelection, ProjectId, + ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import { @@ -154,8 +155,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( ); export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }); export const resolveWelcomeBase = Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 55f598054be..f11c5bf4519 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; @@ -49,14 +55,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const decoded = decode({ textGenerationModelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.4-mini", options: { reasoningEffort: "low" }, }, }); assert.deepEqual(decoded.textGenerationModelSelection, { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4-mini", options: [{ id: "reasoningEffort", value: "low" }], }); @@ -79,10 +85,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, options: createModelSelection( - "codex", + ProviderInstanceId.make("codex"), DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ { id: "reasoningEffort", value: "high" }, @@ -107,20 +113,26 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/codex", homePath: "/Users/julius/.codex", + shadowHomePath: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { enabled: true, binaryPath: "/usr/local/bin/claude", + homePath: "", customModels: ["claude-custom"], launchArgs: "", }); assert.deepEqual( next.textGenerationModelSelection, - createModelSelection("codex", DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ - { id: "reasoningEffort", value: "high" }, - { id: "fastMode", value: false }, - ]), + createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ], + ), ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -132,11 +144,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { // Start with Claude text generation selection yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", - options: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ - { id: "effort", value: "high" }, - ]).options!, + options: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "high" }], + ).options!, }, }); @@ -144,9 +158,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { // cause the update to lose the selected model. const next = yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", - options: createModelSelection("codex", "gpt-5.4", [ + options: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "reasoningEffort", value: "high" }, ]).options!, }, @@ -154,21 +168,107 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual( next.textGenerationModelSelection, - createModelSelection("codex", "gpt-5.4", [{ id: "reasoningEffort", value: "high" }]), + createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("preserves custom provider instance text generation selections", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("claude_openrouter")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claude_openrouter"), + model: "openai/gpt-5.5", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: ProviderInstanceId.make("claude_openrouter"), + model: "openai/gpt-5.5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect( + "uses explicit provider instance enabled state over legacy provider enabled state", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const instanceId = ProviderInstanceId.make("claude_openrouter"); + + const next = yield* serverSettings.updateSettings({ + providers: { + claudeAgent: { + enabled: false, + }, + }, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + textGenerationModelSelection: { + instanceId, + model: "openai/gpt-5.5", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId, + model: "openai/gpt-5.5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves enabled text generation selections for non-built-in drivers", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const instanceId = ProviderInstanceId.make("openrouter_text"); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("openrouter"), + enabled: true, + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + textGenerationModelSelection: { + instanceId, + model: "openai/gpt-5.5", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId, + model: "openai/gpt-5.5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("drops stale text generation options when resetting model selection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, options: createModelSelection( - "codex", + ProviderInstanceId.make("codex"), DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ { id: "reasoningEffort", value: "high" }, @@ -180,18 +280,55 @@ it.layer(NodeServices.layer)("server settings", (it) => { const next = yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, }, }); assert.deepEqual(next.textGenerationModelSelection, { - provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("replaces provider instance maps when clearing optional fields", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const codexId = ProviderInstanceId.make("codex"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Work", + accentColor: "#7c3aed", + enabled: true, + config: { homePath: "~/.codex" }, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Work", + enabled: true, + config: { homePath: "~/.codex" }, + }, + }, + }); + + assert.deepEqual(next.providerInstances[codexId], { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Work", + enabled: true, + config: { homePath: "~/.codex" }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("trims provider path settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -217,11 +354,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/codex", homePath: "", + shadowHomePath: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { enabled: true, binaryPath: "/opt/homebrew/bin/claude", + homePath: "", customModels: [], launchArgs: "", }); @@ -318,4 +457,67 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("stores sensitive provider instance environment values outside settings.json", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const instanceId = ProviderInstanceId.make("codex_personal"); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ], + config: {}, + }, + }, + }); + + assert.deepEqual(next.providerInstances[instanceId]?.environment, [ + { + name: "OPENROUTER_API_KEY", + value: "sk-or-secret", + sensitive: true, + valueRedacted: true, + }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ]); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "sk-or-secret"); + assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [ + { + name: "OPENROUTER_API_KEY", + value: "", + sensitive: true, + valueRedacted: true, + }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ]); + + const roundTripped = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Personal", + environment: [ + { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ], + config: {}, + }, + }, + }); + + assert.equal( + roundTripped.providerInstances[instanceId]?.environment?.[0]?.value, + "sk-or-secret", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index c2147a0f45b..2b9d8a52951 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,10 +11,15 @@ * @module ServerSettings */ import { + DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, + isProviderDriverKind, type ModelSelection, - type ProviderKind, + type ProviderInstanceConfig, + type ProviderInstanceEnvironmentVariable, + ProviderDriverKind, + ProviderInstanceId, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -44,6 +49,47 @@ import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function providerEnvironmentSecretName(input: { + readonly instanceId: string; + readonly name: string; +}): string { + return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; +} + +function redactProviderEnvironmentVariable( + variable: ProviderInstanceEnvironmentVariable, +): ProviderInstanceEnvironmentVariable { + if (!variable.sensitive) { + const { valueRedacted: _omit, ...rest } = variable; + return rest; + } + return { + ...variable, + value: "", + ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), + }; +} + +export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { + const providerInstances = Object.fromEntries( + Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ + instanceId, + instance.environment + ? { + ...instance, + environment: instance.environment.map(redactProviderEnvironmentVariable), + } + : instance, + ]), + ); + return { ...settings, providerInstances }; +} export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ @@ -106,7 +152,13 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"]; +type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; + +const getLegacyProviderSettings = ( + settings: ServerSettings, + provider: ProviderDriverKind, +): LegacyProviderSettings | undefined => + (settings.providers as Record)[provider]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. @@ -116,22 +168,36 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "openco */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; - if (settings.providers[selection.provider].enabled) { + const instanceConfig = settings.providerInstances[selection.instanceId]; + if (instanceConfig !== undefined) { + return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); + } + + if ( + isProviderDriverKind(selection.instanceId) && + getLegacyProviderSettings(settings, selection.instanceId)?.enabled + ) { return settings; } - const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); + return fallbackTextGenerationProvider(settings); +} + +function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { + const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); + const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { - // No providers enabled — return as-is; callers will report the error. return settings; } return { ...settings, textGenerationModelSelection: { - provider: fallback, - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], - } as ModelSelection, + instanceId: ProviderInstanceId.make(fallback), + model: + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? + DEFAULT_GIT_TEXT_GENERATION_MODEL, + } satisfies ModelSelection, }; } @@ -176,6 +242,7 @@ const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; + const secretStore = yield* ServerSecretStore; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -233,6 +300,138 @@ const makeServerSettings = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); + const toSettingsError = (detail: string, cause: unknown) => + new ServerSettingsError({ + settingsPath, + detail, + cause, + }); + + const materializeProviderEnvironmentSecrets = ( + settings: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const providerInstances: Record = { + ...settings.providerInstances, + }; + for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + if (!instance.environment) continue; + const environment: ProviderInstanceEnvironmentVariable[] = []; + for (const variable of instance.environment) { + if (!variable.sensitive || !variable.valueRedacted) { + environment.push(variable); + continue; + } + const secret = yield* secretStore + .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) + .pipe( + Effect.mapError((cause) => + toSettingsError( + `failed to read sensitive environment variable ${variable.name}`, + cause, + ), + ), + ); + environment.push({ + ...variable, + value: secret ? textDecoder.decode(secret) : "", + }); + } + providerInstances[instanceId] = { + ...instance, + environment, + } satisfies ProviderInstanceConfig; + } + return { + ...settings, + providerInstances: providerInstances as ServerSettings["providerInstances"], + }; + }); + + const persistProviderEnvironmentSecrets = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const providerInstances: Record = { + ...next.providerInstances, + }; + + const nextSecretKeys = new Set(); + for (const [instanceId, instance] of Object.entries(next.providerInstances)) { + if (!instance.environment) continue; + const environment: ProviderInstanceEnvironmentVariable[] = []; + for (const variable of instance.environment) { + const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); + if (!variable.sensitive) { + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError((cause) => + toSettingsError(`failed to remove environment secret ${variable.name}`, cause), + ), + ); + environment.push(redactProviderEnvironmentVariable(variable)); + continue; + } + + nextSecretKeys.add(secretName); + if (!variable.valueRedacted) { + if (variable.value.length > 0) { + yield* secretStore + .set(secretName, textEncoder.encode(variable.value)) + .pipe( + Effect.mapError((cause) => + toSettingsError(`failed to persist environment secret ${variable.name}`, cause), + ), + ); + environment.push({ ...variable, value: "", valueRedacted: true }); + } else { + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError((cause) => + toSettingsError(`failed to remove environment secret ${variable.name}`, cause), + ), + ); + const { valueRedacted: _omit, ...rest } = variable; + environment.push(rest); + } + continue; + } + + environment.push(redactProviderEnvironmentVariable(variable)); + } + providerInstances[instanceId] = { + ...instance, + environment, + } satisfies ProviderInstanceConfig; + } + + for (const [instanceId, instance] of Object.entries(current.providerInstances)) { + for (const variable of instance.environment ?? []) { + if (!variable.sensitive) continue; + const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); + if (nextSecretKeys.has(secretName)) continue; + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError((cause) => + toSettingsError( + `failed to remove stale environment secret ${variable.name}`, + cause, + ), + ), + ); + } + } + + return { + ...next, + providerInstances: providerInstances as ServerSettings["providerInstances"], + }; + }); + const writeSettingsAtomically = (settings: ServerSettings) => { const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; @@ -324,14 +523,19 @@ const makeServerSettings = Effect.gen(function* () { return { start, ready: Deferred.await(startedDeferred), - getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), + getSettings: getSettingsFromCache.pipe( + Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.map(resolveTextGenerationProvider), + ), updateSettings: (patch) => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const next = yield* Schema.decodeEffect(ServerSettings)( + const nextPersisted = yield* persistProviderEnvironmentSecrets( + current, applyServerSettingsPatch(current, patch), - ).pipe( + ); + const next = yield* Schema.decodeEffect(ServerSettings)(nextPersisted).pipe( Effect.mapError( (cause) => new ServerSettingsError({ @@ -344,13 +548,27 @@ const makeServerSettings = Effect.gen(function* () { yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); - return resolveTextGenerationProvider(next); + const materialized = yield* materializeProviderEnvironmentSecrets(next); + return resolveTextGenerationProvider(materialized); }), ), get streamChanges() { - return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); + return Stream.fromPubSub(changesPubSub).pipe( + Stream.mapEffect((settings) => + materializeProviderEnvironmentSecrets(settings).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize provider environment secrets", { + detail: error.detail, + }).pipe(Effect.as(settings)), + ), + ), + ), + Stream.map(resolveTextGenerationProvider), + ); }, } satisfies ServerSettingsShape; }); -export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); +export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( + Layer.provide(ServerSecretStoreLive), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index aac716cfeb6..fd256b32dfa 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -45,7 +45,7 @@ import { import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; -import { ServerSettingsService } from "./serverSettings.ts"; +import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -512,7 +512,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; - const settings = yield* serverSettings.getSettings; + const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -749,10 +749,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), - [WS_METHODS.serverRefreshProviders]: (_input) => + [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + (input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() + ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), [WS_METHODS.serverUpsertKeybinding]: (rule) => @@ -765,13 +768,21 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => { "rpc.aggregate": "server" }, ), [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverGetSettings, + serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverUpdateSettings]: ({ patch }) => - observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverUpdateSettings, + serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -976,6 +987,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -983,13 +995,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => })), ); - yield* Effect.all( - [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")], - { - concurrency: "unbounded", - discard: true, - }, - ).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + yield* providerRegistry + .refresh() + .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); const liveUpdates = Stream.merge( keybindingsUpdates, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f5cc79b88a8..445fe193057 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -9,6 +9,8 @@ import { type MessageId, type OrchestrationReadModel, type ProjectId, + ProviderDriverKind, + ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, @@ -167,7 +169,8 @@ function createBaseServerConfig(): ServerConfig { issues: [], providers: [ { - provider: "codex", + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), enabled: true, installed: true, version: "0.116.0", @@ -323,7 +326,7 @@ function createSnapshotForTargetUser(options: { title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, scripts: [], @@ -338,7 +341,7 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: THREAD_TITLE, modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -403,7 +406,7 @@ function addThreadToSnapshot( projectId: PROJECT_ID, title: "New thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -740,7 +743,7 @@ function createSnapshotWithSecondaryProject(options?: { id: "thread-secondary-project" as ThreadId, projectId: SECOND_PROJECT_ID, title: "Release checklist", - modelSelection: { provider: "codex", model: "gpt-5" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, interactionMode: "default", runtimeMode: "full-access", branch: "release/docs-portal", @@ -772,7 +775,7 @@ function createSnapshotWithSecondaryProject(options?: { id: ARCHIVED_SECONDARY_THREAD_ID, projectId: SECOND_PROJECT_ID, title: "Archived Docs Notes", - modelSelection: { provider: "codex", model: "gpt-5" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, interactionMode: "default", runtimeMode: "full-access", branch: "release/docs-archive", @@ -807,7 +810,7 @@ function createSnapshotWithSecondaryProject(options?: { id: SECOND_PROJECT_ID, title: "Docs Portal", workspaceRoot: "/repo/clients/docs-portal", - defaultModelSelection: { provider: "codex", model: "gpt-5" }, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -884,7 +887,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { } function createSnapshotWithPlanFollowUpPrompt(options?: { - modelSelection?: { provider: "codex"; model: string }; + modelSelection?: { instanceId: ProviderInstanceId; model: string }; planMarkdown?: string; }): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ @@ -892,7 +895,7 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { targetText: "plan follow-up thread", }); const modelSelection = options?.modelSelection ?? { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }; const planMarkdown = @@ -2438,6 +2441,126 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { + setDraftThreadWithoutWorktree(); + const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); + const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); + useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.117", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: openRouterInstanceId, + displayName: "Claude OpenRouter", + enabled: true, + installed: true, + version: "2.1.117", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + ], + settings: { + ...nextFixture.serverConfig.settings, + providerInstances: { + ...nextFixture.serverConfig.settings.providerInstances, + [openRouterInstanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude OpenRouter", + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + }, + }; + }, + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + const turnStartRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as + | { + modelSelection?: { instanceId?: string; model?: string }; + bootstrap?: { + createThread?: { + modelSelection?: { instanceId?: string; model?: string }; + }; + }; + } + | undefined; + + expect(turnStartRequest?.modelSelection).toMatchObject({ + instanceId: openRouterInstanceId, + model: "openai/gpt-5.5", + }); + expect(turnStartRequest?.bootstrap?.createThread?.modelSelection).toMatchObject({ + instanceId: openRouterInstanceId, + model: "openai/gpt-5.5", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); const mounted = await mountChatView({ @@ -3778,12 +3901,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - codex: createModelSelection("codex", "gpt-5.3-codex", [ - { id: "reasoningEffort", value: "medium" }, - { id: "fastMode", value: true }, - ]), + [ProviderInstanceId.make("codex")]: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.3-codex", + [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ], + ), }, - stickyActiveProvider: "codex", + stickyActiveProvider: ProviderInstanceId.make("codex"), }); const mounted = await mountChatView({ @@ -3814,7 +3941,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { codex: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, @@ -3829,12 +3956,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]), + [ProviderInstanceId.make("claudeAgent")]: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), }, - stickyActiveProvider: "claudeAgent", + stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), }); const mounted = await mountChatView({ @@ -3860,10 +3991,14 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { - claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]), + claudeAgent: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), }, activeProvider: "claudeAgent", }); @@ -3903,12 +4038,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("prefers draft state over sticky composer settings and defaults", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - codex: createModelSelection("codex", "gpt-5.3-codex", [ - { id: "reasoningEffort", value: "medium" }, - { id: "fastMode", value: true }, - ]), + [ProviderInstanceId.make("codex")]: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.3-codex", + [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ], + ), }, - stickyActiveProvider: "codex", + stickyActiveProvider: ProviderInstanceId.make("codex"), }); const mounted = await mountChatView({ @@ -3938,7 +4077,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, @@ -3948,7 +4087,7 @@ describe("ChatView timeline estimator parity (full app)", () => { useComposerDraftStore.getState().setModelSelection( draftId, - createModelSelection("codex", "gpt-5.4", [ + createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "reasoningEffort", value: "low" }, { id: "fastMode", value: true }, ]), @@ -3963,7 +4102,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { - codex: createModelSelection("codex", "gpt-5.4", [ + codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "reasoningEffort", value: "low" }, { id: "fastMode", value: true }, ]), @@ -5435,7 +5574,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex-spark", + }, planMarkdown: "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", }), @@ -5465,7 +5607,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex-spark", + }, planMarkdown: "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", }), @@ -5589,14 +5734,17 @@ describe("ChatView timeline estimator parity (full app)", () => { projects: snapshot.projects.map((project) => project.id === PROJECT_ID ? Object.assign({}, project, { - defaultModelSelection: { provider: "codex", model: "gpt-5.4" }, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, }) : project, ), threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, }) : thread, ), @@ -5647,7 +5795,6 @@ describe("ChatView timeline estimator parity (full app)", () => { providers: [ { ...nextFixture.serverConfig.providers[0]!, - provider: "codex", models: [ { slug: "gpt-5.1-codex-max", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ba0a0eddf30..83c90edaddc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,5 +1,12 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; -import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; @@ -220,7 +227,7 @@ const makeThread = (input?: { codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", - modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access" as const, interactionMode: "default" as const, session: null, @@ -253,7 +260,7 @@ function setStoreThreads(threads: ReadonlyArray>) name: "Project", cwd: "/tmp/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, createdAt: "2026-03-29T00:00:00.000Z", @@ -452,7 +459,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }; const previousSession = { - provider: "codex" as const, + provider: ProviderDriverKind.make("codex"), status: "ready" as const, createdAt: "2026-03-29T00:00:00.000Z", updatedAt: "2026-03-29T00:00:10.000Z", @@ -466,7 +473,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -503,7 +510,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -549,7 +556,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -592,7 +599,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -635,7 +642,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -685,7 +692,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b77cc7b1762..417313ef2c1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,8 +1,9 @@ import { type EnvironmentId, + isProviderDriverKind, ProjectId, type ModelSelection, - type ProviderKind, + type ProviderDriverKind, type ScopedThreadRef, type ThreadId, type TurnId, @@ -226,15 +227,39 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } +// `threadProvider` is the open branded driver kind carried by the session. +// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe +// rollback / fork behavior — the routing layer is the right place to surface +// "driver not installed" errors, not the lock state. +// +// `selectedProvider` takes the same open-string shape because the composer +// now tracks the picker selection as a `ProviderInstanceId` (e.g. +// `codex_personal`). Custom instance ids that don't directly match a +// registered driver resolve to `null` here, which matches the existing +// "unknown driver -> unlocked" semantics. Callers that want the lock to track +// a custom instance's underlying driver kind should resolve the instance id +// upstream and pass the correlated kind. export function deriveLockedProvider(input: { thread: Thread | null | undefined; - selectedProvider: ProviderKind | null; - threadProvider: ProviderKind | null; -}): ProviderKind | null { + selectedProvider: string | null; + threadProvider: string | null; +}): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; } - return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null; + const sessionProvider = input.thread?.session?.provider ?? null; + if (sessionProvider) { + return sessionProvider; + } + const narrowedThreadProvider = + input.threadProvider && isProviderDriverKind(input.threadProvider) + ? input.threadProvider + : null; + const narrowedSelectedProvider = + input.selectedProvider && isProviderDriverKind(input.selectedProvider) + ? input.selectedProvider + : null; + return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } export async function waitForStartedServerThread( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c27afda975b..765a2d5f9ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,13 +1,14 @@ import { type ApprovalRequestId, - DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_MODEL, + defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, - type ProviderKind, type ProjectId, type ProviderApprovalDecision, + ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, @@ -16,6 +17,7 @@ import { type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, + ProviderDriverKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -115,7 +117,7 @@ import { import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; -import { resolveAppModelSelection } from "../modelSelection"; +import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { @@ -302,7 +304,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog } function formatOutgoingPrompt(params: { - provider: ProviderKind; + provider: ProviderDriverKind; model: string | null; models: ReadonlyArray; effort: string | null; @@ -777,8 +779,8 @@ export default function ChatView(props: ChatViewProps) { threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }, localDraftError, ) @@ -1031,7 +1033,9 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = - activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, @@ -1051,9 +1055,9 @@ export default function ChatView(props: ChatViewProps) { const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? "codex", + selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); - const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -1425,10 +1429,24 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, - [selectedProvider, providerStatuses], - ); + // Prefer an instance-id match so a custom Codex instance (e.g. + // `codex_personal`) surfaces its own status/message in the banner rather + // than the default Codex's. Falls back to first-match-by-kind when no + // saved instance id is available or the instance no longer exists. + const activeProviderInstanceId = + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; + const activeProviderStatus = useMemo(() => { + if (activeProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null + ); + } + const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); + return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; + }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -1945,7 +1963,7 @@ export default function ChatView(props: ChatViewProps) { if ( input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || - input.modelSelection.provider !== serverThread.modelSelection.provider || + input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || JSON.stringify(input.modelSelection.options ?? null) !== JSON.stringify(serverThread.modelSelection.options ?? null)) ) { @@ -2559,10 +2577,8 @@ export default function ChatView(props: ChatViewProps) { } const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( - ctxSelectedProvider, - ctxSelectedModel || - activeProject.defaultModelSelection?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, + ctxSelectedModelSelection.instanceId, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); @@ -3128,21 +3144,46 @@ export default function ChatView(props: ChatViewProps) { ]); const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: string) => { + (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; - if (lockedProvider !== null && provider !== lockedProvider) { + // Look up the configured instance so model normalization and custom + // model lookup stay scoped to that exact instance. Unknown instance ids + // are rejected by returning early; the server remains authoritative too. + const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); + const resolvedDriverKind = entry?.driver ?? null; + if ( + lockedProvider !== null && + resolvedDriverKind !== null && + resolvedDriverKind !== lockedProvider + ) { scheduleComposerFocus(); return; } - const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); - const resolvedModel = resolveAppModelSelection( - resolvedProvider, + if (lockedProvider !== null && activeThread.session?.providerInstanceId) { + const currentEntry = providerStatuses.find( + (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, + ); + if ( + currentEntry?.continuation?.groupKey && + entry?.continuation?.groupKey && + currentEntry.continuation.groupKey !== entry.continuation.groupKey + ) { + scheduleComposerFocus(); + return; + } + } + const resolvedModel = resolveAppModelSelectionForInstance( + instanceId, settings, providerStatuses, model, ); + if (!resolvedModel) { + scheduleComposerFocus(); + return; + } const nextModelSelection: ModelSelection = { - provider: resolvedProvider, + instanceId, model: resolvedModel, }; setComposerDraftModelSelection( diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index a49dadc851f..38b44f3f6a7 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { buildThreadActionItems, @@ -17,7 +17,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: PROJECT_ID, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", session: null, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2b587afc510..050337e438a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2,10 +2,11 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import { - DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_MODEL, type EnvironmentId, type FilesystemBrowseResult, type ProjectId, + ProviderInstanceId, } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -786,8 +787,8 @@ function OpenCommandPaletteDialog() { workspaceRoot: cwd, createWorkspaceRootIfMissing: true, defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }, createdAt: new Date().toISOString(), }); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 98bc509a6f3..7df7975fb10 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -588,3 +588,26 @@ export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( ); + +export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( + + + +); + +export const PiAgentIcon: Icon = ({ className, ...props }) => ( + + + + + +); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7eadd4e41be..df1c6ba542f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -7,6 +7,8 @@ import { type MessageId, type OrchestrationReadModel, type ProjectId, + ProviderDriverKind, + ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, @@ -72,7 +74,8 @@ function createBaseServerConfig(): ServerConfig { issues: [], providers: [ { - provider: "codex", + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), enabled: true, installed: true, version: "0.116.0", @@ -95,10 +98,25 @@ function createBaseServerConfig(): ServerConfig { ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, - textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4-mini", + }, providers: { - codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, - claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, + codex: { + enabled: true, + binaryPath: "", + homePath: "", + shadowHomePath: "", + customModels: [], + }, + claudeAgent: { + enabled: true, + binaryPath: "", + homePath: "", + customModels: [], + launchArgs: "", + }, cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, opencode: { enabled: true, @@ -121,7 +139,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, scripts: [], @@ -136,7 +154,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { projectId: PROJECT_ID, title: "Test thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f92f2f628c3..926c117c1c0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; import { createThreadJumpHintVisibilityController, @@ -20,7 +21,13 @@ import { sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + OrchestrationLatestTurn, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -474,7 +481,7 @@ describe("resolveThreadStatusPill", () => { latestTurn: null, lastVisitedAt: undefined, session: { - provider: "codex" as const, + provider: ProviderDriverKind.make("codex"), status: "running" as const, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", @@ -698,7 +705,7 @@ function makeProject(overrides: Partial = {}): Project { name: "Project", cwd: "/tmp/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", ...defaultModelSelection, }, @@ -717,7 +724,7 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", ...overrides?.modelSelection, }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3b47b2067cd..2b7f62e8af7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5,7 +5,6 @@ import type { ProjectEntry, ProviderApprovalDecision, ProviderInteractionMode, - ProviderKind, ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, @@ -14,6 +13,8 @@ import type { TurnId, } from "@t3tools/contracts"; import { + ProviderDriverKind, + ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; @@ -95,11 +96,14 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; +import { getProviderInteractionModeToggle } from "../../providerModels"; import { - getProviderInteractionModeToggle, - getProviderModels, - resolveSelectableProvider, -} from "../../providerModels"; + deriveProviderInstanceEntries, + resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, + type ProviderInstanceEntry, +} from "../../providerInstances"; +import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; @@ -346,7 +350,7 @@ export interface ChatComposerHandle { selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; - selectedProvider: ProviderKind; + selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; }; @@ -406,7 +410,7 @@ export interface ChatComposerProps { interactionMode: ProviderInteractionMode; // Provider / model - lockedProvider: ProviderKind | null; + lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -449,7 +453,7 @@ export interface ChatComposerProps { cursorAdjacentToMention: boolean, ) => void; - onProviderModelSelect: (provider: ProviderKind, model: string) => void; + onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; @@ -566,29 +570,134 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ // Model state // ------------------------------------------------------------------ + // Instance-aware projection of the wire provider list. One entry per + // configured instance (default built-in + any custom `providerInstances.*`), + // sorted default-first per driver kind for a stable picker order. + const providerInstanceEntries = useMemo>( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), + [providerStatuses], + ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = - activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null; + activeThread?.session?.providerInstanceId ?? + activeThreadModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? + null; + const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; + + const unlockedSelectedProvider = + resolveProviderDriverKindForInstanceSelection( + providerInstanceEntries, + providerStatuses, + explicitSelectedInstanceId, + ) ?? ProviderDriverKind.make("codex"); + const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const lockedContinuationGroupKey = useMemo((): string | null => { + if (!lockedProvider || !activeThread) return null; + const lockedInstanceId = + activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; + if (!lockedInstanceId) return null; + return ( + providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) + ?.continuationGroupKey ?? null + ); + }, [ + activeThread, + activeThreadModelSelection?.instanceId, + lockedProvider, + providerInstanceEntries, + ]); - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? "codex", - ); - const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + // Resolve which configured instance the composer is currently targeting. + // Priority: + // 1. The composer draft's `activeProvider` — the user's unsaved pick + // from the model picker (must win, otherwise the UI appears to + // ignore picker selections). + // 2. Thread's persisted instance id (server-side saved selection). + // 3. Project default's instance id. + // 4. First enabled entry matching the current driver kind. + // 5. First enabled entry overall / default instance for the kind. + // + const selectedInstanceId = useMemo(() => { + const candidates: Array = [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const match = providerInstanceEntries.find( + (entry) => entry.instanceId === candidate && entry.enabled, + ); + if (match) { + // When locked to a specific driver kind, ignore persisted instance + // ids from a different kind or continuation group. + if (lockedProvider && match.driverKind !== lockedProvider) continue; + if ( + lockedContinuationGroupKey && + match.continuationGroupKey !== lockedContinuationGroupKey + ) { + continue; + } + return match.instanceId; + } + } + if (explicitSelectedInstanceId) { + return ProviderInstanceId.make(explicitSelectedInstanceId); + } + const byKind = providerInstanceEntries.find( + (entry) => + entry.enabled && + entry.driverKind === selectedProvider && + (!lockedContinuationGroupKey || + entry.continuationGroupKey === lockedContinuationGroupKey), + ); + if (byKind) return byKind.instanceId; + const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + return ( + anyEnabled?.instanceId ?? + providerInstanceEntries[0]?.instanceId ?? + activeThreadModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? + ProviderInstanceId.make("codex") + ); + }, [ + activeProjectDefaultModelSelection?.instanceId, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + composerDraft.activeProvider, + explicitSelectedInstanceId, + lockedContinuationGroupKey, + lockedProvider, + providerInstanceEntries, + selectedProvider, + ]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, providers: providerStatuses, selectedProvider, + selectedInstanceId, threadModelSelection: activeThreadModelSelection, projectModelSelection: activeProjectDefaultModelSelection, settings, }); - const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + // Resolve the active instance's snapshot by `instanceId` so a custom + // instance gets its own slash commands, skills, and model list — not + // the first snapshot for the same driver kind. + const selectedProviderEntry = useMemo( + () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), + [providerInstanceEntries, selectedInstanceId], + ); const selectedProviderStatus = useMemo( - () => providerStatuses.find((provider) => provider.provider === selectedProvider), - [providerStatuses, selectedProvider], + () => selectedProviderEntry?.snapshot ?? null, + [selectedProviderEntry], + ); + const selectedProviderModels = useMemo>( + () => selectedProviderEntry?.models ?? [], + [selectedProviderEntry], ); const composerProviderState = useMemo( @@ -615,29 +724,30 @@ export const ChatComposer = memo( [providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( - () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), - [selectedModel, selectedModelOptionsForDispatch, selectedProvider], + () => + createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), + [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], ); const selectedModelForPicker = selectedModel; - const modelOptionsByProvider = useMemo< - Record> - >( - () => ({ - codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], - claudeAgent: - providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], - opencode: - providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], - cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], - }), - [providerStatuses], - ); + // Instance-keyed option list so the picker can show each configured + // instance (built-in + custom) as a first-class sidebar entry. The + // options are server-reported models plus that exact instance's + // configured custom models; selected slugs are not injected into lists. + const modelOptionsByInstance = useMemo< + ReadonlyMap> + >(() => { + const out = new Map>(); + for (const entry of providerInstanceEntries) { + out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); + } + return out; + }, [providerInstanceEntries, settings]); const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; + const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); - }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]); // ------------------------------------------------------------------ // Context window @@ -1889,12 +1999,13 @@ export const ChatComposer = memo(
{ setIsComposerModelPickerOpen(open); }} - onProviderModelChange={onProviderModelSelect} + onInstanceModelChange={onProviderModelSelect} /> {isComposerFooterCompact ? ( diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 1808b94033c..49eb5fbb94b 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,7 +1,10 @@ import { + DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, EnvironmentId, ModelSelection, + ProviderInstanceId, + ProviderDriverKind, ThreadId, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; @@ -50,8 +53,10 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str const threadId = ThreadId.make("thread-compact-menu"); const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); const threadKey = scopedThreadKey(threadRef); - const provider = props?.modelSelection?.provider ?? "claudeAgent"; - const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; + const provider = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make(props?.modelSelection?.instanceId ?? provider); + const model = + props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; useComposerDraftStore.setState({ draftsByThreadKey: { @@ -62,9 +67,9 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str persistedAttachments: [], terminalContexts: [], modelSelectionByProvider: { - [provider]: createModelSelection(provider, model, props?.modelSelection?.options), + [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), }, - activeProvider: provider, + activeProvider: instanceId, runtimeMode: null, interactionMode: null, }, @@ -178,7 +183,10 @@ describe("CompactComposerControlsMenu", () => { it("shows fast mode controls for Opus", async () => { await using _ = await mountMenu({ - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + ), }); await page.getByLabelText("More composer controls").click(); @@ -193,7 +201,10 @@ describe("CompactComposerControlsMenu", () => { it("hides fast mode controls for non-Opus Claude models", async () => { await using _ = await mountMenu({ - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + ), }); await page.getByLabelText("More composer controls").click(); @@ -205,7 +216,10 @@ describe("CompactComposerControlsMenu", () => { it("shows only the provided effort options", async () => { await using _ = await mountMenu({ - modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + ), }); await page.getByLabelText("More composer controls").click(); @@ -222,9 +236,11 @@ describe("CompactComposerControlsMenu", () => { it("shows a Claude thinking on/off section for Haiku", async () => { await using _ = await mountMenu({ - modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ - { id: "thinking", value: true }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "thinking", value: true }], + ), }); await page.getByLabelText("More composer controls").click(); @@ -239,9 +255,11 @@ describe("CompactComposerControlsMenu", () => { it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { await using _ = await mountMenu({ - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "high" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "high" }], + ), prompt: "Ultrathink:\nInvestigate this", }); @@ -256,9 +274,11 @@ describe("CompactComposerControlsMenu", () => { it("warns when ultrathink appears in prompt body text", async () => { await using _ = await mountMenu({ - modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ - { id: "effort", value: "high" }, - ]), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "high" }], + ), prompt: "Ultrathink:\nplease ultrathink about this problem", }); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 5d13e6593b4..f687ec7ba23 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,6 +1,6 @@ import { type ProjectEntry, - type ProviderKind, + type ProviderDriverKind, type ServerProviderSkill, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -39,7 +39,7 @@ export type ComposerCommandItem = | { id: string; type: "provider-slash-command"; - provider: ProviderKind; + provider: ProviderDriverKind; command: ServerProviderSlashCommand; label: string; description: string; @@ -47,7 +47,7 @@ export type ComposerCommandItem = | { id: string; type: "skill"; - provider: ProviderKind; + provider: ProviderDriverKind; skill: ServerProviderSkill; label: string; description: string; diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 6cd097ad1fe..064df338e46 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,9 +1,8 @@ -import { type ProviderKind } from "@t3tools/contracts"; +import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { memo } from "react"; import { StarIcon } from "lucide-react"; import { getDisplayModelName, - getProviderLabel, getTriggerDisplayModelLabel, type ModelEsque, PROVIDER_ICON_BY_PROVIDER, @@ -16,7 +15,17 @@ import { cn } from "~/lib/utils"; export const ModelListRow = memo(function ModelListRow(props: { index: number; model: ModelEsque; - provider: ProviderKind; + /** Instance the model belongs to — the routing key used in combobox values. */ + instanceId: ProviderInstanceId; + /** Driver kind of the instance — used for the provider icon glyph. */ + driverKind: ProviderDriverKind; + /** + * Display name to show in the secondary line (provider footer). Usually + * the instance's configured `displayName` so custom instances like + * "Codex Personal" render with their user-authored label. + */ + providerDisplayName: string; + providerAccentColor?: string | undefined; isFavorite: boolean; showProvider: boolean; preferShortName?: boolean; @@ -25,13 +34,16 @@ export const ModelListRow = memo(function ModelListRow(props: { jumpLabel?: string | null; onToggleFavorite: () => void; }) { - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const providerLabel = props.model.subProvider + ? `${props.providerDisplayName} · ${props.model.subProvider}` + : props.providerDisplayName; return ( {props.showProvider && (
- + {ProviderIcon ? : null} + {props.providerAccentColor ? ( + + ) : null} - {getProviderLabel(props.provider, props.model)} + {providerLabel}
)} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 82720425ef5..c3468ef8c65 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,8 +1,7 @@ import { - type ProviderKind, - PROVIDER_DISPLAY_NAMES, + type ProviderInstanceId, + type ProviderDriverKind, type ResolvedKeybindingsConfig, - type ServerProvider, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; @@ -22,40 +21,89 @@ import { import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { TooltipProvider } from "../ui/tooltip"; +import type { ProviderInstanceEntry } from "../../providerInstances"; +import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { slug: string; name: string; shortName?: string; subProvider?: string; - provider: ProviderKind; + instanceId: ProviderInstanceId; + driverKind: ProviderDriverKind; + instanceDisplayName: string; + instanceAccentColor?: string | undefined; + continuationGroupKey?: string | undefined; }; const EMPTY_MODEL_JUMP_LABELS = new Map(); +// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs +// can contain colons (e.g. some vendor model ids), so we only split on the +// first colon — anything after that is the slug. +function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; slug: string } { + const colonIndex = key.indexOf(":"); + if (colonIndex === -1) { + return { instanceId: key as ProviderInstanceId, slug: "" }; + } + return { + instanceId: key.slice(0, colonIndex) as ProviderInstanceId, + slug: key.slice(colonIndex + 1), + }; +} + export const ModelPickerContent = memo(function ModelPickerContent(props: { - provider: ProviderKind; + /** The instance currently selected in the composer (combobox "value"). */ + activeInstanceId: ProviderInstanceId; model: string; - lockedProvider: ProviderKind | null; - providers?: ReadonlyArray; + /** + * When set, the picker is locked to the given driver kind — typically + * because the user is editing a previously-sent message and can't change + * which driver served the turn. Multiple instances of the same kind + * remain selectable (e.g. locked to `codex` still lets the user switch + * between the default Codex and a custom Codex Personal). + */ + lockedProvider: ProviderDriverKind | null; + lockedContinuationGroupKey?: string | null; + /** + * All configured provider instances in display order. Used to render + * the sidebar (one button per instance) and to resolve display names + * for the locked-mode header. + */ + instanceEntries: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; - modelOptionsByProvider: Record>; + /** + * Model options per instance. Keyed by `ProviderInstanceId` so the + * default Codex instance and any custom Codex instances each have their + * own list (custom instances typically start with the same built-in + * model set but are free to diverge via customModels). + */ + modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; onRequestClose?: () => void; - onProviderModelChange: (provider: ProviderKind, model: string) => void; + onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { - const { keybindings: providedKeybindings, modelOptionsByProvider, onProviderModelChange } = props; + const { + keybindings: providedKeybindings, + modelOptionsByInstance, + instanceEntries, + onInstanceModelChange, + } = props; const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); const listRegionRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useSettings((s) => s.favorites ?? []); - const [selectedProvider, setSelectedProvider] = useState(() => { - if (props.lockedProvider !== null) { - return props.lockedProvider; - } - return favorites.length > 0 ? "favorites" : props.provider; - }); + const [selectedInstanceId, setSelectedInstanceId] = useState( + () => { + if (props.lockedProvider !== null) { + // When locked, prime the sidebar to the currently-active instance + // so jumping into the picker keeps the focused instance visible. + return props.activeInstanceId; + } + return favorites.length > 0 ? "favorites" : props.activeInstanceId; + }, + ); const keybindings = useMemo( () => providedKeybindings ?? [], [providedKeybindings], @@ -66,9 +114,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { searchInputRef.current?.focus({ preventScroll: true }); }, []); - const handleSelectProvider = useCallback( - (provider: ProviderKind | "favorites") => { - setSelectedProvider(provider); + const handleSelectInstance = useCallback( + (instanceId: ProviderInstanceId | "favorites") => { + setSelectedInstanceId(instanceId); window.requestAnimationFrame(() => { focusSearchInput(); }); @@ -90,44 +138,97 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [focusSearchInput]); - // Create a Set for efficient lookup + // Create a Set for efficient lookup. Favorites are keyed by + // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind + // to ProviderInstanceId so pre-migration favorites keyed by driver slugs + // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals + // the driver slug. const favoritesSet = useMemo(() => { - return new Set(favorites.map((fav) => `${fav.provider}:${fav.model}`)); - }, [favorites]); - const favoriteOrder = useMemo(() => { - return new Map( - favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), - ); + return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); }, [favorites]); - const readyProviderSet = useMemo(() => { - if (!props.providers || props.providers.length === 0) { - return null; + /** + * Lookup table keyed by `instanceId`. Used for display name + driver + * kind enrichment and for `ready`/enabled filtering before flattening + * models into the search list. + */ + const entryByInstanceId = useMemo( + () => new Map(instanceEntries.map((entry) => [entry.instanceId, entry])), + [instanceEntries], + ); + const matchesLockedProvider = useCallback( + (entry: Pick): boolean => { + if (props.lockedProvider === null) return true; + if (entry.driverKind !== props.lockedProvider) return false; + if (!props.lockedContinuationGroupKey) return true; + return entry.continuationGroupKey === props.lockedContinuationGroupKey; + }, + [props.lockedContinuationGroupKey, props.lockedProvider], + ); + + const readyInstanceSet = useMemo(() => { + const ready = new Set(); + for (const entry of instanceEntries) { + if (entry.status === "ready") { + ready.add(entry.instanceId); + } } - return new Set( - props.providers - .filter((provider) => provider.status === "ready") - .map((provider) => provider.provider), - ); - }, [props.providers]); - - // Flatten models into a searchable array + return ready; + }, [instanceEntries]); + + // Flatten models into a searchable array. One pass over the + // instance-keyed map; each model carries its instance id + driver kind + // so the list row can render the right icon and display name without + // another lookup. const flatModels = useMemo(() => { - return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { - if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { - return []; + const out: ModelPickerItem[] = []; + for (const [instanceId, models] of modelOptionsByInstance) { + const entry = entryByInstanceId.get(instanceId); + if (!entry) { + // Instance disappeared between renders (configuration change). Skip + // its models — stale options shouldn't appear in the picker. + continue; } - return models.map((m) => ({ - slug: m.slug, - name: m.name, - ...(m.shortName ? { shortName: m.shortName } : {}), - ...(m.subProvider ? { subProvider: m.subProvider } : {}), - provider: providerKind as ProviderKind, - })) satisfies Array; - }); - }, [props.modelOptionsByProvider, readyProviderSet]); + if (!readyInstanceSet.has(instanceId)) { + continue; + } + for (const model of models) { + out.push({ + slug: model.slug, + name: model.name, + ...(model.shortName ? { shortName: model.shortName } : {}), + ...(model.subProvider ? { subProvider: model.subProvider } : {}), + instanceId, + driverKind: entry.driverKind, + instanceDisplayName: entry.displayName, + ...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}), + ...(entry.continuationGroupKey + ? { continuationGroupKey: entry.continuationGroupKey } + : {}), + }); + } + } + return out; + }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); + + const isLocked = props.lockedProvider !== null; + const isSearching = searchQuery.trim().length > 0; + const lockedInstanceEntries = useMemo( + () => + props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], + [instanceEntries, matchesLockedProvider, props.lockedProvider], + ); + const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; + const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); + const sidebarInstanceEntries = showLockedInstanceSidebar + ? lockedInstanceEntries + : instanceEntries; + const instanceOrder = useMemo( + () => instanceEntries.map((entry) => entry.instanceId), + [instanceEntries], + ); - // Filter models based on search query and selected provider + // Filter models based on search query and selected instance const filteredModels = useMemo(() => { let result = flatModels; @@ -138,13 +239,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { model, score: scoreModelPickerSearch( { - ...model, - isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + name: model.name, + ...(model.shortName ? { shortName: model.shortName } : {}), + ...(model.subProvider ? { subProvider: model.subProvider } : {}), + driverKind: model.driverKind, + providerDisplayName: model.instanceDisplayName, + isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), }, searchQuery, ), - isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), - tieBreaker: buildModelPickerSearchText(model), + isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), + tieBreaker: buildModelPickerSearchText({ + name: model.name, + ...(model.shortName ? { shortName: model.shortName } : {}), + ...(model.subProvider ? { subProvider: model.subProvider } : {}), + driverKind: model.driverKind, + providerDisplayName: model.instanceDisplayName, + }), })) .filter( ( @@ -157,10 +268,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } => rankedModel.score !== null, ); - // When searching, we only respect locked provider, ignoring sidebar selection + // When searching, we only respect locked provider (by driver kind), + // ignoring sidebar selection so account-scoped searches can find a + // model before the user chooses a specific instance rail item. if (props.lockedProvider !== null) { return rankedMatches - .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider) + .filter((rankedModel) => matchesLockedProvider(rankedModel.model)) .toSorted((a, b) => { const scoreDelta = a.score - b.score; if (scoreDelta !== 0) { @@ -188,72 +301,87 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .map((rankedModel) => rankedModel.model); } - // Locked provider mode always shows that provider's models, with favorites first. if (props.lockedProvider !== null) { - result = result.filter((m) => m.provider === props.lockedProvider); - } else if (selectedProvider === "favorites") { - result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); + result = result.filter((m) => matchesLockedProvider(m)); + if (showLockedInstanceSidebar) { + result = result.filter((m) => m.instanceId === selectedInstanceId); + } + } else if (selectedInstanceId === "favorites") { + result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); } else { - result = result.filter((m) => m.provider === selectedProvider); + result = result.filter((m) => m.instanceId === selectedInstanceId); } - return result.toSorted((a, b) => { - const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`); - const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`); - - if (aOrder !== undefined && bOrder !== undefined) { - return aOrder - bOrder; - } - if (aOrder !== undefined) { - return -1; - } - if (bOrder !== undefined) { - return 1; - } - return 0; + return sortProviderModelItems(result, { + favoriteModelKeys: favoritesSet, + groupFavorites: selectedInstanceId !== "favorites", + instanceOrder: selectedInstanceId === "favorites" ? instanceOrder : [], }); }, [ - favoriteOrder, favoritesSet, flatModels, + instanceOrder, + matchesLockedProvider, props.lockedProvider, searchQuery, - selectedProvider, + showLockedInstanceSidebar, + selectedInstanceId, ]); const handleModelSelect = useCallback( - (modelSlug: string, provider: ProviderKind) => { - const resolvedModel = resolveSelectableModel( - provider, - modelSlug, - modelOptionsByProvider[provider], - ); + (modelSlug: string, instanceId: ProviderInstanceId) => { + const options = modelOptionsByInstance.get(instanceId); + if (!options) { + return; + } + const entry = entryByInstanceId.get(instanceId); + if (!entry) { + return; + } + // `resolveSelectableModel` uses the driver kind for normalization + // (slug casing etc.). Custom instances share their driver's + // normalization rules, so pass the driver kind here. + const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); if (resolvedModel) { - onProviderModelChange(provider, resolvedModel); + onInstanceModelChange(instanceId, resolvedModel); } }, - [modelOptionsByProvider, onProviderModelChange], + [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange], ); const toggleFavorite = useCallback( - (provider: ProviderKind, model: string) => { + (instanceId: ProviderInstanceId, model: string) => { const newFavorites = [...favorites]; - const index = newFavorites.findIndex((f) => f.provider === provider && f.model === model); + const index = newFavorites.findIndex((f) => f.provider === instanceId && f.model === model); if (index >= 0) { newFavorites.splice(index, 1); } else { - newFavorites.push({ provider, model }); + newFavorites.push({ provider: instanceId, model }); } updateSettings({ favorites: newFavorites }); }, [favorites, updateSettings], ); - const isLocked = props.lockedProvider !== null; - const isSearching = searchQuery.trim().length > 0; - const showSidebar = !isLocked && !isSearching; const LockedProviderIcon = isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; + // Header label for locked mode. Use the active instance's displayName + // when the lock narrows to exactly one instance (so "Codex Personal" + // shows instead of the generic driver label); fall back to the first + // matching entry otherwise. + const lockedHeaderLabel = useMemo(() => { + if (!isLocked || !props.lockedProvider) return null; + const matches = instanceEntries.filter((entry) => matchesLockedProvider(entry)); + if (matches.length === 0) return null; + const active = matches.find((entry) => entry.instanceId === props.activeInstanceId); + return (active ?? matches[0])?.displayName ?? null; + }, [ + isLocked, + matchesLockedProvider, + props.lockedProvider, + props.activeInstanceId, + instanceEntries, + ]); const modelJumpCommandByKey = useMemo(() => { const mapping = new Map< string, @@ -264,7 +392,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!jumpCommand) { return mapping; } - mapping.set(`${model.provider}:${model.slug}`, jumpCommand); + mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand); } return mapping; }, [filteredModels]); @@ -273,16 +401,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [modelJumpCommandByKey], ); const allModelKeys = useMemo( - (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`), + (): string[] => flatModels.map((model) => `${model.instanceId}:${model.slug}`), [flatModels], ); const filteredModelKeys = useMemo( - (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`), + (): string[] => filteredModels.map((model) => `${model.instanceId}:${model.slug}`), [filteredModels], ); const filteredModelByKey = useMemo( (): ReadonlyMap => - new Map(filteredModels.map((model) => [`${model.provider}:${model.slug}`, model] as const)), + new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), [filteredModels], ); const modelJumpShortcutContext = useMemo( @@ -331,10 +459,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!targetModelKey) { return; } - const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string]; + const { instanceId, slug } = splitInstanceModelKey(targetModelKey); event.preventDefault(); event.stopPropagation(); - handleModelSelect(slug, provider); + handleModelSelect(slug, instanceId); }; window.addEventListener("keydown", onWindowKeyDown, true); @@ -392,25 +520,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Locked provider header (only shown in locked mode) */} - {isLocked && LockedProviderIcon && props.lockedProvider && ( + {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && (
- - {PROVIDER_DISPLAY_NAMES[props.lockedProvider]} - + {lockedHeaderLabel}
)} {/* Sidebar (only in unlocked mode) */} {showSidebar && ( )} @@ -422,7 +550,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { filter={null} autoHighlight open - value={`${props.provider}:${props.model}`} + value={`${props.activeInstanceId}:${props.model}`} onItemHighlighted={(modelKey) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; }} @@ -430,14 +558,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (typeof modelKey !== "string") { return; } - const [provider, slug] = modelKey.split(":") as [ProviderKind, string]; - handleModelSelect(slug, provider); + const { instanceId, slug } = splitInstanceModelKey(modelKey); + handleModelSelect(slug, instanceId); }} >
{/* Search bar */} @@ -464,11 +592,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ).preventBaseUIHandler?.(); e.preventDefault(); e.stopPropagation(); - const [provider, slug] = highlightedModelKeyRef.current.split(":") as [ - ProviderKind, - string, - ]; - handleModelSelect(slug, provider); + const { instanceId, slug } = splitInstanceModelKey( + highlightedModelKeyRef.current, + ); + handleModelSelect(slug, instanceId); return; } e.stopPropagation(); @@ -495,14 +622,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { key={modelKey} index={index} model={model} - provider={model.provider} + instanceId={model.instanceId} + driverKind={model.driverKind} + providerDisplayName={model.instanceDisplayName} + providerAccentColor={model.instanceAccentColor} isFavorite={favoritesSet.has(modelKey)} - showProvider={!isLocked} + showProvider={!isLocked || showLockedInstanceSidebar} preferShortName={!isLocked} - useTriggerLabel={isLocked} - showNewBadge={isModelPickerNewModel(model.provider, model.slug)} + useTriggerLabel={isLocked && !showLockedInstanceSidebar} + showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} - onToggleFavorite={() => toggleFavorite(model.provider, model.slug)} + onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} /> ); })} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index b4f23cacdd5..121b5267a3d 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,28 +1,32 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { memo } from "react"; +import { type ProviderInstanceId } from "@t3tools/contracts"; +import { memo, useMemo } from "react"; import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; import { Gemini, GithubCopilotIcon } from "../Icons"; -import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { getProviderSnapshot } from "../../providerModels"; +import type { ProviderInstanceEntry } from "../../providerInstances"; -function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string { - if (!live) { - return `${label} — waiting for provider status…`; - } - if (live.status === "ready") { +/** + * Build the hover tooltip for an instance button. Mirrors the old + * kind-based copy but uses the entry's configured `displayName` so custom + * instances get their user-authored name (e.g. "Codex Personal — Unavailable."). + */ +function describeUnavailableInstance(entry: ProviderInstanceEntry): string { + const label = entry.displayName; + if (entry.status === "ready") { return label; } const kind = - live.status === "error" + entry.status === "error" ? "Unavailable" - : live.status === "warning" + : entry.status === "warning" ? "Limited" - : live.status === "disabled" + : entry.status === "disabled" ? "Disabled in settings" : "Not ready"; - const msg = live.message?.trim(); + const msg = entry.snapshot.message?.trim(); return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; } @@ -39,172 +43,221 @@ const PICKER_TOOLTIP_SIDE = "left" as const; const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { - selectedProvider: ProviderKind | "favorites"; - onSelectProvider: (provider: ProviderKind | "favorites") => void; - providers?: ReadonlyArray; + selectedInstanceId: ProviderInstanceId | "favorites"; + onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; + /** + * Instance entries to render as rail buttons. Each entry becomes one icon + * keyed by `instanceId`, so the default built-in Codex and a user-authored + * `codex_personal` appear as two distinct rail items, each routing to + * their own model list. + */ + instanceEntries: ReadonlyArray; + /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ + showFavorites?: boolean; + /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ + showComingSoon?: boolean; + /** + * Instance id values that should render the "new" sparkle badge. Callers + * pass the subset of default built-in ids they want flagged (custom + * instances are never flagged — the user just made them). + */ + newBadgeInstanceIds?: ReadonlySet; }) { - const handleProviderClick = (provider: ProviderKind | "favorites") => { - props.onSelectProvider(provider); + const handleSelect = (instanceId: ProviderInstanceId | "favorites") => { + props.onSelectInstance(instanceId); }; + const showFavorites = props.showFavorites ?? true; + const showComingSoon = props.showComingSoon ?? true; + const duplicateDriverCounts = useMemo(() => { + const counts = new Map(); + for (const entry of props.instanceEntries) { + counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); + } + return counts; + }, [props.instanceEntries]); return ( -
- {/* Favorites section */} -
-
- {props.selectedProvider === "favorites" &&
} - - handleProviderClick("favorites")} - type="button" - data-model-picker-provider="favorites" - aria-label="Favorites" + +
+ {/* Favorites section */} + {showFavorites ? ( +
+
+ {props.selectedInstanceId === "favorites" && ( +
+ )} + + handleSelect("favorites")} + type="button" + data-model-picker-provider="favorites" + aria-label="Favorites" + > + + + } + /> + - - - } - /> - - Favorites - - -
-
+ Favorites + + +
+
+ ) : null} - {/* Provider buttons */} - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - const liveProvider = props.providers - ? getProviderSnapshot(props.providers, option.value) - : undefined; + {/* Instance buttons (one per configured instance — built-in + custom) */} + {props.instanceEntries.map((entry) => { + const isDisabled = !entry.isAvailable || entry.status !== "ready"; + const isSelected = props.selectedInstanceId === entry.instanceId; + const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; + const showInstanceBadge = + Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; - const isDisabled = !liveProvider || liveProvider.status !== "ready"; - const isSelected = props.selectedProvider === option.value; - const badge = option.pickerSidebarBadge; + const tooltip = isDisabled + ? describeUnavailableInstance(entry) + : showNewBadge + ? `${entry.displayName} — New` + : entry.displayName; - const providerTooltip = isDisabled - ? describeUnavailableProvider(option.label, liveProvider) - : badge === "new" - ? `${option.label} — New` - : option.label; + const button = ( + + ); - const button = ( - - ); + const trigger = isDisabled ? ( + {button} + ) : ( + button + ); - const trigger = isDisabled ? ( - {button} - ) : ( - button - ); + return ( +
+ {isSelected &&
} + + + + {tooltip} + + +
+ ); + })} - return ( -
- {isSelected &&
} + {showComingSoon ? ( + <> + {/* Gemini button (coming soon) */} - + + + + } + /> - {providerTooltip} + Gemini — Coming soon -
- ); - })} - - {/* Gemini button (coming soon) */} - - - - - } - /> - - Gemini — Coming soon - - - {/* Github Copilot button (coming soon) */} - - - + + } + /> + - - - - - - - } - /> - - Github Copilot — Coming soon - - -
+ Github Copilot — Coming soon + + + + ) : null} +
+
); }); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx new file mode 100644 index 00000000000..154cada19aa --- /dev/null +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -0,0 +1,73 @@ +import { type CSSProperties, memo } from "react"; +import { type ProviderDriverKind } from "@t3tools/contracts"; + +import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { cn } from "~/lib/utils"; + +export function providerInstanceInitials(label: string): string { + const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean); + if (words.length === 0) return ""; + if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase(); + return words + .slice(0, 2) + .map((word) => word[0]?.toUpperCase() ?? "") + .join(""); +} + +export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { + driverKind: ProviderDriverKind; + displayName: string; + accentColor?: string | undefined; + showBadge?: boolean; + className?: string; + iconClassName?: string; + badgeClassName?: string; + statusDotClassName?: string; +}) { + const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const accentStyle = props.accentColor + ? ({ "--provider-accent": props.accentColor } as CSSProperties) + : undefined; + + return ( + + {Icon ? ( + + ) : ( + + {providerInstanceInitials(props.displayName)} + + )} + {props.statusDotClassName ? ( + + ) : null} + {props.showBadge ? ( + + {providerInstanceInitials(props.displayName)} + + ) : null} + + ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index a5f539ae5ac..d3b168876a6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,4 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { EnvironmentId } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { page, userEvent } from "vitest/browser"; @@ -6,8 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { ProviderModelPicker } from "./ProviderModelPicker"; -import { getCustomModelOptionsByProvider } from "../../modelSelection"; -import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import type { ModelEsque } from "./providerIconUtils"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + type UnifiedSettings, +} from "@t3tools/contracts/settings"; import { __resetLocalApiForTests } from "../../localApi"; // Mock the environments/runtime module to provide a mock primary environment connection @@ -93,7 +102,8 @@ function booleanDescriptor(id: string, label: string) { const TEST_PROVIDERS: ReadonlyArray = [ { - provider: "codex", + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), displayName: "Codex", enabled: true, installed: true, @@ -137,7 +147,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], }, { - provider: "claudeAgent", + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), displayName: "Claude", enabled: true, installed: true, @@ -199,9 +210,14 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ]; +const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); +const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent"); +const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); + function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { return { - provider: "codex", + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), displayName: "Codex", enabled: true, installed: true, @@ -217,7 +233,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { return { - provider: "opencode", + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode"), enabled: true, installed: true, version: "1.0.0", @@ -231,37 +248,47 @@ function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider } async function mountPicker(props: { - provider: ProviderKind; + activeInstanceId?: ProviderInstanceId; model: string; - lockedProvider: ProviderKind | null; + lockedProvider: ProviderDriverKind | null; + lockedContinuationGroupKey?: string | null; providers?: ReadonlyArray; + settings?: UnifiedSettings; triggerVariant?: "ghost" | "outline"; }) { const host = document.createElement("div"); document.body.append(host); - const onProviderModelChange = vi.fn(); + const onInstanceModelChange = vi.fn(); const providers = props.providers ?? TEST_PROVIDERS; - const modelOptionsByProvider = getCustomModelOptionsByProvider( - DEFAULT_UNIFIED_SETTINGS, + const instanceEntries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const activeInstanceId = props.activeInstanceId ?? CODEX_INSTANCE_ID; + const modelOptionsByInstance = getCustomModelOptionsByInstance( + props.settings ?? DEFAULT_UNIFIED_SETTINGS, providers, - props.provider, + activeInstanceId, props.model, ); const screen = await render( , { container: host }, ); return { - onProviderModelChange, + onInstanceModelChange, + // Back-compat alias used by callers that still assert on the old callback + // name. Delegates to the instance-aware mock so existing expectations work. + get onProviderModelChange() { + return onInstanceModelChange; + }, cleanup: async () => { await screen.unmount(); host.remove(); @@ -304,7 +331,7 @@ describe("ProviderModelPicker", () => { it("shows provider sidebar in unlocked mode", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -325,7 +352,7 @@ describe("ProviderModelPicker", () => { it("shows favorites first in the provider sidebar", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -347,7 +374,7 @@ describe("ProviderModelPicker", () => { it("filters models by selected provider in sidebar", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -379,9 +406,37 @@ describe("ProviderModelPicker", () => { } }); + it("uses client model visibility and ordering preferences", async () => { + const mounted = await mountPicker({ + activeInstanceId: CLAUDE_INSTANCE_ID, + model: "claude-opus-4-6", + lockedProvider: null, + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + providerModelPreferences: { + [CLAUDE_INSTANCE_ID]: { + hiddenModels: ["claude-opus-4-6"], + modelOrder: ["claude-haiku-4-5", "claude-sonnet-4-6"], + }, + }, + }, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Haiku 4.5", "Claude Sonnet 4.6"]); + expect(getModelPickerListText()).not.toContain("Claude Opus 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + it("focuses the search input after selecting a sidebar provider", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -419,9 +474,9 @@ describe("ProviderModelPicker", () => { ); const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", - lockedProvider: "claudeAgent", + lockedProvider: ProviderDriverKind.make("claudeAgent"), }); try { @@ -443,27 +498,115 @@ describe("ProviderModelPicker", () => { } }); + it("keeps an instance sidebar in locked mode when that provider has multiple instances", async () => { + const defaultCodexModels: ServerProvider["models"] = [ + { + slug: "gpt-work", + name: "GPT Work", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ]; + const personalCodexModels: ServerProvider["models"] = [ + { + slug: "gpt-personal", + name: "GPT Personal", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ]; + const isolatedCodexModels: ServerProvider["models"] = [ + { + slug: "gpt-isolated", + name: "GPT Isolated", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ]; + const providers: ReadonlyArray = [ + { + ...buildCodexProvider(defaultCodexModels), + instanceId: "codex" as ProviderInstanceId, + displayName: "Codex Work", + accentColor: "#2563eb", + continuation: { groupKey: "codex:home:/Users/julius/.codex" }, + }, + { + ...buildCodexProvider(personalCodexModels), + instanceId: "codex_personal" as ProviderInstanceId, + displayName: "Codex Personal", + accentColor: "#dc2626", + continuation: { groupKey: "codex:home:/Users/julius/.codex" }, + }, + { + ...buildCodexProvider(isolatedCodexModels), + instanceId: "codex_isolated" as ProviderInstanceId, + displayName: "Codex Isolated", + accentColor: "#16a34a", + continuation: { groupKey: "codex:home:/Users/julius/.codex_isolated" }, + }, + TEST_PROVIDERS[1]!, + ]; + const mounted = await mountPicker({ + activeInstanceId: "codex" as ProviderInstanceId, + model: "gpt-work", + lockedProvider: ProviderDriverKind.make("codex"), + lockedContinuationGroupKey: "codex:home:/Users/julius/.codex", + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder()).toEqual(["codex", "codex_personal"]); + expect(getModelPickerListText()).not.toContain("Codex Isolated"); + expect( + document.querySelector('[data-model-picker-provider="codex_personal"]') + ?.dataset.providerAccentColor, + ).toBe("#dc2626"); + expect(getModelPickerListText()).toContain("Codex Work"); + expect(getVisibleModelNames()).toEqual(["GPT Work"]); + }); + + await page.getByRole("button", { name: "Codex Personal" }).click(); + + await vi.waitFor(() => { + expect(getModelPickerListText()).toContain("Codex Personal"); + expect(getVisibleModelNames()).toEqual(["GPT Personal"]); + }); + } finally { + await mounted.cleanup(); + } + }); + it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { const host = document.createElement("div"); document.body.append(host); - const onProviderModelChange = vi.fn(); - const modelOptionsByProvider = { - claudeAgent: [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + const onInstanceModelChange = vi.fn(); + const modelOptionsByInstance = new Map>([ + [ + "claudeAgent" as ProviderInstanceId, + [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], ], - codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }], - cursor: [], - opencode: [], - } as const; + ["codex" as ProviderInstanceId, [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }]], + ["cursor" as ProviderInstanceId, []], + ["opencode" as ProviderInstanceId, []], + ]); + const instanceEntries = sortProviderInstanceEntries( + deriveProviderInstanceEntries(TEST_PROVIDERS), + ); const screen = await render( , { container: host }, ); @@ -504,9 +647,9 @@ describe("ProviderModelPicker", () => { ]), ]; const mounted = await mountPicker({ - provider: "opencode", + activeInstanceId: OPENCODE_INSTANCE_ID, model: "github-copilot/claude-opus-4.5", - lockedProvider: "opencode", + lockedProvider: ProviderDriverKind.make("opencode"), providers, }); @@ -531,7 +674,7 @@ describe("ProviderModelPicker", () => { it("searches models by name in flat list", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -561,9 +704,9 @@ describe("ProviderModelPicker", () => { it("supports arrow-key navigation in the model picker", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", - lockedProvider: "claudeAgent", + lockedProvider: ProviderDriverKind.make("claudeAgent"), }); try { @@ -600,7 +743,7 @@ describe("ProviderModelPicker", () => { it("hides the provider sidebar while searching", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -624,7 +767,7 @@ describe("ProviderModelPicker", () => { it("closes the picker when escape is pressed in search", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -652,7 +795,7 @@ describe("ProviderModelPicker", () => { it("searches models by provider name", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -718,7 +861,7 @@ describe("ProviderModelPicker", () => { ]), ]; const mounted = await mountPicker({ - provider: "opencode", + activeInstanceId: OPENCODE_INSTANCE_ID, model: "github-copilot/claude-opus-4.7", lockedProvider: null, providers, @@ -780,7 +923,7 @@ describe("ProviderModelPicker", () => { }, ]; const mounted = await mountPicker({ - provider: "opencode", + activeInstanceId: OPENCODE_INSTANCE_ID, model: "github-copilot/claude-opus-4.7", lockedProvider: null, providers, @@ -805,7 +948,7 @@ describe("ProviderModelPicker", () => { localStorage.removeItem("t3code:client-settings:v1"); const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -850,7 +993,7 @@ describe("ProviderModelPicker", () => { localStorage.removeItem("t3code:client-settings:v1"); const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", lockedProvider: null, }); @@ -890,7 +1033,6 @@ describe("ProviderModelPicker", () => { ); const mounted = await mountPicker({ - provider: "codex", model: "gpt-5-codex", lockedProvider: null, }); @@ -910,9 +1052,9 @@ describe("ProviderModelPicker", () => { it("dispatches callback with correct provider and model when selected", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", + activeInstanceId: CLAUDE_INSTANCE_ID, model: "claude-opus-4-6", - lockedProvider: "claudeAgent", + lockedProvider: ProviderDriverKind.make("claudeAgent"), }); try { @@ -995,7 +1137,6 @@ describe("ProviderModelPicker", () => { ]; const hidden = await mountPicker({ - provider: "codex", model: "gpt-5.3-codex", lockedProvider: null, providers: providersWithoutSpark, @@ -1014,7 +1155,6 @@ describe("ProviderModelPicker", () => { } const visible = await mountPicker({ - provider: "codex", model: "gpt-5.3-codex", lockedProvider: null, providers: providersWithSpark, @@ -1034,7 +1174,7 @@ describe("ProviderModelPicker", () => { it("shows disabled providers grayed out in sidebar", async () => { const disabledProviders = TEST_PROVIDERS.slice(); const claudeIndex = disabledProviders.findIndex( - (provider) => provider.provider === "claudeAgent", + (provider) => provider.instanceId === ProviderInstanceId.make("claudeAgent"), ); if (claudeIndex >= 0) { const claudeProvider = disabledProviders[claudeIndex]!; @@ -1046,7 +1186,6 @@ describe("ProviderModelPicker", () => { } const mounted = await mountPicker({ - provider: "codex", model: "gpt-5-codex", lockedProvider: null, providers: disabledProviders, @@ -1068,7 +1207,6 @@ describe("ProviderModelPicker", () => { it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ - provider: "codex", model: "gpt-5-codex", lockedProvider: null, triggerVariant: "outline", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 4f4140f834d..4a2860ee469 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,9 +1,9 @@ import { - type ProviderKind, + type ProviderInstanceId, + type ProviderDriverKind, type ResolvedKeybindingsConfig, - type ServerProvider, } from "@t3tools/contracts"; -import { memo, useEffect, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; @@ -11,21 +11,28 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { ModelPickerContent } from "./ModelPickerContent"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, - PROVIDER_ICON_BY_PROVIDER, getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; import { setModelPickerOpen } from "../../modelPickerOpenState"; +import type { ProviderInstanceEntry } from "../../providerInstances"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { - provider: ProviderKind; + /** + * The instance currently selected in the composer. Drives the trigger + * icon, label and the default-highlighted combobox row. + */ + activeInstanceId: ProviderInstanceId; model: string; - lockedProvider: ProviderKind | null; - providers?: ReadonlyArray; + lockedProvider: ProviderDriverKind | null; + lockedContinuationGroupKey?: string | null; + /** Instance entries rendered in the sidebar + used to resolve display name. */ + instanceEntries: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; - modelOptionsByProvider: Record>; + modelOptionsByInstance: ReadonlyMap>; activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; @@ -34,22 +41,36 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerVariant?: VariantProps["variant"]; triggerClassName?: string; onOpenChange?: (open: boolean) => void; - onProviderModelChange: (provider: ProviderKind, model: string) => void; + onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); - const activeProvider = props.lockedProvider ?? props.provider; const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; - const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - // If the current slug belongs to a different provider (for example after a provider - // switch or disable), prefer the active provider's first option so the trigger icon - // and label stay in sync instead of showing a stale foreign slug. + + // Resolve the active instance entry by exact routing key. The composer + // resolves fallbacks before rendering this component; if the selected + // instance disappears, do not infer a replacement from its driver kind. + const activeEntry = useMemo(() => { + return ( + props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null + ); + }, [props.activeInstanceId, props.instanceEntries]); + + const activeInstanceId = props.activeInstanceId; + const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; + // If the current slug belongs to a different instance (for example after + // a provider switch or disable), prefer the active instance's first + // option so the trigger icon and label stay in sync instead of showing + // a stale foreign slug. const selectedModel = - selectedProviderOptions.find((option) => option.slug === props.model) ?? - selectedProviderOptions[0]; - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; + selectedInstanceOptions.find((option) => option.slug === props.model) ?? + selectedInstanceOptions[0]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; const triggerSubtitle = selectedModel?.subProvider; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + const duplicateDriverCount = props.instanceEntries.filter( + (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, + ).length; + const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); @@ -65,9 +86,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }; }, [isMenuOpen]); - const handleProviderModelChange = (provider: ProviderKind, model: string) => { + const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { if (props.disabled) return; - props.onProviderModelChange(provider, model); + props.onInstanceModelChange(instanceId, model); setIsMenuOpen(false); }; @@ -103,10 +124,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { props.compact ? "max-w-36 sm:pl-1" : undefined, )} > -