diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 5535d54a5b4..73376110d9a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -30,3 +30,6 @@ github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 github:shivamhwp +github:jappyjan +github:justsomelegs +github:UtkarshUsername diff --git a/.gitignore b/.gitignore index 6c48782f9ac..d05180eb2a0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__ __screenshots__/ .tanstack squashfs-root/ +.scratch/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0be83f96dcf..34a061ffc70 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "effect": "catalog:", - "electron": "40.8.5", + "electron": "40.9.3", "electron-updater": "^6.6.2" }, "devDependencies": { @@ -28,8 +28,5 @@ "typescript": "catalog:", "vitest": "catalog:" }, - "productName": "T3 Code (Alpha)", - "trustedDependencies": [ - "electron" - ] + "productName": "T3 Code (Alpha)" } 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/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 6f37ae922da..7bdda075bc8 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -4,4 +4,20 @@ export default defineConfig({ server: { port: Number(process.env.PORT ?? 4173), }, + // Workaround: Astro 6.2.1's `astro:dev-toolbar` plugin registers an esbuild + // plugin via `optimizeDeps.esbuildOptions` whose `onEnd` callback reads + // `result.metafile`. Vite 8.0.10's Rolldown-based esbuild compatibility shim + // proxies that result and throws "Not implemented" on any property access, + // crashing dep prebundling (and `astro check`). Setting `disabled: true` + // bypasses the optimizer entirely; this is a static marketing site with no + // client deps that benefit from prebundling. Vite warns that `disabled` is + // deprecated and suggests `noDiscovery: true` + empty `include`, but astro's + // dev-toolbar plugin force-injects entries into `include`, so noDiscovery is + // not sufficient. Remove this once vite/rolldown shims `metafile` (or astro + // migrates the dev-toolbar plugin to `rolldownOptions`). + vite: { + optimizeDeps: { + disabled: true, + }, + }, }); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 8f59fd3f46c..92820be46a4 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 69a4d528cdd..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"; @@ -24,7 +24,6 @@ import type { ProviderThreadSnapshot, ProviderThreadTurnSnapshot, } from "../src/provider/Services/ProviderAdapter.ts"; -import { getProviderCapabilities } from "../src/provider/Services/ProviderAdapter.ts"; export interface TestTurnResponse { readonly events: ReadonlyArray; @@ -37,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; @@ -179,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, @@ -199,7 +198,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; } function nowIso(): string { @@ -207,7 +206,7 @@ function nowIso(): string { } function sessionNotFound( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -217,7 +216,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); @@ -225,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(); @@ -258,6 +257,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const session: ProviderSession = { provider, + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), status: "ready", runtimeMode: input.runtimeMode, threadId, @@ -475,7 +477,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const adapter: ProviderAdapterShape = { provider, - capabilities: getProviderCapabilities(provider), + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, 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 261bd11991c..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, @@ -744,18 +754,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git messageId: "msg-user-revert-1", text: "First edit", }); - yield* harness.waitForReceipt( - (receipt): receipt is CheckpointDiffFinalizedReceipt => - receipt.type === "checkpoint.diff.finalized" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); - yield* harness.waitForReceipt( - (receipt): receipt is TurnProcessingQuiescedReceipt => - receipt.type === "turn.processing.quiesced" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); yield* harness.waitForThread( THREAD_ID, @@ -814,18 +812,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git messageId: "msg-user-revert-2", text: "Second edit", }); - yield* harness.waitForReceipt( - (receipt): receipt is CheckpointDiffFinalizedReceipt => - receipt.type === "checkpoint.diff.finalized" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 2, - ); - yield* harness.waitForReceipt( - (receipt): receipt is TurnProcessingQuiescedReceipt => - receipt.type === "turn.processing.quiesced" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 2, - ); yield* harness.waitForThread( THREAD_ID, @@ -833,6 +819,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && entry.activities.some((activity) => activity.turnId === "turn-2"), + 8000, ); yield* harness.engine.dispatch({ @@ -935,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", @@ -962,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", }, }); @@ -978,119 +977,140 @@ it.live("starts a claudeAgent session on first turn when provider is requested", ); assert.equal(thread.session?.providerName, "claudeAgent"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); -// Skip: flaky timeout in CI after upstream sync — needs investigation -it.live.skip( - "recovers claudeAgent sessions after provider stopAll using persisted resume state", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), - 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"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-recover-1", - messageId: "msg-user-claude-recover-1", - text: "Before restart", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", +it.live("recovers claudeAgent sessions after provider stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-recover-1", + "2026-02-24T10:11:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, }, - }); - - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", - ); - - yield* harness.providerService.stopSession({ threadId: THREAD_ID }); - yield* waitForSync( - () => harness.adapterHarness!.listActiveSessionIds(), - (sessionIds) => sessionIds.length === 0, - "provider stopSession", - ); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), - 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"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-recover-2", - messageId: "msg-user-claude-recover-2", - text: "After restart", - }); - yield* waitForSync( - () => harness.adapterHarness!.getStartCount(), - (count) => count === 2, - "claude provider recovery start", - ); - - const recoveredThread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.providerName === "claudeAgent" && - entry.messages.some( - (message) => message.role === "user" && message.text === "After restart", - ) && - !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), - ); - assert.equal(recoveredThread.session?.providerName, "claudeAgent"); - assert.equal(recoveredThread.session?.threadId, "thread-1"); - }), - "claudeAgent", - ), + { + type: "message.delta", + ...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", + CLAUDE_AGENT_PROVIDER, + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.adapter.stopAll(); + yield* waitForSync( + () => harness.adapterHarness!.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "provider stopAll", + ); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...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", + 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", + CLAUDE_AGENT_PROVIDER, + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + }); + yield* waitForSync( + () => harness.adapterHarness!.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeAgent" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ); + assert.equal(recoveredThread.session?.providerName, "claudeAgent"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + CLAUDE_AGENT_PROVIDER, + ), ); it.live("forwards claudeAgent approval responses to the provider session", () => @@ -1103,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, @@ -1118,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", @@ -1132,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", }, }); @@ -1163,7 +1195,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => ); assert.equal(approvalResponses[0]?.decision, "accept"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1177,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", @@ -1204,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", }, }); @@ -1232,7 +1276,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => ); assert.equal(interruptCalls.length, 1); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1246,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", @@ -1277,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", }, }); @@ -1292,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", @@ -1356,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 3dd127a5564..0939e7ba385 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/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 46005126060..3178d6e5017 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -6,6 +6,7 @@ import readline from "node:readline"; import { ApprovalRequestId, EventId, + ProviderDriverKind, RuntimeItemId, RuntimeTaskId, ThreadId, @@ -24,7 +25,7 @@ import { createLogger } from "./logger.ts"; // ── Constants ─────────────────────────────────────────────────────── -const PROVIDER = "amp" as const; +const PROVIDER = ProviderDriverKind.make("amp"); // ── Module-level usage tracking ────────────────────────────────────── diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts deleted file mode 100644 index 66193cefa3d..00000000000 --- a/apps/server/src/geminiCliServerManager.test.ts +++ /dev/null @@ -1,750 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import type { PathLike } from "node:fs"; -import { ThreadId, TurnId, type ProviderRuntimeEvent } from "@t3tools/contracts"; - -import { - buildGeminiSpawnOptions, - GeminiCliServerManager, - resolveGeminiSpawnPlan, -} from "./geminiCliServerManager.ts"; - -const asThreadId = (value: string): ThreadId => ThreadId.make(value); - -// --------------------------------------------------------------------------- -// Helpers to inspect spawned processes -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Unit tests — no real gemini process -// --------------------------------------------------------------------------- - -describe("GeminiCliServerManager", () => { - describe("buildGeminiSpawnOptions", () => { - it("uses piped stdio without a shell", () => { - const options = buildGeminiSpawnOptions({ - cwd: "/tmp", - env: {}, - }); - - expect(options.cwd).toBe("/tmp"); - expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]); - expect(options.shell).toBe(false); - }); - }); - - describe("resolveGeminiSpawnPlan", () => { - it("rewrites Windows npm shim launches to node dist/index.js", () => { - const env = { - PATH: "C:\\Users\\user\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }; - const geminiCmd = "C:\\Users\\user\\AppData\\Roaming\\npm\\gemini.cmd"; - const nodeBinary = "C:\\Program Files\\nodejs\\node.exe"; - - const plan = resolveGeminiSpawnPlan( - { - binaryPath: "gemini", - args: ["-p", "Reply with exactly PONG"], - cwd: "C:\\repo", - env, - }, - "win32", - { - resolveCommandPath: (command: string) => { - if (command === "gemini") { - return geminiCmd; - } - if (command === "node") { - return nodeBinary; - } - return undefined; - }, - existsSync: (path: PathLike) => - String(path).replace(/\\/g, "/") === - "C:/Users/user/AppData/Roaming/npm/node_modules/@google/gemini-cli/dist/index.js", - }, - ); - - expect(plan.command).toBe(nodeBinary); - expect(plan.args[0]?.replace(/\\/g, "/")).toContain( - "/AppData/Roaming/npm/node_modules/@google/gemini-cli/dist/index.js", - ); - expect(plan.args.slice(1)).toEqual(["-p", "Reply with exactly PONG"]); - expect(plan.options.shell).toBe(false); - }); - - it("spawns directly on non-Windows platforms", () => { - const plan = resolveGeminiSpawnPlan( - { - binaryPath: "gemini", - args: ["-p", "Reply with exactly PONG"], - cwd: "/tmp", - env: {}, - }, - "linux", - ); - - expect(plan.command).toBe("gemini"); - expect(plan.args).toEqual(["-p", "Reply with exactly PONG"]); - }); - }); - - describe("startSession", () => { - it("creates a session and returns a ready ProviderSession", async () => { - const manager = new GeminiCliServerManager(); - try { - const session = await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - cwd: "/tmp", - modelSelection: { provider: "geminiCli", model: "gemini-2.5-pro" }, - }); - - expect(session.provider).toBe("geminiCli"); - expect(session.status).toBe("ready"); - expect(session.threadId).toBe("thread-1"); - expect(session.model).toBe("gemini-2.5-pro"); - expect(manager.hasSession(asThreadId("thread-1"))).toBe(true); - } finally { - manager.stopAll(); - } - }); - - it("hydrates persisted resume state from resumeCursor", async () => { - const manager = new GeminiCliServerManager(); - try { - const session = await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - resumeCursor: { - sessionId: "gemini-session-123", - }, - }); - - expect(session.resumeCursor).toEqual({ sessionId: "gemini-session-123" }); - expect(manager.listSessions()[0]?.resumeCursor).toEqual({ - sessionId: "gemini-session-123", - }); - } finally { - manager.stopAll(); - } - }); - - it("rejects duplicate sessions", async () => { - const manager = new GeminiCliServerManager(); - try { - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - expect(() => - manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }), - ).toThrow("already exists"); - } finally { - manager.stopAll(); - } - }); - - // TODO: Strengthen this test by mocking child_process.spawn and asserting it - // is NOT called during startSession. Currently we only verify session state, - // which doesn't prove that no process was spawned. - it("does not spawn a process on startSession (lazy per-turn spawning)", async () => { - const manager = new GeminiCliServerManager(); - try { - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - // No process should exist yet — we only spawn on sendTurn. - expect(manager.listSessions()).toHaveLength(1); - expect(manager.listSessions()[0]?.status).toBe("ready"); - } finally { - manager.stopAll(); - } - }); - }); - - describe("sendTurn", () => { - it("rejects when session does not exist", () => { - const manager = new GeminiCliServerManager(); - expect(() => - manager.sendTurn({ - threadId: asThreadId("nonexistent"), - input: "hello", - }), - ).toThrow("Unknown Gemini CLI session"); - }); - - it("rejects when session is closed", async () => { - const manager = new GeminiCliServerManager(); - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - // Directly mark the session as closed without removing it from the map, - // so we exercise the "closed session" branch (not the "unknown session" branch). - const sessions = (manager as unknown as { sessions: Map }) - .sessions; - const session = sessions.get("thread-1"); - expect(session).toBeDefined(); - session!.status = "closed"; - - expect(() => - manager.sendTurn({ - threadId: asThreadId("thread-1"), - input: "hello", - }), - ).toThrow("Gemini CLI session is closed"); - }); - - it("rejects when session is already running", async () => { - const manager = new GeminiCliServerManager(); - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - // Mark the session as running to simulate an in-progress turn. - const sessions = (manager as unknown as { sessions: Map }) - .sessions; - const session = sessions.get("thread-1"); - expect(session).toBeDefined(); - session!.status = "running"; - - expect(() => - manager.sendTurn({ - threadId: asThreadId("thread-1"), - input: "hello", - }), - ).toThrow("Gemini CLI session already running"); - }); - - it("rejects when attachments are provided", async () => { - const manager = new GeminiCliServerManager(); - try { - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - expect(() => - manager.sendTurn({ - threadId: asThreadId("thread-1"), - input: "hello", - attachments: [{ type: "image", url: "https://example.com/img.png" }] as never, - }), - ).toThrow("does not support attachments"); - } finally { - manager.stopAll(); - } - }); - }); - - describe("stopSession", () => { - it("removes the session", async () => { - const manager = new GeminiCliServerManager(); - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - expect(manager.hasSession(asThreadId("thread-1"))).toBe(true); - manager.stopSession(asThreadId("thread-1")); - expect(manager.hasSession(asThreadId("thread-1"))).toBe(false); - }); - - it("is a no-op for unknown sessions", () => { - const manager = new GeminiCliServerManager(); - expect(() => manager.stopSession(asThreadId("nonexistent"))).not.toThrow(); - }); - }); - - describe("listSessions / hasSession", () => { - it("lists all active sessions", async () => { - const manager = new GeminiCliServerManager(); - try { - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - modelSelection: { provider: "geminiCli", model: "gemini-3-flash" }, - }); - await manager.startSession({ - threadId: asThreadId("thread-2"), - provider: "geminiCli", - runtimeMode: "full-access", - modelSelection: { provider: "geminiCli", model: "gemini-2.5-pro" }, - }); - - const sessions = manager.listSessions(); - expect(sessions).toHaveLength(2); - expect(sessions.map((s: { threadId: string }) => s.threadId).toSorted()).toEqual([ - "thread-1", - "thread-2", - ]); - } finally { - manager.stopAll(); - } - }); - }); - - describe("readThread / rollbackThread", () => { - it("returns empty turns for a valid session", async () => { - const manager = new GeminiCliServerManager(); - try { - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - const snapshot = await manager.readThread(asThreadId("thread-1")); - expect(snapshot.threadId).toBe("thread-1"); - expect(snapshot.turns).toEqual([]); - } finally { - manager.stopAll(); - } - }); - - it("throws for unknown sessions", () => { - const manager = new GeminiCliServerManager(); - expect(() => manager.readThread(asThreadId("nonexistent"))).toThrow( - "Unknown Gemini CLI session", - ); - }); - - it("reports rollback as unsupported", async () => { - const manager = new GeminiCliServerManager(); - try { - await manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "geminiCli", - runtimeMode: "full-access", - }); - - expect(() => manager.rollbackThread(asThreadId("thread-1"))).toThrow( - "rollbackThread is not supported for Gemini CLI session", - ); - } finally { - manager.stopAll(); - } - }); - }); - - describe("interruptTurn", () => { - it("throws for unknown sessions", () => { - const manager = new GeminiCliServerManager(); - expect(() => manager.interruptTurn(asThreadId("nonexistent"))).toThrow( - "Unknown Gemini CLI session", - ); - }); - }); -}); - -// --------------------------------------------------------------------------- -// handleJsonLine — test event mapping from Gemini stream-json to provider events -// --------------------------------------------------------------------------- - -describe("GeminiCliServerManager JSON event mapping", () => { - let manager: GeminiCliServerManager; - let events: ProviderRuntimeEvent[]; - - beforeEach(async () => { - manager = new GeminiCliServerManager(); - events = []; - manager.on("event", (event: ProviderRuntimeEvent) => events.push(event)); - - await manager.startSession({ - threadId: asThreadId("thread-json"), - provider: "geminiCli", - runtimeMode: "full-access", - modelSelection: { provider: "geminiCli", model: "gemini-2.5-pro" }, - cwd: "/tmp", - }); - - events = []; - }); - - /** Invoke the private handleJsonLine method for testing. */ - function feedJsonLine(line: string): void { - const turnId = TurnId.make("test-turn-1"); - ( - manager as unknown as { - handleJsonLine: (threadId: ThreadId, turnId: TurnId, line: string) => void; - } - ).handleJsonLine(asThreadId("thread-json"), turnId, line); - } - - it("captures gemini session_id from init event", () => { - feedJsonLine( - JSON.stringify({ - type: "init", - session_id: "gemini-sess-abc", - model: "gemini-2.5-pro", - timestamp: new Date().toISOString(), - }), - ); - - // init doesn't emit a provider event, but we can verify the session ID - // was captured by checking that a subsequent sendTurn would use --resume. - expect(events).toHaveLength(0); - - // Verify the session_id was actually stored for --resume on subsequent turns. - const sessions = (manager as unknown as { sessions: Map }) - .sessions; - expect(sessions.get("thread-json")?.geminiSessionId).toBe("gemini-sess-abc"); - }); - - it("maps assistant message deltas to content.delta events with stable itemId", () => { - feedJsonLine( - JSON.stringify({ - type: "message", - role: "assistant", - content: "Hello, ", - delta: true, - timestamp: new Date().toISOString(), - }), - ); - feedJsonLine( - JSON.stringify({ - type: "message", - role: "assistant", - content: "world!", - delta: true, - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(2); - expect(events[0]?.type).toBe("content.delta"); - expect(events[0]?.provider).toBe("geminiCli"); - expect((events[0]!.payload as { delta: string }).delta).toBe("Hello, "); - expect((events[0]!.payload as { streamKind: string }).streamKind).toBe("assistant_text"); - - // Both deltas must share the same itemId for proper message aggregation. - expect(events[1]?.type).toBe("content.delta"); - expect((events[1] as { itemId?: string }).itemId).toBe( - (events[0] as { itemId?: string }).itemId, - ); - }); - - it("ignores user message echoes", () => { - feedJsonLine( - JSON.stringify({ - type: "message", - role: "user", - content: "Say hello", - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(0); - }); - - it("maps tool_use to item.started events with descriptive title", () => { - feedJsonLine( - JSON.stringify({ - type: "tool_use", - tool_name: "list_directory", - tool_id: "tool_123", - parameters: { dir_path: "." }, - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(1); - expect(events[0]?.type).toBe("item.started"); - const payload = events[0]?.payload as { itemType: string; title: string }; - expect(payload.itemType).toBe("command_execution"); - expect(payload.title).toBe("list_directory · ."); - }); - - it("maps tool_result to item.completed events with descriptive title and detail", () => { - // First emit tool_use to register the tool item. - feedJsonLine( - JSON.stringify({ - type: "tool_use", - tool_name: "read_file", - tool_id: "tool_456", - parameters: { file_path: "README.md" }, - timestamp: new Date().toISOString(), - }), - ); - - // Then the result with output. - feedJsonLine( - JSON.stringify({ - type: "tool_result", - tool_id: "tool_456", - status: "success", - output: "File contents here", - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(2); - expect(events[1]?.type).toBe("item.completed"); - const payload = events[1]?.payload as { - itemType: string; - status: string; - title: string; - detail: string; - }; - expect(payload.itemType).toBe("command_execution"); - expect(payload.status).toBe("completed"); - expect(payload.title).toBe("read_file · README.md"); - expect(payload.detail).toBe("File contents here"); - }); - - it("falls back to parameter summary when tool output is empty", () => { - feedJsonLine( - JSON.stringify({ - type: "tool_use", - tool_name: "read_file", - tool_id: "tool_789", - parameters: { file_path: "package.json" }, - timestamp: new Date().toISOString(), - }), - ); - - feedJsonLine( - JSON.stringify({ - type: "tool_result", - tool_id: "tool_789", - status: "success", - output: "", - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(2); - const payload = events[1]?.payload as { title: string; detail?: string }; - expect(payload.title).toBe("read_file · package.json"); - expect(payload.detail).toBe('{"file_path":"package.json"}'); - }); - - it("maps result success to turn.completed with state=completed", () => { - feedJsonLine( - JSON.stringify({ - type: "result", - status: "success", - stats: { - total_tokens: 1000, - input_tokens: 800, - output_tokens: 200, - duration_ms: 3000, - tool_calls: 1, - }, - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(1); - expect(events[0]?.type).toBe("turn.completed"); - const payload = events[0]?.payload as { state: string; usage?: unknown }; - expect(payload.state).toBe("completed"); - expect(payload.usage).toEqual({ - total_tokens: 1000, - input_tokens: 800, - output_tokens: 200, - cached_tokens: undefined, - duration_ms: 3000, - tool_calls: 1, - }); - }); - - it("maps result error to turn.completed with state=failed", () => { - feedJsonLine( - JSON.stringify({ - type: "result", - status: "error", - error_message: "Rate limit exceeded", - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(1); - expect(events[0]?.type).toBe("turn.completed"); - const payload = events[0]?.payload as { state: string; errorMessage?: string }; - expect(payload.state).toBe("failed"); - expect(payload.errorMessage).toBe("Rate limit exceeded"); - }); - - it("maps result error with nested error object to turn.completed with errorMessage", () => { - feedJsonLine( - JSON.stringify({ - type: "result", - status: "error", - error: { type: "Error", message: "[API Error: Requested entity was not found.]" }, - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(1); - expect(events[0]?.type).toBe("turn.completed"); - const payload = events[0]?.payload as { state: string; errorMessage?: string }; - expect(payload.state).toBe("failed"); - expect(payload.errorMessage).toBe("[API Error: Requested entity was not found.]"); - }); - - it("maps result interrupted to turn.completed with state=interrupted", () => { - feedJsonLine( - JSON.stringify({ - type: "result", - status: "interrupted", - timestamp: new Date().toISOString(), - }), - ); - - expect(events).toHaveLength(1); - const payload = events[0]?.payload as { state: string }; - expect(payload.state).toBe("interrupted"); - }); - - it("ignores non-JSON lines", () => { - feedJsonLine("YOLO mode is enabled."); - feedJsonLine("Loaded cached credentials."); - feedJsonLine("Skill conflict detected: ..."); - feedJsonLine(""); - feedJsonLine(" "); - - expect(events).toHaveLength(0); - }); - - it("ignores malformed JSON", () => { - feedJsonLine("{broken json"); - expect(events).toHaveLength(0); - }); - - it("handles a full conversation flow in sequence", () => { - const lines = [ - '{"type":"init","timestamp":"2026-03-08T09:10:06.236Z","session_id":"sess-1","model":"gemini-3-flash"}', - '{"type":"message","timestamp":"2026-03-08T09:10:06.237Z","role":"user","content":"Hello"}', - '{"type":"message","timestamp":"2026-03-08T09:10:09.823Z","role":"assistant","content":"Hi there!","delta":true}', - '{"type":"message","timestamp":"2026-03-08T09:10:09.900Z","role":"assistant","content":" How can I help?","delta":true}', - '{"type":"tool_use","timestamp":"2026-03-08T09:10:10.000Z","tool_name":"list_directory","tool_id":"tool_1","parameters":{"dir_path":"."}}', - '{"type":"tool_result","timestamp":"2026-03-08T09:10:10.100Z","tool_id":"tool_1","status":"success","output":"3 items"}', - '{"type":"message","timestamp":"2026-03-08T09:10:11.000Z","role":"assistant","content":"Found 3 items.","delta":true}', - '{"type":"result","timestamp":"2026-03-08T09:10:11.100Z","status":"success","stats":{"total_tokens":500,"input_tokens":400,"output_tokens":100,"duration_ms":5000,"tool_calls":1}}', - ]; - - for (const line of lines) { - feedJsonLine(line); - } - - // Expected events: - // 2 content.delta (segment 1) + 1 item.completed (assistant_message, segment 1 finalized by tool_use) - // + 1 item.started + 1 item.completed (tool) - // + 1 content.delta (segment 2) + 1 item.completed (assistant_message, segment 2 finalized by result) - // + 1 turn.completed = 8 - expect(events).toHaveLength(8); - expect(events.map((e) => e.type)).toEqual([ - "content.delta", // "Hi there!" (segment 1) - "content.delta", // " How can I help?" (segment 1) - "item.completed", // assistant_message finalized (segment 1) - "item.started", // tool_use list_directory - "item.completed", // tool_result list_directory - "content.delta", // "Found 3 items." (segment 2) - "item.completed", // assistant_message finalized (segment 2) - "turn.completed", - ]); - - // Deltas before the tool call share one itemId (segment 1), - // and the delta after the tool call gets a new itemId (segment 2). - const deltas = events.filter((e) => e.type === "content.delta"); - const deltaItemIds = deltas.map((e) => (e as { itemId?: string }).itemId); - expect(new Set(deltaItemIds).size).toBe(2); - // First two deltas share the same itemId. - expect(deltaItemIds[0]).toBe(deltaItemIds[1]); - // Third delta has a different itemId. - expect(deltaItemIds[2]).not.toBe(deltaItemIds[0]); - - // Both assistant_message completions are present. - const assistantCompletes = events.filter( - (e) => - e.type === "item.completed" && - (e.payload as { itemType?: string }).itemType === "assistant_message", - ); - expect(assistantCompletes).toHaveLength(2); - }); -}); - -// --------------------------------------------------------------------------- -// Live integration test — only runs when `gemini` is available -// --------------------------------------------------------------------------- - -const hasGemini = await (async () => { - try { - const { execSync } = await import("node:child_process"); - execSync("gemini --version", { stdio: "pipe" }); - return true; - } catch { - return false; - } -})(); - -describe.skipIf(!hasGemini || process.env.RUN_GEMINI_LIVE_TESTS !== "1")( - "GeminiCliServerManager live integration", - () => { - it("sends a prompt and receives streaming events ending with turn.completed", async () => { - const manager = new GeminiCliServerManager(); - const events: ProviderRuntimeEvent[] = []; - manager.on("event", (event: ProviderRuntimeEvent) => events.push(event)); - - try { - await manager.startSession({ - threadId: asThreadId("live-thread"), - provider: "geminiCli", - runtimeMode: "full-access", - modelSelection: { provider: "geminiCli", model: "gemini-2.5-flash" }, - }); - - const result = await manager.sendTurn({ - threadId: asThreadId("live-thread"), - input: "Reply with exactly the word PONG", - }); - - expect(result.threadId).toBe("live-thread"); - expect(result.turnId).toBeTruthy(); - - // Wait for the turn to complete. - await vi.waitFor( - () => { - const completed = events.find((e) => e.type === "turn.completed"); - expect(completed).toBeDefined(); - }, - { timeout: 30_000, interval: 500 }, - ); - - // Should have received content deltas. - const deltas = events.filter((e) => e.type === "content.delta"); - expect(deltas.length).toBeGreaterThan(0); - - // The text should contain "PONG" somewhere. - const fullText = deltas.map((e) => (e.payload as { delta: string }).delta).join(""); - expect(fullText.toLowerCase()).toContain("pong"); - - // Turn should be completed successfully. - const completed = events.find((e) => e.type === "turn.completed"); - expect((completed!.payload as { state: string }).state).toBe("completed"); - } finally { - manager.stopAll(); - } - }, 60_000); - }, -); diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index fb682a19ee3..961d48f15ef 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -13,6 +13,7 @@ import readline from "node:readline"; import { ApprovalRequestId, EventId, + ProviderDriverKind, RuntimeItemId, ThreadId, TurnId, @@ -28,7 +29,7 @@ import type { ProviderSessionUsage, ProviderUsageResult } from "@t3tools/contrac import type { ProviderThreadSnapshot } from "./provider/Services/ProviderAdapter.ts"; import { resolveCommandPath } from "./commandPath.ts"; -const PROVIDER = "geminiCli" as const; +const PROVIDER = ProviderDriverKind.make("geminiCli"); // ── Module-level usage tracking ────────────────────────────────────── diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 08471346989..eb7bf62ad48 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -1,23 +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* () { @@ -51,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", @@ -72,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; @@ -121,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) => { @@ -190,26 +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: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - effort: "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"); + }), ), ); @@ -224,28 +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: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: 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"); + }), ), ); @@ -260,27 +261,57 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { }), stdinMustContain: "You write concise thread titles for coding conversations.", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - 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", - }, - }); + (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", + }, + }); - 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( { @@ -290,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 97e18c3e789..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, @@ -28,10 +28,17 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "../../provider/Layers/ClaudeProvider.ts"; +import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -43,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, @@ -81,28 +91,32 @@ 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 normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( - getClaudeModelCapabilities(modelSelection.model), - modelSelection.options, - ); + const caps = getClaudeModelCapabilities(modelSelection.model); + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); + const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); + const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); + const cliEffort = normalizeClaudeCliEffort(resolvedEffort); + const thinkingDescriptor = findDescriptor("thinking"); + const fastModeDescriptor = findDescriptor("fastMode"); + const thinking = + thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; + const fastMode = + fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { - ...(typeof normalizedOptions?.thinking === "boolean" - ? { alwaysThinkingEnabled: normalizedOptions.thinking } - : {}), - ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(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", @@ -111,11 +125,12 @@ const makeClaudeTextGeneration = Effect.gen(function* () { jsonSchemaStr, "--model", resolveClaudeApiModelId(modelSelection), - ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), + ...(cliEffort ? ["--effort", cliEffort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", ], { + env: claudeEnvironment, cwd, shell: process.platform === "win32", stdin: { @@ -216,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, @@ -251,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, @@ -280,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, @@ -308,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, @@ -335,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 a07505f025c..f38d6a68a87 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -1,29 +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: { @@ -161,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) => { @@ -204,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(); + }), ), ); @@ -236,24 +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: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, - }, - }); - }), + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), + }), ), ); @@ -266,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, - }); - }), + }), ), ); @@ -290,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"); + }), ), ); @@ -316,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); + }), ), ); @@ -344,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"); + }), ), ); @@ -366,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..."); + }), ), ); @@ -387,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"); + }), ), ); @@ -408,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"); + }), ), ); @@ -430,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"); + }), ), ); @@ -453,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.", @@ -515,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({ @@ -540,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"); + } + }), ), ); @@ -611,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 8f15bfa1868..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,16 +26,25 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; 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; @@ -64,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 => @@ -139,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, @@ -148,16 +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 = - modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? + CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( - codexSettings?.binaryPath || "codex", + codexConfig.binaryPath || "codex", [ "exec", "--ephemeral", @@ -168,7 +172,9 @@ const makeCodexTextGeneration = Effect.gen(function* () { modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, - ...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []), + ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true + ? ["--config", `service_tier="fast"`] + : []), "--output-schema", schemaPath, "--output-last-message", @@ -178,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", @@ -281,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, @@ -316,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, @@ -349,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, @@ -382,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, @@ -411,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/CopilotTextGeneration.ts b/apps/server/src/git/Layers/CopilotTextGeneration.ts deleted file mode 100644 index 8948c719896..00000000000 --- a/apps/server/src/git/Layers/CopilotTextGeneration.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { - CopilotClient as CopilotClientType, - CopilotClientOptions, - PermissionRequestResult, -} from "@github/copilot-sdk"; -import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; -import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { Effect, Layer, Schema, SchemaIssue } from "effect"; - -import { - normalizeCopilotCliPathOverride, - resolveBundledCopilotCliPath, -} from "../../provider/Layers/copilotCliPath.ts"; -import { TextGenerationError } from "@t3tools/contracts"; -import { - CopilotTextGeneration, - type CopilotTextGenerationShape, -} from "../Services/CopilotTextGeneration.ts"; -import type { - CommitMessageGenerationResult, - PrContentGenerationResult, -} from "../Services/TextGeneration.ts"; - -const COPILOT_TIMEOUT_MS = 180_000; -const DENY_PERMISSION_RESULT: PermissionRequestResult = { - kind: "denied-interactively-by-user", -}; - -const CommitMessageResponseSchema = Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.optional(Schema.String), -}); - -const PrContentResponseSchema = Schema.Struct({ - title: Schema.String, - body: Schema.String, -}); - -interface CopilotClientHandle { - createSession( - config: Parameters[0], - ): Promise; - stop(): Promise>; -} - -interface CopilotSessionHandle { - destroy(): Promise; - sendAndWait( - input: { - prompt: string; - mode: "immediate"; - }, - timeoutMs: number, - ): Promise<{ - data?: { - content?: string; - }; - }>; -} - -export interface CopilotTextGenerationLiveOptions { - readonly cliPath?: string; - readonly clientFactory?: (options: CopilotClientOptions) => CopilotClientHandle; -} - -function normalizeCopilotError( - operation: "generateCommitMessage" | "generatePrContent", - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - const lower = error.message.toLowerCase(); - if (lower.includes("enoent") || lower.includes("spawn")) { - return new TextGenerationError({ - operation, - detail: "GitHub Copilot CLI is required but was not found.", - cause: error, - }); - } - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} - -function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n\n[truncated]`; -} - -function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - return singleLine.length > 0 ? singleLine : "Update project changes"; -} - -function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.startsWith("```")) { - const fenced = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, ""); - return fenced.trim(); - } - const start = trimmed.indexOf("{"); - const end = trimmed.lastIndexOf("}"); - if (start !== -1 && end !== -1 && end > start) { - return trimmed.slice(start, end + 1); - } - return trimmed; -} - -function decodeJsonResponse( - operation: "generateCommitMessage" | "generatePrContent", - raw: string, - schema: S, -): Effect.Effect { - return Effect.gen(function* () { - const jsonText = extractJsonObject(raw); - const parsed = yield* Effect.try({ - try: () => JSON.parse(jsonText) as unknown, - catch: (cause) => - normalizeCopilotError(operation, cause, "GitHub Copilot returned invalid JSON"), - }); - - return yield* Schema.decodeUnknownEffect(schema)(parsed).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: `GitHub Copilot returned an unexpected payload: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, - cause, - }), - ), - ); - }); -} - -export const makeCopilotTextGenerationLive = (options?: CopilotTextGenerationLiveOptions) => - Layer.effect( - CopilotTextGeneration, - Effect.sync(() => { - const runCopilotJson = ({ - operation, - prompt, - schema, - }: { - operation: "generateCommitMessage" | "generatePrContent"; - prompt: string; - schema: S; - }): Effect.Effect => - Effect.gen(function* () { - const cliPath = - normalizeCopilotCliPathOverride(options?.cliPath) ?? resolveBundledCopilotCliPath(); - const model = DEFAULT_MODEL_BY_PROVIDER.copilot; - const clientOptions: CopilotClientOptions = { - ...(cliPath ? { cliPath } : {}), - logLevel: "error", - }; - const { CopilotClient } = yield* Effect.promise(() => import("@github/copilot-sdk")); - const client = - options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions); - let session: CopilotSessionHandle | undefined; - const cleanup = Effect.promise(async () => { - if (session) { - await session.destroy().catch(() => undefined); - } - await client.stop().catch(() => []); - }).pipe(Effect.asVoid); - - return yield* Effect.gen(function* () { - const createdSession = yield* Effect.tryPromise({ - try: () => - client.createSession({ - model, - onPermissionRequest: () => DENY_PERMISSION_RESULT, - systemMessage: { - mode: "append", - content: - "Do not use tools, do not request permissions, and answer using only valid JSON with no markdown fences or prose.", - }, - }), - catch: (cause) => - normalizeCopilotError( - operation, - cause, - "Failed to start a GitHub Copilot text-generation session", - ), - }); - session = createdSession; - - const response = yield* Effect.tryPromise({ - try: () => - createdSession.sendAndWait({ prompt, mode: "immediate" }, COPILOT_TIMEOUT_MS), - catch: (cause) => - normalizeCopilotError( - operation, - cause, - "GitHub Copilot did not finish generating text", - ), - }); - - const responseContent = - response && - typeof response === "object" && - "data" in response && - response.data && - typeof response.data === "object" && - "content" in response.data && - typeof response.data.content === "string" - ? response.data.content - : null; - - if (!responseContent) { - return yield* new TextGenerationError({ - operation, - detail: "GitHub Copilot did not return any text.", - }); - } - - return yield* decodeJsonResponse(operation, responseContent, schema); - }).pipe(Effect.ensuring(cleanup)); - }); - - const generateCommitMessage: CopilotTextGenerationShape["generateCommitMessage"] = ( - input, - ) => { - const prompt = [ - "You write concise git commit messages.", - input.includeBranch === true - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and have no trailing period", - "- body can be an empty string or short bullet points", - ...(input.includeBranch === true - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); - - return runCopilotJson({ - operation: "generateCommitMessage", - prompt, - schema: CommitMessageResponseSchema, - }).pipe( - Effect.map( - (generated) => - ({ - subject: generated.subject, - body: generated.body.trim(), - ...(generated.branch - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }) satisfies CommitMessageGenerationResult, - ), - ); - }; - - const generatePrContent: CopilotTextGenerationShape["generatePrContent"] = (input) => { - const prompt = [ - "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", - "Rules:", - "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, mention concrete commands when available", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commit summary:", - limitSection(input.commitSummary, 6_000), - "", - "Diff summary:", - limitSection(input.diffSummary, 8_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); - - return runCopilotJson({ - operation: "generatePrContent", - prompt, - schema: PrContentResponseSchema, - }).pipe( - Effect.map( - (generated) => - ({ - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }) satisfies PrContentGenerationResult, - ), - ); - }; - - return { - generateCommitMessage, - generatePrContent, - } satisfies CopilotTextGenerationShape; - }), - ); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index e7bce113474..3718557664c 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -5,15 +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"); @@ -22,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"); @@ -56,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 { @@ -125,87 +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: { - provider: "cursor", - model: "gpt-5.4", - options: { - reasoning: "xhigh", - fastMode: true, - contextWindow: "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 }); + }), ); }); @@ -215,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(""); + }), ), ); @@ -242,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."); + }), ), ); @@ -271,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 24f066059c7..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" }, @@ -108,7 +108,7 @@ const makeCursorTextGeneration = Effect.gen(function* () { yield* applyCursorAcpModelSelection({ runtime, model: modelSelection.model, - modelOptions: modelSelection.options, + selections: modelSelection.options, mapError: ({ cause, configId, step }) => mapCursorAcpError( operation, @@ -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/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index dbbc821a088..4907ef1047e 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -1189,6 +1189,36 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status hides merged PRs on the default branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 23, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/23", + baseRefName: "feature/status-default-branch-target", + headRefName: "main", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index dadf2f7e79b..21f3411d1e5 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -688,7 +688,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: details.branch, upstreamRef: details.upstreamRef, }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.map((latest) => { + if (!latest) return null; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") return null; + return toStatusPr(latest); + }), Effect.catch(() => Effect.succeed(null)), ) : null; 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 fd28188d600..f2d0b4b2724 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -1,23 +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, @@ -92,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), @@ -201,6 +204,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { openCodeRuntime .startOpenCodeServerProcess({ binaryPath: input.binaryPath, + environment, }) .pipe( Effect.provideService(Scope.Scope, serverScope), @@ -266,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); @@ -277,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) => @@ -309,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({ @@ -320,16 +304,17 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { if (!session.data) { throw new Error("OpenCode session.create returned no session payload."); } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue( + input.modelSelection, + "variant", + ); const result = await client.session.prompt({ sessionID: session.data.id, model: parsedModel, - ...(input.modelSelection.options?.agent - ? { agent: input.modelSelection.options.agent } - : {}), - ...(input.modelSelection.options?.variant - ? { variant: input.modelSelection.options.variant } - : {}), + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), parts: [{ type: "text", text: input.prompt }, ...fileParts], }); const info = result.data?.info; @@ -352,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, @@ -381,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, @@ -414,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, @@ -445,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, @@ -473,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, @@ -505,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 a4d8cc494f1..00000000000 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * RoutingTextGeneration – Dispatches text generation requests to the - * appropriate CLI implementation based on the provider in each request input. - * - * Currently supported providers with dedicated layers: - * - `"claudeAgent"` → Claude CLI layer - * - `"copilot"` → Copilot text-generation layer (partial – falls back to - * codex for branch names / thread titles) - * - `"codex"` → Codex CLI layer (also the default fallback) - * - `"cursor"` → Cursor text-generation layer (ACP-based) - * - `"opencode"` → OpenCode text-generation layer (SDK-based) - * - * Providers without a dedicated CLI text-generation layer (geminiCli, amp, - * kilo) fall back to Codex. - * - * @module RoutingTextGeneration - */ -import { Effect, Layer, Context } from "effect"; - -import type { ProviderKind } from "@t3tools/contracts"; -import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { - CopilotTextGeneration, - type CopilotTextGenerationShape, -} from "../Services/CopilotTextGeneration.ts"; -import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; -import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; -import { makeCopilotTextGenerationLive } from "./CopilotTextGeneration.ts"; -import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; -import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; - -// --------------------------------------------------------------------------- -// Supported git text-generation providers. Providers not in this set fall -// back to codex (the most broadly compatible CLI implementation). -// --------------------------------------------------------------------------- - -const GIT_TEXT_GEN_PROVIDERS = new Set([ - "codex", - "claudeAgent", - "copilot", - "cursor", - "opencode", -]); - -class CodexTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/CodexTextGen", -) {} - -class ClaudeTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", -) {} - -class CopilotTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/CopilotTextGen", -) {} - -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 codex = yield* CodexTextGen; - const claude = yield* ClaudeTextGen; - const copilot = yield* CopilotTextGen; - const cursor = yield* CursorTextGen; - const openCode = yield* OpenCodeTextGen; - - const route = (provider?: ProviderKind): TextGenerationShape => { - if (!provider || !GIT_TEXT_GEN_PROVIDERS.has(provider)) return codex; - if (provider === "claudeAgent") return claude; - if (provider === "cursor") return cursor; - if (provider === "opencode") return openCode; - if (provider === "copilot") { - return { - generateCommitMessage: copilot.generateCommitMessage, - generatePrContent: copilot.generatePrContent, - // Copilot text generation doesn't support these yet; fall back to codex. - generateBranchName: codex.generateBranchName, - generateThreadTitle: codex.generateThreadTitle, - }; - } - return codex; - }; - - return { - generateCommitMessage: (input) => - route(input.modelSelection.provider).generateCommitMessage(input), - generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), - generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), - generateThreadTitle: (input) => route(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 InternalCopilotLayer = Layer.effect( - CopilotTextGen, - Effect.gen(function* () { - const svc = yield* CopilotTextGeneration; - return svc; - }), -).pipe(Layer.provide(makeCopilotTextGenerationLive())); - -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(InternalCopilotLayer), - Layer.provide(InternalCursorLayer), - Layer.provide(InternalOpenCodeLayer), -); diff --git a/apps/server/src/git/Layers/SessionTextGeneration.test.ts b/apps/server/src/git/Layers/SessionTextGeneration.test.ts deleted file mode 100644 index b5dd2924486..00000000000 --- a/apps/server/src/git/Layers/SessionTextGeneration.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { SessionTextGenerationLive } from "./SessionTextGeneration.ts"; - -describe("SessionTextGeneration", () => { - it("exports a valid Layer", () => { - expect(SessionTextGenerationLive).toBeDefined(); - }); -}); diff --git a/apps/server/src/git/Layers/SessionTextGeneration.ts b/apps/server/src/git/Layers/SessionTextGeneration.ts deleted file mode 100644 index 66c0dda9bf6..00000000000 --- a/apps/server/src/git/Layers/SessionTextGeneration.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import type { ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option, Queue, Schema, SchemaIssue, Stream } from "effect"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { resolveModelSlugForProvider } from "@t3tools/shared/model"; - -import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { TextGenerationError } from "@t3tools/contracts"; -import { - type BranchNameGenerationInput, - type BranchNameGenerationResult, - type CommitMessageGenerationResult, - type PrContentGenerationResult, - type ThreadTitleGenerationInput, - type ThreadTitleGenerationResult, -} from "../Services/TextGeneration.ts"; -import { - SessionTextGeneration, - type SessionTextGenerationShape, -} from "../Services/SessionTextGeneration.ts"; - -const PROVIDER_TEXT_GENERATION_TIMEOUT_MS = 180_000; - -function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n\n[truncated]`; -} - -function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - return singleLine.length > 0 ? singleLine : "Update project changes"; -} - -function sanitizeThreadTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - return singleLine.length > 0 ? singleLine : "New thread"; -} - -function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.startsWith("```")) { - const fenced = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, ""); - return fenced.trim(); - } - const start = trimmed.indexOf("{"); - const end = trimmed.lastIndexOf("}"); - if (start !== -1 && end !== -1 && end > start) { - return trimmed.slice(start, end + 1); - } - return trimmed; -} - -function toThreadId(value: string): ThreadId { - return value as ThreadId; -} - -function normalizeProviderTextGenerationError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} - -function decodeJsonResponse( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - raw: string, - schema: S, -): Effect.Effect { - return Effect.gen(function* () { - const jsonText = extractJsonObject(raw); - if (jsonText.length === 0) { - return yield* new TextGenerationError({ - operation, - detail: "Provider returned an empty response.", - }); - } - - const parsed = yield* Effect.try({ - try: () => JSON.parse(jsonText) as unknown, - catch: (cause) => - normalizeProviderTextGenerationError(operation, cause, "Provider returned invalid JSON"), - }); - - return yield* Schema.decodeUnknownEffect(schema)(parsed).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: `Provider returned an unexpected payload: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, - cause, - }), - ), - ); - }); -} - -function assistantMessageFromEvent(event: ProviderRuntimeEvent): string | null { - if ( - event.type !== "item.completed" || - event.payload.itemType !== "assistant_message" || - typeof event.payload.detail !== "string" - ) { - return null; - } - const trimmed = event.payload.detail.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -const makeSessionTextGeneration = Effect.gen(function* () { - const providerService = yield* ProviderService; - - const runProviderJson = ({ - operation, - cwd, - provider, - model, - prompt, - attachments, - schema, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - provider: BranchNameGenerationInput["provider"]; - model: BranchNameGenerationInput["model"]; - prompt: string; - attachments?: BranchNameGenerationInput["attachments"]; - schema: S; - }): Effect.Effect => - Effect.gen(function* () { - const resolvedProvider = provider ?? "codex"; - const resolvedModel = resolveModelSlugForProvider(resolvedProvider, model); - const threadId = toThreadId(`git-textgen-${operation}-${randomUUID()}`); - const eventQueue = yield* Queue.unbounded(); - - yield* Stream.runForEach(providerService.streamEvents, (event) => { - if (event.threadId !== threadId) { - return Effect.void; - } - return Queue.offer(eventQueue, event).pipe(Effect.asVoid); - }).pipe(Effect.forkScoped); - - const cleanup = providerService.stopSession({ threadId }).pipe( - Effect.tapError((e) => Effect.logWarning("Failed to stop text generation session", e)), - Effect.orElseSucceed(() => undefined), - Effect.asVoid, - ); - - return yield* Effect.gen(function* () { - yield* providerService.startSession(threadId, { - threadId, - provider: resolvedProvider, - cwd, - ...(resolvedModel - ? { modelSelection: { provider: resolvedProvider, model: resolvedModel } as never } - : {}), - runtimeMode: "approval-required", - }); - - const turn = yield* providerService.sendTurn({ - threadId, - input: prompt, - ...(resolvedModel - ? { modelSelection: { provider: resolvedProvider, model: resolvedModel } as never } - : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - interactionMode: "default", - }); - - let assistantText = ""; - let fallbackAssistantMessage: string | null = null; - - while (true) { - const event = yield* Queue.take(eventQueue); - if (event.turnId !== undefined && event.turnId !== turn.turnId) { - continue; - } - - if (event.type === "content.delta" && event.payload.streamKind === "assistant_text") { - assistantText += event.payload.delta; - continue; - } - - const assistantMessage = assistantMessageFromEvent(event); - if (assistantMessage && fallbackAssistantMessage === null) { - fallbackAssistantMessage = assistantMessage; - continue; - } - - if (event.type === "request.opened") { - return yield* new TextGenerationError({ - operation, - detail: `The ${resolvedProvider} provider requested '${event.payload.requestType}' while generating git text. Git text generation must run without tools or approvals.`, - }); - } - - if (event.type === "user-input.requested") { - return yield* new TextGenerationError({ - operation, - detail: `The ${resolvedProvider} provider requested interactive input while generating git text.`, - }); - } - - if (event.type === "runtime.error") { - return yield* new TextGenerationError({ - operation, - detail: `${resolvedProvider} provider runtime error: ${event.payload.message}`, - }); - } - - if (event.type === "session.exited") { - return yield* new TextGenerationError({ - operation, - detail: `${resolvedProvider} provider session exited unexpectedly during text generation.`, - }); - } - - if (event.type === "turn.completed") { - if (event.payload.state !== "completed") { - return yield* new TextGenerationError({ - operation, - detail: - event.payload.errorMessage ?? - `${resolvedProvider} provider turn ended with state '${event.payload.state}'.`, - }); - } - - const responseText = assistantText.trim() || fallbackAssistantMessage?.trim() || ""; - return yield* decodeJsonResponse(operation, responseText, schema); - } - } - }).pipe( - Effect.timeoutOption(PROVIDER_TEXT_GENERATION_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation, - detail: `${resolvedProvider} provider request timed out.`, - }), - ), - onSome: (result) => Effect.succeed(result), - }), - ), - Effect.ensuring(cleanup), - Effect.scoped, - ); - }).pipe( - Effect.mapError((cause) => - normalizeProviderTextGenerationError( - operation, - cause, - "Provider git text generation failed", - ), - ), - ); - - const generateCommitMessage: SessionTextGenerationShape["generateCommitMessage"] = (input) => { - const wantsBranch = input.includeBranch === true; - const prompt = [ - "You write concise git commit messages.", - "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", - wantsBranch - ? 'Return a JSON object with keys: "subject", "body", "branch".' - : 'Return a JSON object with keys: "subject", "body".', - "Rules:", - "- subject must be imperative, <= 72 chars, and have no trailing period", - "- body can be an empty string or short bullet points", - ...(wantsBranch - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); - - const schema = wantsBranch - ? Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }) - : Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }); - - return runProviderJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - provider: input.provider, - model: input.model, - prompt, - schema, - }).pipe( - Effect.map( - (generated) => - ({ - subject: generated.subject, - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }) satisfies CommitMessageGenerationResult, - ), - ); - }; - - const generatePrContent: SessionTextGenerationShape["generatePrContent"] = (input) => { - const prompt = [ - "You write GitHub pull request content.", - "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", - 'Return a JSON object with keys: "title", "body".', - "Rules:", - "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commits:", - limitSection(input.commitSummary, 12_000), - "", - "Diff stat:", - limitSection(input.diffSummary, 12_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); - - return runProviderJson({ - operation: "generatePrContent", - cwd: input.cwd, - provider: input.provider, - model: input.model, - prompt, - schema: Schema.Struct({ - title: Schema.String, - body: Schema.String, - }), - }).pipe( - Effect.map( - (generated) => - ({ - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }) satisfies PrContentGenerationResult, - ), - ); - }; - - const generateBranchName: SessionTextGenerationShape["generateBranchName"] = (input) => { - const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, - ); - const promptSections = [ - "You generate concise git branch names.", - "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", - 'Return a JSON object with key: "branch".', - "Rules:", - "- Branch should describe the requested work from the user message.", - "- Keep it short and specific (2-6 words).", - "- Use plain words only, no issue prefixes and no punctuation-heavy text.", - "- If images are attached, use them as primary context for visual/UI issues.", - "", - "User message:", - limitSection(input.message, 8_000), - ]; - if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); - } - - return runProviderJson({ - operation: "generateBranchName", - cwd: input.cwd, - provider: input.provider, - model: input.model, - prompt: promptSections.join("\n"), - attachments: input.attachments, - schema: Schema.Struct({ - branch: Schema.String, - }), - }).pipe( - Effect.map( - (generated) => - ({ - branch: sanitizeBranchFragment(generated.branch), - }) satisfies BranchNameGenerationResult, - ), - ); - }; - - const generateThreadTitle: SessionTextGenerationShape["generateThreadTitle"] = (input) => { - const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, - ); - const promptSections = [ - "You generate concise thread titles.", - "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", - 'Return a JSON object with key: "title".', - "Rules:", - "- Keep the title short and specific.", - "- Use the user's request as the main signal.", - "- If images are attached, use them as primary context for visual/UI issues.", - "", - "User message:", - limitSection(input.message, 8_000), - ]; - if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); - } - - return runProviderJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - provider: input.modelSelection.provider, - model: input.modelSelection.model, - prompt: promptSections.join("\n"), - attachments: input.attachments, - schema: Schema.Struct({ - title: Schema.String, - }), - }).pipe( - Effect.map( - (generated) => - ({ - title: sanitizeThreadTitle(generated.title), - }) satisfies ThreadTitleGenerationResult, - ), - ); - }; - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies SessionTextGenerationShape; -}); - -export const SessionTextGenerationLive = Layer.effect( - SessionTextGeneration, - makeSessionTextGeneration, -); 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/git/Services/CopilotTextGeneration.ts b/apps/server/src/git/Services/CopilotTextGeneration.ts deleted file mode 100644 index 45e2f117da1..00000000000 --- a/apps/server/src/git/Services/CopilotTextGeneration.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Context } from "effect"; -import type { Effect } from "effect"; - -import type { TextGenerationError } from "@t3tools/contracts"; -import type { - CommitMessageGenerationInput, - CommitMessageGenerationResult, - PrContentGenerationInput, - PrContentGenerationResult, -} from "./TextGeneration.ts"; - -export interface CopilotTextGenerationShape { - readonly generateCommitMessage: ( - input: CommitMessageGenerationInput, - ) => Effect.Effect; - readonly generatePrContent: ( - input: PrContentGenerationInput, - ) => Effect.Effect; -} - -export class CopilotTextGeneration extends Context.Service< - CopilotTextGeneration, - CopilotTextGenerationShape ->()("t3/git/Services/CopilotTextGeneration") {} diff --git a/apps/server/src/git/Services/SessionTextGeneration.ts b/apps/server/src/git/Services/SessionTextGeneration.ts deleted file mode 100644 index 351f3fa3603..00000000000 --- a/apps/server/src/git/Services/SessionTextGeneration.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Context } from "effect"; - -import type { TextGenerationShape } from "./TextGeneration.ts"; - -export interface SessionTextGenerationShape extends TextGenerationShape {} - -export class SessionTextGeneration extends Context.Service< - SessionTextGeneration, - SessionTextGenerationShape ->()("t3/git/Services/SessionTextGeneration") {} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index c0356438a29..78d37a01088 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,7 +8,7 @@ */ import { Context } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment, ModelSelection, ProviderKind } from "@t3tools/contracts"; +import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; @@ -20,8 +20,6 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; - provider?: ProviderKind | undefined; - model?: string | undefined; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; /** What model and provider to use for generation. */ @@ -42,8 +40,6 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; - provider?: ProviderKind | undefined; - model?: string | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -56,8 +52,6 @@ export interface PrContentGenerationResult { export interface BranchNameGenerationInput { cwd: string; message: string; - provider?: ProviderKind | undefined; - model?: string | undefined; attachments?: ReadonlyArray | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; diff --git a/apps/server/src/kilo/types.ts b/apps/server/src/kilo/types.ts index 158929d98c2..68f006dfd09 100644 --- a/apps/server/src/kilo/types.ts +++ b/apps/server/src/kilo/types.ts @@ -5,8 +5,9 @@ import type { ProviderSessionStartInput, } from "@t3tools/contracts"; import type { ApprovalRequestId, CanonicalRequestType, ThreadId, TurnId } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; -export const PROVIDER = "kilo" as const; +export const PROVIDER = ProviderDriverKind.make("kilo"); export const DEFAULT_HOSTNAME = "127.0.0.1"; // Kilo defaults to port 0 (OS-assigned), unlike OpenCode's 6733. // We use 0 to always spawn a fresh server and parse the URL from stdout. diff --git a/apps/server/src/kiloServerManager.test.ts b/apps/server/src/kiloServerManager.test.ts deleted file mode 100644 index a863e7b9e28..00000000000 --- a/apps/server/src/kiloServerManager.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { ApprovalRequestId, ThreadId, TurnId, type ProviderRuntimeEvent } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vitest"; - -import { KiloServerManager } from "./kiloServerManager.ts"; -import { - PROVIDER, - type KiloClient, - type KiloProviderSession, - type KiloSessionContext, -} from "./kilo/types.ts"; - -class TestKiloServerManager extends KiloServerManager { - seedSession(context: KiloSessionContext) { - (this as unknown as { sessions: Map }).sessions.set( - context.threadId, - context, - ); - } -} - -function createClient() { - return { - session: { - get: vi.fn(async () => ({})), - create: vi.fn(async () => ({})), - promptAsync: vi.fn(async () => ({})), - abort: vi.fn(async () => ({})), - messages: vi.fn(async () => []), - revert: vi.fn(async () => ({})), - unrevert: vi.fn(async () => ({})), - }, - permission: { - reply: vi.fn(async () => ({})), - }, - question: { - reply: vi.fn(async () => ({})), - }, - provider: { - list: vi.fn(async () => ({ data: { all: [], connected: [] } })), - }, - config: { - providers: vi.fn(async () => ({ data: { providers: [] } })), - }, - event: { - subscribe: vi.fn(async () => ({ stream: (async function* () {})() })), - }, - } satisfies KiloClient; -} - -function createContext(client: KiloClient): KiloSessionContext { - const now = new Date().toISOString(); - return { - threadId: ThreadId.make("thread-kilo"), - directory: process.cwd(), - workspace: "/workspace/project", - client, - providerSessionId: "session-kilo", - pendingPermissions: new Map([ - [ - ApprovalRequestId.make("approval-kilo"), - { - requestId: ApprovalRequestId.make("approval-kilo"), - requestType: "exec_command_approval", - }, - ], - ]), - pendingQuestions: new Map(), - partStreamById: new Map(), - messageIds: [], - streamAbortController: new AbortController(), - streamTask: Promise.resolve(), - session: { - provider: PROVIDER, - status: "running", - runtimeMode: "approval-required", - threadId: ThreadId.make("thread-kilo"), - createdAt: now, - updatedAt: now, - resumeCursor: { sessionId: "session-kilo" }, - activeTurnId: TurnId.make("turn-kilo"), - } as KiloProviderSession, - activeTurnId: TurnId.make("turn-kilo"), - lastError: undefined, - }; -} - -describe("KiloServerManager.respondToRequest", () => { - it("aborts the active turn when the user cancels a pending approval", async () => { - const manager = new TestKiloServerManager(); - const client = createClient(); - const context = createContext(client); - const events: ProviderRuntimeEvent[] = []; - manager.on("event", (event) => { - events.push(event); - }); - manager.seedSession(context); - - await manager.respondToRequest( - context.threadId, - ApprovalRequestId.make("approval-kilo"), - "cancel", - ); - - expect(client.permission.reply).toHaveBeenCalledWith({ - requestID: "approval-kilo", - workspace: "/workspace/project", - reply: "reject", - }); - expect(client.session.abort).toHaveBeenCalledWith({ - sessionID: "session-kilo", - workspace: "/workspace/project", - }); - expect(client.permission.reply.mock.invocationCallOrder[0]).toBeLessThan( - client.session.abort.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, - ); - expect(context.activeTurnId).toBeUndefined(); - expect(context.session.status).toBe("ready"); - expect(events.some((event) => event.type === "turn.completed")).toBe(true); - }); - - it("does not abort the turn for a normal rejection", async () => { - const manager = new TestKiloServerManager(); - const client = createClient(); - const context = createContext(client); - manager.seedSession(context); - - await manager.respondToRequest( - context.threadId, - ApprovalRequestId.make("approval-kilo"), - "decline", - ); - - expect(client.permission.reply).toHaveBeenCalledWith({ - requestID: "approval-kilo", - workspace: "/workspace/project", - reply: "reject", - }); - expect(client.session.abort).not.toHaveBeenCalled(); - expect(context.activeTurnId).toBe(TurnId.make("turn-kilo")); - }); -}); 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 954c4d4726c..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()); @@ -96,7 +101,18 @@ function createProviderServiceHarness( respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions, - getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" } as any), + 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 ddcc99902b8..10639c1b42d 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, @@ -351,6 +352,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", @@ -609,6 +611,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", @@ -880,6 +883,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 ce796de7842..c44f291504a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2,7 +2,14 @@ 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, CommandId, @@ -31,16 +38,16 @@ import { GitStatusBroadcaster, type GitStatusBroadcasterShape, } from "../../git/Services/GitStatusBroadcaster.ts"; -import { - TextGeneration, - type TextGenerationShape, - type ThreadTitleGenerationResult, -} from "../../git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; 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"; @@ -101,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; @@ -115,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) => { @@ -131,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" && @@ -147,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, @@ -217,14 +266,13 @@ describe("ProviderCommandReactor", () => { }), ), ); - const generateThreadTitle = vi.fn( - (_input?: unknown): Effect.Effect => - Effect.fail( - new TextGenerationError({ - operation: "generateThreadTitle", - detail: "disabled in test harness", - }), - ), + const generateThreadTitle = vi.fn((_) => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "disabled in test harness", + }), + ), ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; @@ -239,14 +287,26 @@ describe("ProviderCommandReactor", () => { getCapabilities: (_provider) => Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", - transport: "app-server-json-rpc" as const, - modelDiscovery: "native" as const, - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: true, - supportsAttachments: true, - persistentRuntime: true, }), + 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); @@ -278,7 +338,7 @@ describe("ProviderCommandReactor", () => { Layer.mock(TextGeneration, { generateBranchName, generateThreadTitle, - } as unknown as TextGenerationShape), + }), ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -331,6 +391,7 @@ describe("ProviderCommandReactor", () => { refreshStatus, generateBranchName, generateThreadTitle, + runtimeSessions, stateDir, drain, }; @@ -363,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", @@ -585,14 +646,10 @@ describe("ProviderCommandReactor", () => { text: "hello fast mode", attachments: [], }, - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -602,31 +659,26 @@ 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: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + 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: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }); }); 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(); @@ -641,13 +693,11 @@ describe("ProviderCommandReactor", () => { text: "hello with effort", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -657,29 +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: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "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: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "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(); @@ -694,13 +743,11 @@ describe("ProviderCommandReactor", () => { text: "hello with fast mode", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -710,23 +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: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: 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: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), }); }); @@ -813,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(); @@ -837,7 +880,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -846,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 () => { @@ -917,9 +956,147 @@ 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: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-workspace-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-workspace-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/provider-project", + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-change"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-workspace-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-workspace-2"), + role: "user", + text: "second in worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project-worktree", + resumeCursor: { opaque: "resume-1" }, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + runtimeMode: "approval-required", + }); + }); + 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(); @@ -934,13 +1111,11 @@ describe("ProviderCommandReactor", () => { text: "first claude turn", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "medium", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "medium" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -961,13 +1136,11 @@ describe("ProviderCommandReactor", () => { text: "second claude turn", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -978,13 +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: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); }); @@ -1075,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(); @@ -1111,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", @@ -1219,7 +1393,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1245,10 +1419,76 @@ describe("ProviderCommandReactor", () => { const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.providerName).toBe("codex"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); + }); + + 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({ - summary: "Provider turn start failed", payload: { detail: expect.stringContaining("cannot switch to 'claudeAgent'"), }, @@ -1338,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", @@ -1348,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(); @@ -1440,7 +1751,7 @@ describe("ProviderCommandReactor", () => { harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "cursor", + provider: ProviderDriverKind.make("codex"), method: "session/request_permission", detail: "Unknown pending permission request: approval-request-1", }), @@ -1455,7 +1766,7 @@ describe("ProviderCommandReactor", () => { session: { threadId: ThreadId.make("thread-1"), status: "running", - providerName: "cursor", + providerName: "codex", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -1535,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", }), @@ -1651,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, @@ -1675,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 8a24c9538a0..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) { @@ -170,18 +185,7 @@ const make = Effect.gen(function* () { ), ); - const threadModelSelections = new Map(); - const getThreadModelSelection = (threadId: ThreadId) => - Effect.sync(() => { - const modelSelection = threadModelSelections.get(threadId); - return modelSelection === undefined - ? Option.none() - : Option.some(modelSelection); - }); - const setThreadModelSelection = (threadId: ThreadId, modelSelection: ModelSelection) => - Effect.sync(() => { - threadModelSelections.set(threadId, modelSelection); - }); + const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -282,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 } : {}), @@ -325,46 +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 shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; - const previousModelSelection = Option.getOrUndefined( - yield* getThreadModelSelection(threadId), - ); + 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 ) { @@ -377,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, @@ -386,6 +467,7 @@ const make = Effect.gen(function* () { desiredCwd: effectiveCwd, cwdChanged, modelChanged, + instanceChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, @@ -430,7 +512,7 @@ const make = Effect.gen(function* () { input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, ); if (input.modelSelection !== undefined) { - yield* setThreadModelSelection(input.threadId, input.modelSelection); + threadModelSelections.set(input.threadId, input.modelSelection); } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -442,11 +524,16 @@ 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 ?? - Option.getOrUndefined(yield* getThreadModelSelection(input.threadId)) ?? - thread.modelSelection; + input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = sessionModelSwitch === "unsupported" && input.modelSelection === undefined ? activeSession?.model !== undefined @@ -815,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, @@ -841,9 +931,7 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - const cachedModelSelection = Option.getOrUndefined( - yield* getThreadModelSelection(event.payload.threadId), - ); + const cachedModelSelection = threadModelSelections.get(event.payload.threadId); yield* ensureSessionForThread( event.payload.threadId, event.occurredAt, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 6352428dac0..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, @@ -29,7 +31,6 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { getProviderCapabilities } from "../../provider/Services/ProviderAdapter.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -98,7 +99,20 @@ function createProviderServiceHarness() { respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([...runtimeSessions]), - getCapabilities: () => Effect.succeed(getProviderCapabilities("codex")), + 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); @@ -143,7 +157,7 @@ function createProviderServiceHarness() { async function waitForThread( engine: OrchestrationEngineShape, predicate: (thread: ProviderRuntimeTestThread) => boolean, - timeoutMs = 5000, + timeoutMs = 2000, threadId: ThreadId = asThreadId("thread-1"), ) { const deadline = Date.now() + timeoutMs; @@ -233,7 +247,7 @@ describe("ProviderRuntimeIngestion", () => { title: "Provider Project", workspaceRoot, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -247,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, @@ -275,7 +289,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); provider.setSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", runtimeMode: "approval-required", threadId: ThreadId.make("thread-1"), @@ -298,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"), @@ -312,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"), @@ -340,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: { @@ -359,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: { @@ -381,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: { @@ -402,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: { @@ -428,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"), @@ -444,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"), }); @@ -465,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"), @@ -503,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"), @@ -519,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"), @@ -536,31 +550,10 @@ describe("ProviderRuntimeIngestion", () => { const harness = await createHarness(); const now = new Date().toISOString(); - // Seed thread-2 so the auxiliary completion targets a real but different thread - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-thread2-seed"), - provider: "codex", - createdAt: now, - threadId: asThreadId("thread-2"), - turnId: asTurnId("turn-thread2-seed"), - }); - - harness.emit({ - type: "turn.completed", - eventId: asEventId("evt-turn-completed-thread2-seed"), - provider: "codex", - createdAt: now, - threadId: asThreadId("thread-2"), - turnId: asTurnId("turn-thread2-seed"), - status: "completed", - }); - - // Start primary turn on thread-1 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"), @@ -572,13 +565,12 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary", ); - // Emit auxiliary turn.completed on thread-2 — should not affect thread-1 harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-aux"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: new Date().toISOString(), - threadId: asThreadId("thread-2"), + threadId: asThreadId("thread-1"), turnId: asTurnId("turn-aux"), status: "completed", }); @@ -592,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"), @@ -612,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"), @@ -628,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"), @@ -644,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"), @@ -664,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"), @@ -677,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"), @@ -690,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"), @@ -721,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"), @@ -753,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"), @@ -809,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"), @@ -851,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"), @@ -893,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"), @@ -932,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", @@ -967,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, @@ -995,7 +987,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", runtimeMode: "approval-required", threadId: targetThreadId, @@ -1007,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, @@ -1077,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, @@ -1119,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", @@ -1147,7 +1139,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "running", runtimeMode: "approval-required", threadId: targetThreadId, @@ -1159,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, @@ -1176,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, @@ -1229,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, @@ -1272,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", @@ -1307,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, @@ -1338,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, @@ -1389,7 +1381,7 @@ describe("ProviderRuntimeIngestion", () => { ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "running", runtimeMode: "approval-required", threadId: targetThreadId, @@ -1401,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, @@ -1428,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"), @@ -1443,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"), @@ -1454,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"), @@ -1465,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"), @@ -1494,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"), @@ -1508,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"), @@ -1531,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"), @@ -1555,15 +1547,14 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); - // TODO(upstream-sync): re-enable once assistant segmentation reconciled - it.skip("flushes and completes buffered assistant text when an approval request opens", async () => { + it("flushes and completes buffered assistant text when an approval request opens", async () => { const harness = await createHarness(); const now = new Date().toISOString(); 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"), @@ -1578,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"), @@ -1591,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"), @@ -1616,15 +1607,14 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); - // TODO(upstream-sync): re-enable once assistant segmentation reconciled - it.skip("flushes and completes buffered assistant text when user input is requested", async () => { + it("flushes and completes buffered assistant text when user input is requested", async () => { const harness = await createHarness(); const now = new Date().toISOString(); 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"), @@ -1639,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"), @@ -1652,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"), @@ -1692,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"), @@ -1707,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"), @@ -1720,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"), @@ -1744,8 +1734,7 @@ describe("ProviderRuntimeIngestion", () => { ).toBe(false); }); - // TODO(upstream-sync): re-enable once assistant segmentation reconciled - it.skip("starts a new buffered assistant message segment after approval and completes without duplication", async () => { + it("starts a new buffered assistant message segment after approval and completes without duplication", async () => { const harness = await createHarness(); const startedAt = "2026-03-28T06:07:00.000Z"; const pausedAt = "2026-03-28T06:07:01.000Z"; @@ -1755,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"), @@ -1770,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"), @@ -1783,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"), @@ -1806,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"), @@ -1819,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"), @@ -1877,8 +1866,7 @@ describe("ProviderRuntimeIngestion", () => { expect(assistantEvents[3]?.payload.text).toBe(""); }); - // TODO(upstream-sync): re-enable once assistant segmentation reconciled - it.skip("starts a new streaming assistant message segment after approval", async () => { + it("starts a new streaming assistant message segment after approval", async () => { const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const startedAt = "2026-03-28T07:00:00.000Z"; const pausedAt = "2026-03-28T07:00:01.000Z"; @@ -1888,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"), @@ -1903,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"), @@ -1916,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"), @@ -1939,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"), @@ -1952,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"), @@ -2010,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"), @@ -2025,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"), @@ -2052,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"), @@ -2077,255 +2065,6 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); - it("completes streaming assistant messages even when read model lookup lags completion", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-streaming-lag"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("message-streaming-lag"), - role: "user", - text: "stream with lag", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - await Effect.runPromise(Effect.sleep("30 millis")); - - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-streaming-lag"), - provider: "codex", - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-streaming-lag"), - }); - await waitForThread( - harness.engine, - (thread) => - thread.session?.status === "running" && - thread.session?.activeTurnId === "turn-streaming-lag", - ); - - harness.emit({ - type: "content.delta", - eventId: asEventId("evt-message-delta-streaming-lag"), - provider: "codex", - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-streaming-lag"), - itemId: asItemId("item-streaming-lag"), - payload: { - streamKind: "assistant_text", - delta: "hello lagged", - }, - }); - - const liveThread = await waitForThread(harness.engine, (entry) => - entry.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-lag" && - message.streaming && - message.text === "hello lagged", - ), - ); - const liveMessage = liveThread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-lag", - ); - expect(liveMessage?.streaming).toBe(true); - - harness.emit({ - type: "item.completed", - eventId: asEventId("evt-message-completed-streaming-lag"), - provider: "codex", - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-streaming-lag"), - itemId: asItemId("item-streaming-lag"), - payload: { - itemType: "assistant_message", - status: "completed", - }, - }); - - const finalThread = await waitForThread(harness.engine, (entry) => - entry.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-lag" && !message.streaming, - ), - ); - const finalMessage = finalThread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-lag", - ); - expect(finalMessage?.text).toBe("hello lagged"); - expect(finalMessage?.streaming).toBe(false); - }); - - it.skip("splits streaming assistant text into separate messages around tool activity", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); - const turnStartedAt = "2026-03-09T10:00:00.000Z"; - const beforeToolAt = "2026-03-09T10:00:01.000Z"; - const toolAt = "2026-03-09T10:00:02.000Z"; - const afterToolAt = "2026-03-09T10:00:03.000Z"; - const completedAt = "2026-03-09T10:00:04.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-interleaved-streaming"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("message-interleaved-streaming"), - role: "user", - text: "show interleaving", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: turnStartedAt, - }), - ); - await Effect.runPromise(Effect.sleep("30 millis")); - - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-interleaved-streaming"), - provider: "codex", - createdAt: turnStartedAt, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-interleaved-streaming"), - }); - await waitForThread( - harness.engine, - (thread) => - thread.session?.status === "running" && - thread.session?.activeTurnId === "turn-interleaved-streaming", - ); - - harness.emit({ - type: "content.delta", - eventId: asEventId("evt-message-delta-before-tool"), - provider: "codex", - createdAt: beforeToolAt, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-interleaved-streaming"), - itemId: asItemId("item-interleaved-streaming"), - payload: { - streamKind: "assistant_text", - delta: "Before tool.", - }, - }); - await waitForThread(harness.engine, (thread) => - thread.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-interleaved-streaming" && - message.streaming && - message.text === "Before tool.", - ), - ); - - harness.emit({ - type: "item.updated", - eventId: asEventId("evt-tool-updated-interleaved-streaming"), - provider: "codex", - createdAt: toolAt, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-interleaved-streaming"), - itemId: asItemId("tool-interleaved-streaming"), - payload: { - itemType: "command_execution", - status: "in_progress", - title: "Run command", - detail: "pwd", - }, - }); - await waitForThread( - harness.engine, - (thread) => - thread.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-interleaved-streaming" && - !message.streaming && - message.text === "Before tool.", - ) && - thread.activities.some( - (activity: ProviderRuntimeTestActivity) => - activity.id === "evt-tool-updated-interleaved-streaming" && - activity.kind === "tool.updated", - ), - ); - - harness.emit({ - type: "content.delta", - eventId: asEventId("evt-message-delta-after-tool"), - provider: "codex", - createdAt: afterToolAt, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-interleaved-streaming"), - itemId: asItemId("item-interleaved-streaming"), - payload: { - streamKind: "assistant_text", - delta: "After tool.", - }, - }); - await waitForThread(harness.engine, (thread) => - thread.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-interleaved-streaming:segment:1" && - message.streaming && - message.text === "After tool.", - ), - ); - - harness.emit({ - type: "item.completed", - eventId: asEventId("evt-message-completed-interleaved-streaming"), - provider: "codex", - createdAt: completedAt, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-interleaved-streaming"), - itemId: asItemId("item-interleaved-streaming"), - payload: { - itemType: "assistant_message", - status: "completed", - }, - }); - - const thread = await waitForThread(harness.engine, (entry) => - entry.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-interleaved-streaming:segment:1" && !message.streaming, - ), - ); - expect( - thread.messages.map((message: ProviderRuntimeTestMessage) => ({ - id: message.id, - text: message.text, - streaming: message.streaming, - })), - ).toEqual( - expect.arrayContaining([ - { - id: "assistant:item-interleaved-streaming", - text: "Before tool.", - streaming: false, - }, - { - id: "assistant:item-interleaved-streaming:segment:1", - text: "After tool.", - streaming: false, - }, - ]), - ); - }); - it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -2334,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"), @@ -2349,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"), @@ -2362,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"), @@ -2394,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"), @@ -2410,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"), @@ -2423,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"), @@ -2436,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"), @@ -2480,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"), @@ -2493,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"), @@ -2546,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"), @@ -2573,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"), @@ -2604,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"), @@ -2614,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"), @@ -2648,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", @@ -2656,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"), @@ -2700,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: { @@ -2712,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"), @@ -2728,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"), @@ -2745,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"), @@ -2758,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"), @@ -2834,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: { @@ -2886,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: { @@ -2939,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: { @@ -2983,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"), @@ -3013,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"), @@ -3026,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"), @@ -3040,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"), @@ -3053,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"), @@ -3116,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"), @@ -3141,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"), @@ -3189,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"), @@ -3203,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 7d98bbe0380..b7a4c195a5b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -32,6 +32,12 @@ const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${t const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); +interface AssistantSegmentState { + baseKey: string; + nextSegmentIndex: number; + activeMessageId: MessageId | null; +} + const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; @@ -56,18 +62,6 @@ type RuntimeIngestionInput = event: TurnStartRequestedDomainEvent; }; -type AssistantSegmentState = { - baseMessageId: MessageId; - currentSegmentIndex: number | null; - nextSegmentIndex: number; -}; - -const assistantSegmentStateKey = (threadId: ThreadId, baseMessageId: MessageId) => - `${threadId}:${baseMessageId}`; - -const assistantSegmentMessageId = (baseMessageId: MessageId, segmentIndex: number): MessageId => - segmentIndex === 0 ? baseMessageId : MessageId.make(`${baseMessageId}:segment:${segmentIndex}`); - function toTurnId(value: TurnId | string | undefined): TurnId | undefined { return value === undefined ? undefined : TurnId.make(String(value)); } @@ -95,6 +89,10 @@ function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string return trimmed; } +function hasRenderableAssistantText(text: string | undefined): boolean { + return (text?.trim().length ?? 0) > 0; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -110,10 +108,15 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId return `plan:${threadId}:event:${event.eventId}`; } -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; +function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string { + return String(event.itemId ?? event.turnId ?? event.eventId); } +function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId { + return MessageId.make( + segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`, + ); +} function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { @@ -123,14 +126,6 @@ function buildContextWindowActivityPayload( return event.payload.usage; } -function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { - const payload = (event as { payload?: unknown }).payload; - if (!payload || typeof payload !== "object") { - return undefined; - } - return payload as Record; -} - function normalizeRuntimeTurnState( value: string | undefined, ): "completed" | "failed" | "interrupted" | "cancelled" { @@ -145,38 +140,6 @@ function normalizeRuntimeTurnState( } } -function runtimeTurnState( - event: ProviderRuntimeEvent, -): "completed" | "failed" | "interrupted" | "cancelled" { - const payloadState = asString(runtimePayloadRecord(event)?.state); - return normalizeRuntimeTurnState(payloadState); -} - -function runtimeTurnErrorMessage(event: ProviderRuntimeEvent): string | undefined { - const payloadErrorMessage = asString(runtimePayloadRecord(event)?.errorMessage); - return payloadErrorMessage; -} - -function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | undefined { - const payloadMessage = asString(runtimePayloadRecord(event)?.message); - return payloadMessage; -} - -function runtimeErrorClassLabel(errorClass: string): string | undefined { - switch (errorClass) { - case "provider_error": - return "Provider error"; - case "transport_error": - return "Connection error"; - case "permission_error": - return "Permission error"; - case "validation_error": - return "Validation error"; - default: - return undefined; - } -} - function orchestrationSessionStatusFromRuntimeState( state: "starting" | "running" | "waiting" | "ready" | "interrupted" | "stopped" | "error", ): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { @@ -280,23 +243,15 @@ function runtimeEventToActivities( } case "runtime.error": { - const message = runtimeErrorMessageFromEvent(event); - if (!message) { - return []; - } - const errorClass = runtimePayloadRecord(event)?.class; - const errorClassLabel = - typeof errorClass === "string" ? runtimeErrorClassLabel(errorClass) : undefined; return [ { id: event.eventId, createdAt: event.createdAt, tone: "error", kind: "runtime.error", - summary: errorClassLabel ?? "Runtime error", + summary: "Runtime error", payload: { - message: truncateDetail(message), - detail: truncateDetail(message), + message: truncateDetail(event.payload.message), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -565,7 +520,7 @@ function runtimeEventToActivities( return []; } -const make = Effect.fn("make")(function* () { +const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -577,19 +532,19 @@ const make = Effect.fn("make")(function* () { lookup: () => Effect.succeed(new Set()), }); - const bufferedAssistantTextByMessageId = yield* Cache.make< - MessageId, - { text: string; createdAt: string } - >({ + const bufferedAssistantTextByMessageId = yield* Cache.make({ capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, - lookup: () => Effect.succeed({ text: "", createdAt: "" }), + lookup: () => Effect.succeed(""), }); - const assistantMessageSawDeltaByMessageId = yield* Cache.make({ - capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, - timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, - lookup: () => Effect.succeed(false), + const assistantSegmentStateByTurnKey = yield* Cache.make({ + capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, + timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, + lookup: () => + Effect.die( + new Error("assistant segment state should be read through getOption before initialization"), + ), }); const bufferedProposedPlanById = yield* Cache.make({ @@ -598,9 +553,6 @@ const make = Effect.fn("make")(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); - const assistantSegmentStateByKey = new Map(); - const assistantSegmentKeysByTurnKey = new Map>(); - const isGitRepoForThread = Effect.fn("isGitRepoForThread")(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); @@ -662,39 +614,107 @@ const make = Effect.fn("make")(function* () { const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); - const appendBufferedAssistantText = (messageId: MessageId, delta: string, createdAt: string) => + const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); + + const setAssistantSegmentStateForTurn = ( + threadId: ThreadId, + turnId: TurnId, + state: AssistantSegmentState, + ) => Cache.set(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId), state); + + const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); + + const getActiveAssistantMessageIdForTurn = (threadId: ThreadId, turnId: TurnId) => + getAssistantSegmentStateForTurn(threadId, turnId).pipe( + Effect.map((state) => + Option.flatMap(state, (entry) => + entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(), + ), + ), + ); + + const startAssistantSegmentForTurn = (input: { + threadId: ThreadId; + turnId: TurnId; + baseKey: string; + }) => + getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe( + Effect.flatMap((existingState) => + Effect.gen(function* () { + const nextState = Option.match(existingState, { + onNone: () => ({ + baseKey: input.baseKey, + nextSegmentIndex: 1, + activeMessageId: assistantSegmentMessageId(input.baseKey, 0), + }), + onSome: (state) => { + const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0; + const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex); + return { + baseKey: input.baseKey, + nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1, + activeMessageId: messageId, + } satisfies AssistantSegmentState; + }, + }); + yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState); + return nextState.activeMessageId!; + }), + ), + ); + + const getOrCreateAssistantMessageId = (input: { + threadId: ThreadId; + event: ProviderRuntimeEvent; + turnId?: TurnId; + }) => + Effect.gen(function* () { + if (!input.turnId) { + return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0); + } + + const activeMessageId = yield* getActiveAssistantMessageIdForTurn( + input.threadId, + input.turnId, + ); + if (Option.isSome(activeMessageId)) { + return activeMessageId.value; + } + + return yield* startAssistantSegmentForTurn({ + threadId: input.threadId, + turnId: input.turnId, + baseKey: assistantSegmentBaseKeyFromEvent(input.event), + }); + }); + + const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( - Effect.flatMap( - Effect.fn("appendBufferedAssistantText")(function* (existing) { - const prev = Option.getOrUndefined(existing); - const nextText = `${prev?.text ?? ""}${delta}`; - const nextCreatedAt = - prev?.createdAt && prev.createdAt.length > 0 ? prev.createdAt : createdAt; + Effect.flatMap((existingText) => + Effect.gen(function* () { + const nextText = Option.match(existingText, { + onNone: () => delta, + onSome: (text) => `${text}${delta}`, + }); if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { - yield* Cache.set(bufferedAssistantTextByMessageId, messageId, { - text: nextText, - createdAt: nextCreatedAt, - }); - return { spillChunk: "", createdAt: nextCreatedAt }; + yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); + return ""; } // Safety valve: flush full buffered text as an assistant delta to cap memory. yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); - return { spillChunk: nextText, createdAt: nextCreatedAt }; + return nextText; }), ), ); const takeBufferedAssistantText = (messageId: MessageId) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( - Effect.flatMap((existing) => + Effect.flatMap((existingText) => Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe( - Effect.as( - Option.match(existing, { - onNone: () => ({ text: "", createdAt: "" }), - onSome: (entry) => entry, - }), - ), + Effect.as(Option.getOrElse(existingText, () => "")), ), ), ); @@ -702,18 +722,6 @@ const make = Effect.fn("make")(function* () { const clearBufferedAssistantText = (messageId: MessageId) => Cache.invalidate(bufferedAssistantTextByMessageId, messageId); - const markAssistantMessageSawDelta = (messageId: MessageId) => - Cache.set(assistantMessageSawDeltaByMessageId, messageId, true); - - const takeAssistantMessageSawDelta = (messageId: MessageId) => - Cache.getOption(assistantMessageSawDeltaByMessageId, messageId).pipe( - Effect.flatMap((existing) => - Cache.invalidate(assistantMessageSawDeltaByMessageId, messageId).pipe( - Effect.as(Option.getOrElse(existing, () => false)), - ), - ), - ); - const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( Effect.flatMap((existingEntry) => { @@ -738,169 +746,69 @@ const make = Effect.fn("make")(function* () { const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); - const rememberAssistantSegmentKeyForTurn = ( - threadId: ThreadId, - turnId: TurnId, - stateKey: string, - ): void => { - const turnKey = providerTurnKey(threadId, turnId); - const existing = assistantSegmentKeysByTurnKey.get(turnKey); - if (existing) { - existing.add(stateKey); - return; - } - assistantSegmentKeysByTurnKey.set(turnKey, new Set([stateKey])); - }; - - const clearAssistantSegmentsForTurn = (threadId: ThreadId, turnId: TurnId): void => { - const turnKey = providerTurnKey(threadId, turnId); - const stateKeys = assistantSegmentKeysByTurnKey.get(turnKey); - if (!stateKeys) { - return; - } - for (const stateKey of stateKeys) { - assistantSegmentStateByKey.delete(stateKey); - } - assistantSegmentKeysByTurnKey.delete(turnKey); - }; - - const clearAssistantSegment = (input: { - threadId: ThreadId; - baseMessageId: MessageId; - turnId?: TurnId; - }): void => { - const stateKey = assistantSegmentStateKey(input.threadId, input.baseMessageId); - assistantSegmentStateByKey.delete(stateKey); - if (!input.turnId) { - return; - } - const turnKey = providerTurnKey(input.threadId, input.turnId); - const stateKeys = assistantSegmentKeysByTurnKey.get(turnKey); - if (!stateKeys) { - return; - } - stateKeys.delete(stateKey); - if (stateKeys.size === 0) { - assistantSegmentKeysByTurnKey.delete(turnKey); - } - }; - - const clearAssistantSegmentsForThread = (threadId: ThreadId): void => { - const prefix = `${threadId}:`; - for (const key of assistantSegmentKeysByTurnKey.keys()) { - if (!key.startsWith(prefix)) { - continue; - } - const stateKeys = assistantSegmentKeysByTurnKey.get(key); - if (stateKeys) { - for (const stateKey of stateKeys) { - assistantSegmentStateByKey.delete(stateKey); - } - } - assistantSegmentKeysByTurnKey.delete(key); - } - }; + const clearAssistantMessageState = (messageId: MessageId) => + clearBufferedAssistantText(messageId); - const openAssistantSegment = (input: { + const flushBufferedAssistantMessage = (input: { + event: ProviderRuntimeEvent; threadId: ThreadId; - baseMessageId: MessageId; + messageId: MessageId; turnId?: TurnId; - }): MessageId => { - const stateKey = assistantSegmentStateKey(input.threadId, input.baseMessageId); - const existingState = assistantSegmentStateByKey.get(stateKey); - if (existingState && existingState.currentSegmentIndex !== null) { - if (input.turnId) { - rememberAssistantSegmentKeyForTurn(input.threadId, input.turnId, stateKey); + createdAt: string; + commandTag: string; + }) => + Effect.gen(function* () { + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + if (!hasRenderableAssistantText(bufferedText)) { + return false; } - return assistantSegmentMessageId( - existingState.baseMessageId, - existingState.currentSegmentIndex, - ); - } - const segmentIndex = existingState?.nextSegmentIndex ?? 0; - assistantSegmentStateByKey.set(stateKey, { - baseMessageId: input.baseMessageId, - currentSegmentIndex: segmentIndex, - nextSegmentIndex: segmentIndex + 1, + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(input.event, input.commandTag), + threadId: input.threadId, + messageId: input.messageId, + delta: bufferedText, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + return true; }); - if (input.turnId) { - rememberAssistantSegmentKeyForTurn(input.threadId, input.turnId, stateKey); - } - return assistantSegmentMessageId(input.baseMessageId, segmentIndex); - }; - const takeOpenAssistantSegmentMessageId = (input: { - threadId: ThreadId; - baseMessageId: MessageId; - }): { messageId: MessageId; hadAnySegment: boolean } | null => { - const stateKey = assistantSegmentStateKey(input.threadId, input.baseMessageId); - const state = assistantSegmentStateByKey.get(stateKey); - if (!state) { - return { messageId: input.baseMessageId, hadAnySegment: false }; - } - if (state.currentSegmentIndex === null) { - return state.nextSegmentIndex > 0 - ? null - : { messageId: input.baseMessageId, hadAnySegment: false }; - } - return { - messageId: assistantSegmentMessageId(state.baseMessageId, state.currentSegmentIndex), - hadAnySegment: state.nextSegmentIndex > 0, - }; - }; - - const closeOpenAssistantSegmentsForTurn = (input: { + const flushBufferedAssistantMessagesForTurn = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; turnId: TurnId; createdAt: string; - existingAssistantMessageById: ReadonlyMap< - MessageId, - { readonly id: MessageId; readonly text: string; readonly streaming: boolean } - >; + commandTag: string; }) => Effect.gen(function* () { - const turnKey = providerTurnKey(input.threadId, input.turnId); - const stateKeys = Array.from(assistantSegmentKeysByTurnKey.get(turnKey) ?? []); + const assistantMessageIds = yield* getAssistantMessageIdsForTurn( + input.threadId, + input.turnId, + ); + const flushedMessageIds = new Set(); yield* Effect.forEach( - stateKeys, - (stateKey) => - Effect.gen(function* () { - const state = assistantSegmentStateByKey.get(stateKey); - if (!state || state.currentSegmentIndex === null) { - return; - } - const messageId = assistantSegmentMessageId( - state.baseMessageId, - state.currentSegmentIndex, - ); - assistantSegmentStateByKey.set(stateKey, { - ...state, - currentSegmentIndex: null, - }); - yield* finalizeAssistantMessage({ - event: input.event, - threadId: input.threadId, - messageId, - turnId: input.turnId, - createdAt: input.createdAt, - commandTag: "assistant-complete-tool-boundary", - finalDeltaCommandTag: "assistant-delta-tool-boundary", - existingMessage: input.existingAssistantMessageById.get(messageId), - }); - }), + assistantMessageIds, + (messageId) => + flushBufferedAssistantMessage({ + event: input.event, + threadId: input.threadId, + messageId, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: input.commandTag, + }).pipe( + Effect.tap((flushed) => + flushed ? Effect.sync(() => flushedMessageIds.add(messageId)) : Effect.void, + ), + ), { concurrency: 1 }, ).pipe(Effect.asVoid); + return flushedMessageIds; }); - const clearAssistantMessageState = (messageId: MessageId) => - Effect.all([ - clearBufferedAssistantText(messageId), - Cache.invalidate(assistantMessageSawDeltaByMessageId, messageId), - ]).pipe(Effect.asVoid); - - const finalizeAssistantMessage = Effect.fn("finalizeAssistantMessage")(function* (input: { + const finalizeAssistantMessage = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; messageId: MessageId; @@ -909,65 +817,86 @@ const make = Effect.fn("make")(function* () { commandTag: string; finalDeltaCommandTag: string; fallbackText?: string; - existingMessage?: - | { - readonly id: MessageId; - readonly text: string; - readonly streaming: boolean; - } - | undefined; - }) { - if (input.existingMessage && !input.existingMessage.streaming) { - yield* clearAssistantMessageState(input.messageId); - return; - } - - const buffered = yield* takeBufferedAssistantText(input.messageId); - const bufferedText = buffered.text; - - const sawDelta = yield* takeAssistantMessageSawDelta(input.messageId); - const text = - bufferedText.length > 0 - ? bufferedText - : !sawDelta && (input.fallbackText?.trim().length ?? 0) > 0 - ? input.fallbackText! - : ""; + hasProjectedMessage?: boolean; + }) => + Effect.gen(function* () { + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const text = + bufferedText.length > 0 + ? bufferedText + : (input.fallbackText?.trim().length ?? 0) > 0 + ? input.fallbackText! + : ""; + const hasRenderableText = hasRenderableAssistantText(text); + + if (hasRenderableText) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(input.event, input.finalDeltaCommandTag), + threadId: input.threadId, + messageId: input.messageId, + delta: text, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + } - if (text.length === 0 && !input.existingMessage) { + if (input.hasProjectedMessage || hasRenderableText) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.complete", + commandId: providerCommandId(input.event, input.commandTag), + threadId: input.threadId, + messageId: input.messageId, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + } yield* clearAssistantMessageState(input.messageId); - return; - } + }); - // Use the original timestamp from when the first delta arrived, not the - // finalization time. This ensures assistant text messages are positioned - // chronologically relative to tool activities in the timeline instead of - // all appearing at the end when the turn completes. - const deltaCreatedAt = buffered.createdAt.length > 0 ? buffered.createdAt : input.createdAt; + const finalizeActiveAssistantSegmentForTurn = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + commandTag: string; + finalDeltaCommandTag: string; + hasProjectedMessage: boolean; + flushedMessageIds?: ReadonlySet; + }) => + Effect.gen(function* () { + const activeMessageId = yield* getActiveAssistantMessageIdForTurn( + input.threadId, + input.turnId, + ); + if (Option.isNone(activeMessageId)) { + return; + } - if (text.length > 0) { - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.delta", - commandId: providerCommandId(input.event, input.finalDeltaCommandTag), + yield* finalizeAssistantMessage({ + event: input.event, threadId: input.threadId, - messageId: input.messageId, - delta: text, - ...(input.turnId ? { turnId: input.turnId } : {}), - createdAt: deltaCreatedAt, + messageId: activeMessageId.value, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: input.commandTag, + finalDeltaCommandTag: input.finalDeltaCommandTag, + hasProjectedMessage: + input.hasProjectedMessage || + (input.flushedMessageIds?.has(activeMessageId.value) ?? false), }); - } + yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value); - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.complete", - commandId: providerCommandId(input.event, input.commandTag), - threadId: input.threadId, - messageId: input.messageId, - ...(input.turnId ? { turnId: input.turnId } : {}), - createdAt: input.createdAt, + const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); + if (Option.isSome(state)) { + yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, { + ...state.value, + activeMessageId: null, + }); + } }); - yield* clearAssistantMessageState(input.messageId); - }); - const upsertProposedPlan = Effect.fn("upsertProposedPlan")(function* (input: { + const upsertProposedPlan = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; threadProposedPlans: ReadonlyArray<{ @@ -981,31 +910,32 @@ const make = Effect.fn("make")(function* () { planMarkdown: string | undefined; createdAt: string; updatedAt: string; - }) { - const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); - if (!planMarkdown) { - return; - } + }) => + Effect.gen(function* () { + const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); + if (!planMarkdown) { + return; + } - const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); - yield* orchestrationEngine.dispatch({ - type: "thread.proposed-plan.upsert", - commandId: providerCommandId(input.event, "proposed-plan-upsert"), - threadId: input.threadId, - proposedPlan: { - id: input.planId, - turnId: input.turnId ?? null, - planMarkdown, - implementedAt: existingPlan?.implementedAt ?? null, - implementationThreadId: existingPlan?.implementationThreadId ?? null, - createdAt: existingPlan?.createdAt ?? input.createdAt, - updatedAt: input.updatedAt, - }, - createdAt: input.updatedAt, + const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: providerCommandId(input.event, "proposed-plan-upsert"), + threadId: input.threadId, + proposedPlan: { + id: input.planId, + turnId: input.turnId ?? null, + planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? input.createdAt, + updatedAt: input.updatedAt, + }, + createdAt: input.updatedAt, + }); }); - }); - const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: { + const finalizeBufferedProposedPlan = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; threadProposedPlans: ReadonlyArray<{ @@ -1018,71 +948,75 @@ const make = Effect.fn("make")(function* () { turnId?: TurnId; fallbackMarkdown?: string; updatedAt: string; - }) { - const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); - const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); - const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); - const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; - if (!planMarkdown) { - return; - } + }) => + Effect.gen(function* () { + const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); + const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); + const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); + const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; + if (!planMarkdown) { + return; + } - yield* upsertProposedPlan({ - event: input.event, - threadId: input.threadId, - threadProposedPlans: input.threadProposedPlans, - planId: input.planId, - ...(input.turnId ? { turnId: input.turnId } : {}), - planMarkdown, - createdAt: - bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 - ? bufferedPlan.createdAt - : input.updatedAt, - updatedAt: input.updatedAt, + yield* upsertProposedPlan({ + event: input.event, + threadId: input.threadId, + threadProposedPlans: input.threadProposedPlans, + planId: input.planId, + ...(input.turnId ? { turnId: input.turnId } : {}), + planMarkdown, + createdAt: + bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 + ? bufferedPlan.createdAt + : input.updatedAt, + updatedAt: input.updatedAt, + }); + yield* clearBufferedProposedPlan(input.planId); }); - yield* clearBufferedProposedPlan(input.planId); - }); - - const clearTurnStateForSession = Effect.fn("clearTurnStateForSession")(function* ( - threadId: ThreadId, - ) { - const prefix = `${threadId}:`; - const proposedPlanPrefix = `plan:${threadId}:`; - const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); - const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); - yield* Effect.forEach( - turnKeys, - Effect.fn(function* (key) { - if (!key.startsWith(prefix)) { - return; - } - const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); - if (Option.isSome(messageIds)) { - yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { - concurrency: 1, - }).pipe(Effect.asVoid); - } + const clearTurnStateForSession = (threadId: ThreadId) => + Effect.gen(function* () { + const prefix = `${threadId}:`; + const proposedPlanPrefix = `plan:${threadId}:`; + const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); + const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); + const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + yield* Effect.forEach( + turnKeys, + (key) => + Effect.gen(function* () { + if (!key.startsWith(prefix)) { + return; + } - yield* Cache.invalidate(turnMessageIdsByTurnKey, key); - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* Effect.forEach( - proposedPlanKeys, - (key) => - key.startsWith(proposedPlanPrefix) - ? Cache.invalidate(bufferedProposedPlanById, key) - : Effect.void, - { concurrency: 1 }, - ).pipe(Effect.asVoid); - clearAssistantSegmentsForThread(threadId); - }); + const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); + if (Option.isSome(messageIds)) { + yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { + concurrency: 1, + }).pipe(Effect.asVoid); + } - // Accumulate token usage from thread.token-usage.updated events so - // providers like Copilot and Amp (which emit usage separately from - // turn.completed) still get turn-level usage in the completion summary. - const pendingTokenUsageByThread = new Map>(); + yield* Cache.invalidate(turnMessageIdsByTurnKey, key); + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* Effect.forEach( + assistantSegmentKeys, + (key) => + key.startsWith(prefix) + ? Cache.invalidate(assistantSegmentStateByTurnKey, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* Effect.forEach( + proposedPlanKeys, + (key) => + key.startsWith(proposedPlanPrefix) + ? Cache.invalidate(bufferedProposedPlanById, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); + }); const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( "getSourceProposedPlanReferenceForPendingTurnStart", @@ -1160,487 +1094,438 @@ const make = Effect.fn("make")(function* () { }, ); - const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( - event: ProviderRuntimeEvent, - ) { - // Accumulate token usage events per thread - if (event.type === "thread.token-usage.updated") { - const payload = runtimePayloadRecord(event); - const raw = payload?.usage; - if (raw && typeof raw === "object") { - const prev = pendingTokenUsageByThread.get(event.threadId) ?? {}; - const incoming = raw as Record; - // Merge by summing numeric fields - const merged: Record = { ...prev }; - for (const [k, v] of Object.entries(incoming)) { - if (typeof v === "number" && typeof (prev[k] ?? 0) === "number") { - merged[k] = ((prev[k] as number) ?? 0) + v; - } else { - merged[k] = v; - } - } - pendingTokenUsageByThread.set(event.threadId, merged); - } - } - - // Clear accumulated usage when a new turn starts - if (event.type === "turn.started") { - pendingTokenUsageByThread.delete(event.threadId); - } - - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); - if (!thread) return; - - const now = event.createdAt; - const eventTurnId = toTurnId(event.turnId); - const activeTurnId = thread.session?.activeTurnId ?? null; - - const existingAssistantMessageById = new Map( - thread.messages.map((message) => [message.id, message] as const), - ); + const processRuntimeEvent = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); + if (!thread) return; - const assistantBaseMessageId = - event.type === "content.delta" || - (event.type === "item.completed" && event.payload.itemType === "assistant_message") - ? MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`) - : undefined; + const now = event.createdAt; + const eventTurnId = toTurnId(event.turnId); + const activeTurnId = thread.session?.activeTurnId ?? null; - const conflictsWithActiveTurn = - activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); - const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; + const conflictsWithActiveTurn = + activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); + const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; - const shouldApplyThreadLifecycle = (() => { - if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { - return true; - } - switch (event.type) { - case "session.exited": + const shouldApplyThreadLifecycle = (() => { + if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { return true; - case "session.started": - case "thread.started": - return true; - case "turn.started": - return !conflictsWithActiveTurn; - case "turn.completed": - case "turn.aborted": - if (conflictsWithActiveTurn || missingTurnForActiveTurn) { - return false; - } - // Only the active turn may close the lifecycle state. - if (activeTurnId !== null && eventTurnId !== undefined) { - return sameId(activeTurnId, eventTurnId); - } - // If no active turn is tracked, accept completion scoped to this thread. - return true; - default: - return true; - } - })(); - const acceptedTurnStartedSourcePlan = - event.type === "turn.started" && shouldApplyThreadLifecycle - ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) - : null; - - if ( - event.type === "session.started" || - event.type === "session.state.changed" || - event.type === "session.exited" || - event.type === "thread.started" || - event.type === "turn.started" || - event.type === "turn.completed" || - event.type === "turn.aborted" - ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || - event.type === "turn.aborted" || - event.type === "session.exited" - ? null - : activeTurnId; - const status = (() => { + } switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); - case "turn.started": - return "running"; case "session.exited": - return "stopped"; - case "turn.completed": - return runtimeTurnState(event) === "failed" ? "error" : "ready"; - case "turn.aborted": - return "interrupted"; + return true; case "session.started": case "thread.started": - // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; + return true; + case "turn.started": + return !conflictsWithActiveTurn; + case "turn.completed": + if (conflictsWithActiveTurn || missingTurnForActiveTurn) { + return false; + } + // Only the active turn may close the lifecycle state. + if (activeTurnId !== null && eventTurnId !== undefined) { + return sameId(activeTurnId, eventTurnId); + } + // If no active turn is tracked, accept completion scoped to this thread. + return true; + default: + return true; } })(); - const lastError = - event.type === "session.state.changed" && event.payload.state === "error" - ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") - : event.type === "turn.completed" && runtimeTurnState(event) === "failed" - ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" || status === "interrupted" + const acceptedTurnStartedSourcePlan = + event.type === "turn.started" && shouldApplyThreadLifecycle + ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) + : null; + + if ( + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ) { + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" ? null - : (thread.session?.lastError ?? null); - - if (shouldApplyThreadLifecycle) { - const turnUsagePayload = - event.type === "turn.completed" ? runtimePayloadRecord(event) : undefined; - let turnUsage = - turnUsagePayload?.usage !== undefined && - turnUsagePayload.usage !== null && - typeof turnUsagePayload.usage === "object" - ? (turnUsagePayload.usage as Record) - : undefined; - - // Fall back to accumulated thread.token-usage.updated data - // for providers (Copilot, Amp) that emit usage separately. - if (!turnUsage && (event.type === "turn.completed" || event.type === "turn.aborted")) { - const pending = pendingTokenUsageByThread.get(event.threadId); - if (pending) { - turnUsage = pending; + : activeTurnId; + const status = (() => { + switch (event.type) { + case "session.state.changed": + return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "turn.started": + return "running"; + case "session.exited": + return "stopped"; + case "turn.completed": + return normalizeRuntimeTurnState(event.payload.state) === "failed" + ? "error" + : "ready"; + case "session.started": + case "thread.started": + // Provider thread/session start notifications can arrive during an + // active turn; preserve turn-running state in that case. + return activeTurnId !== null ? "running" : "ready"; + } + })(); + const lastError = + event.type === "session.state.changed" && event.payload.state === "error" + ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") + : event.type === "turn.completed" && + normalizeRuntimeTurnState(event.payload.state) === "failed" + ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") + : status === "ready" + ? null + : (thread.session?.lastError ?? null); + + if (shouldApplyThreadLifecycle) { + if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { + yield* markSourceProposedPlanImplemented( + acceptedTurnStartedSourcePlan.sourceThreadId, + acceptedTurnStartedSourcePlan.sourcePlanId, + thread.id, + now, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime ingestion failed to mark source proposed plan", + { + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }, + ), + ), + ); } - } - if (event.type === "turn.completed" || event.type === "turn.aborted") { - pendingTokenUsageByThread.delete(event.threadId); - } - - if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { - yield* markSourceProposedPlanImplemented( - acceptedTurnStartedSourcePlan.sourceThreadId, - acceptedTurnStartedSourcePlan.sourcePlanId, - thread.id, - now, - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider runtime ingestion failed to mark source proposed plan", { - eventId: event.eventId, - eventType: event.type, - cause: Cause.pretty(cause), - }), - ), - ); - } - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: providerCommandId(event, "thread-session-set"), - threadId: thread.id, - session: { + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: providerCommandId(event, "thread-session-set"), threadId: thread.id, - status, - providerName: event.provider, - runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: nextActiveTurnId, - lastError, - updatedAt: now, - }, - ...(turnUsage ? { turnUsage } : {}), - createdAt: now, - }); + session: { + threadId: thread.id, + status, + providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: nextActiveTurnId, + lastError, + updatedAt: now, + }, + createdAt: now, + }); + } } - } - const assistantDelta = - event.type === "content.delta" && event.payload.streamKind === "assistant_text" - ? event.payload.delta - : undefined; - const proposedPlanDelta = - event.type === "turn.proposed.delta" ? event.payload.delta : undefined; - - const isToolLifecycleEvent = - eventTurnId !== undefined && - ((event.type === "item.started" && isToolLifecycleItemType(event.payload.itemType)) || - (event.type === "item.updated" && isToolLifecycleItemType(event.payload.itemType)) || - (event.type === "item.completed" && isToolLifecycleItemType(event.payload.itemType))); - if (isToolLifecycleEvent) { - yield* closeOpenAssistantSegmentsForTurn({ - event, - threadId: thread.id, - turnId: eventTurnId, - createdAt: now, - existingAssistantMessageById, - }); - } + const assistantDelta = + event.type === "content.delta" && event.payload.streamKind === "assistant_text" + ? event.payload.delta + : undefined; + const proposedPlanDelta = + event.type === "turn.proposed.delta" ? event.payload.delta : undefined; - if (assistantDelta && assistantDelta.length > 0) { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); - const turnId = toTurnId(event.turnId); - if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); - } - yield* markAssistantMessageSawDelta(assistantMessageId); + if (assistantDelta && assistantDelta.length > 0) { + const turnId = toTurnId(event.turnId); + const assistantMessageId = yield* getOrCreateAssistantMessageId({ + threadId: thread.id, + event, + ...(turnId ? { turnId } : {}), + }); + if (turnId) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + } - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), - ); - if (assistantDeliveryMode === "buffered") { - const spillResult = yield* appendBufferedAssistantText( - assistantMessageId, - assistantDelta, - now, + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), ); - if (spillResult.spillChunk.length > 0) { + if (assistantDeliveryMode === "buffered") { + const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + if (spillChunk.length > 0) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + threadId: thread.id, + messageId: assistantMessageId, + delta: spillChunk, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + } + } else { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + commandId: providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, - delta: spillResult.spillChunk, + delta: assistantDelta, ...(turnId ? { turnId } : {}), - createdAt: spillResult.createdAt, + createdAt: now, }); } - } else { - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta"), + } + + const pauseForUserTurnId = + event.type === "request.opened" || event.type === "user-input.requested" + ? toTurnId(event.turnId) + : undefined; + if (pauseForUserTurnId) { + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); + const flushedMessageIds = + assistantDeliveryMode === "buffered" + ? yield* flushBufferedAssistantMessagesForTurn({ + event, + threadId: thread.id, + turnId: pauseForUserTurnId, + createdAt: now, + commandTag: + event.type === "request.opened" + ? "assistant-delta-flush-on-request-opened" + : "assistant-delta-flush-on-user-input-requested", + }) + : new Set(); + yield* finalizeActiveAssistantSegmentForTurn({ + event, threadId: thread.id, - messageId: assistantMessageId, - delta: assistantDelta, - ...(turnId ? { turnId } : {}), + turnId: pauseForUserTurnId, createdAt: now, + commandTag: + event.type === "request.opened" + ? "assistant-complete-on-request-opened" + : "assistant-complete-on-user-input-requested", + finalDeltaCommandTag: + event.type === "request.opened" + ? "assistant-delta-finalize-on-request-opened" + : "assistant-delta-finalize-on-user-input-requested", + hasProjectedMessage: thread.messages.some( + (entry) => + entry.role === "assistant" && entry.turnId === pauseForUserTurnId && entry.streaming, + ), + flushedMessageIds, }); } - } - if (proposedPlanDelta && proposedPlanDelta.length > 0) { - const planId = proposedPlanIdFromEvent(event, thread.id); - yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); - } + if (proposedPlanDelta && proposedPlanDelta.length > 0) { + const planId = proposedPlanIdFromEvent(event, thread.id); + yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); + } - const assistantCompletion = - event.type === "item.completed" && event.payload.itemType === "assistant_message" - ? { - messageId: MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), - fallbackText: event.payload.detail, - } - : undefined; - const proposedPlanCompletion = - event.type === "turn.proposed.completed" - ? { - planId: proposedPlanIdFromEvent(event, thread.id), - turnId: toTurnId(event.turnId), - planMarkdown: event.payload.planMarkdown, + const assistantCompletion = + event.type === "item.completed" && event.payload.itemType === "assistant_message" + ? { + messageId: MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ), + fallbackText: event.payload.detail, + } + : undefined; + const proposedPlanCompletion = + event.type === "turn.proposed.completed" + ? { + planId: proposedPlanIdFromEvent(event, thread.id), + turnId: toTurnId(event.turnId), + planMarkdown: event.payload.planMarkdown, + } + : undefined; + + if (assistantCompletion) { + const turnId = toTurnId(event.turnId); + const activeAssistantMessageId = turnId + ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) + : Option.none(); + const hasAssistantMessagesForTurn = + turnId !== undefined + ? thread.messages.some((entry) => entry.role === "assistant" && entry.turnId === turnId) + : false; + const assistantMessageId = Option.getOrElse( + activeAssistantMessageId, + () => assistantCompletion.messageId, + ); + const existingAssistantMessage = thread.messages.find( + (entry) => entry.id === assistantMessageId, + ); + const shouldApplyFallbackCompletionText = + !existingAssistantMessage || existingAssistantMessage.text.length === 0; + + const shouldSkipRedundantCompletion = + Option.isNone(activeAssistantMessageId) && + turnId !== undefined && + hasAssistantMessagesForTurn && + (assistantCompletion.fallbackText?.trim().length ?? 0) === 0; + + if (!shouldSkipRedundantCompletion) { + if (turnId && Option.isNone(activeAssistantMessageId)) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - : undefined; - if (assistantCompletion) { - const turnId = toTurnId(event.turnId); - const assistantMessageId = assistantBaseMessageId - ? takeOpenAssistantSegmentMessageId({ + yield* finalizeAssistantMessage({ + event, threadId: thread.id, - baseMessageId: assistantBaseMessageId, - })?.messageId - : undefined; - if (!assistantMessageId) { - if (assistantBaseMessageId) { - clearAssistantSegment({ - threadId: thread.id, - baseMessageId: assistantBaseMessageId, + messageId: assistantMessageId, ...(turnId ? { turnId } : {}), + createdAt: now, + commandTag: "assistant-complete", + finalDeltaCommandTag: "assistant-delta-finalize", + hasProjectedMessage: existingAssistantMessage !== undefined, + ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText + ? { fallbackText: assistantCompletion.fallbackText } + : {}), }); - } - } else if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); - } - if (assistantMessageId) { - // Avoid duplicating streamed text by checking the resolved - // segment message, not just the base message, for existing content. - const existingSegmentMessage = - existingAssistantMessageById.get(assistantMessageId) ?? - thread.messages.find((entry) => entry.id === assistantMessageId); - const shouldApplyFallbackCompletionText = - !existingSegmentMessage || existingSegmentMessage.text.length === 0; - - yield* finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - ...(turnId ? { turnId } : {}), - createdAt: now, - commandTag: "assistant-complete", - finalDeltaCommandTag: "assistant-delta-finalize", - ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText - ? { fallbackText: assistantCompletion.fallbackText } - : {}), - existingMessage: existingAssistantMessageById.get(assistantMessageId), - }); - } + if (turnId) { + yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + } + } - if (assistantBaseMessageId) { - clearAssistantSegment({ - threadId: thread.id, - baseMessageId: assistantBaseMessageId, - ...(turnId ? { turnId } : {}), - }); - } - if (turnId && assistantMessageId) { - yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + if (turnId) { + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); + } } - } - - if (proposedPlanCompletion) { - yield* finalizeBufferedProposedPlan({ - event, - threadId: thread.id, - threadProposedPlans: thread.proposedPlans, - planId: proposedPlanCompletion.planId, - ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), - fallbackMarkdown: proposedPlanCompletion.planMarkdown, - updatedAt: now, - }); - } - - if (event.type === "turn.completed") { - const turnId = toTurnId(event.turnId); - if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); - yield* Effect.forEach( - assistantMessageIds, - (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - existingMessage: existingAssistantMessageById.get(assistantMessageId), - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(thread.id, turnId); - clearAssistantSegmentsForTurn(thread.id, turnId); + if (proposedPlanCompletion) { yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, threadProposedPlans: thread.proposedPlans, - planId: proposedPlanIdForTurn(thread.id, turnId), - turnId, + planId: proposedPlanCompletion.planId, + ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), + fallbackMarkdown: proposedPlanCompletion.planMarkdown, updatedAt: now, }); } - } - if (event.type === "turn.aborted") { - const turnId = toTurnId(event.turnId); - if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); - yield* Effect.forEach( - assistantMessageIds, - (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - existingMessage: existingAssistantMessageById.get(assistantMessageId), - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(thread.id, turnId); - clearAssistantSegmentsForTurn(thread.id, turnId); - } - } - - if (event.type === "session.exited") { - yield* clearTurnStateForSession(thread.id); - } - - if (event.type === "runtime.error") { - const runtimeErrorMessage = event.payload.message; - - const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD - ? true - : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + if (event.type === "turn.completed") { + const turnId = toTurnId(event.turnId); + if (turnId) { + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + yield* Effect.forEach( + assistantMessageIds, + (assistantMessageId) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + commandTag: "assistant-complete-finalize", + finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: thread.messages.some( + (entry) => entry.id === assistantMessageId, + ), + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); - if (shouldApplyRuntimeError) { - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: providerCommandId(event, "runtime-error-session-set"), - threadId: thread.id, - session: { + yield* finalizeBufferedProposedPlan({ + event, threadId: thread.id, - status: "error", - providerName: event.provider, - runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: eventTurnId ?? null, - lastError: runtimeErrorMessage, + threadProposedPlans: thread.proposedPlans, + planId: proposedPlanIdForTurn(thread.id, turnId), + turnId, updatedAt: now, - }, - createdAt: now, - }); + }); + } } - } - if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); - } + if (event.type === "session.exited") { + yield* clearTurnStateForSession(thread.id); + } - if (event.type === "turn.diff.updated") { - const turnId = toTurnId(event.turnId); - if (turnId && (yield* isGitRepoForThread(thread.id))) { - // Skip if a checkpoint already exists for this turn. A real - // (non-placeholder) capture from CheckpointReactor should not - // be clobbered, and dispatching a duplicate placeholder for the - // same turnId would produce an unstable checkpointTurnCount. - if (thread.checkpoints.some((c) => c.turnId === turnId)) { - // Already tracked; no-op. - } else { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); - const maxTurnCount = thread.checkpoints.reduce( - (max, c) => Math.max(max, c.checkpointTurnCount), - 0, - ); + if (event.type === "runtime.error") { + const runtimeErrorMessage = event.payload.message; + + const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD + ? true + : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + + if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ - type: "thread.turn.diff.complete", - commandId: providerCommandId(event, "thread-turn-diff-complete"), + type: "thread.session.set", + commandId: providerCommandId(event, "runtime-error-session-set"), threadId: thread.id, - turnId, - completedAt: now, - checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), - status: "missing", - files: [], - assistantMessageId, - checkpointTurnCount: maxTurnCount + 1, + session: { + 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, + updatedAt: now, + }, createdAt: now, }); } } - } - const activities = runtimeEventToActivities(event); - yield* Effect.forEach(activities, (activity) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: providerCommandId(event, "thread-activity-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }), - ).pipe(Effect.asVoid); - }); + if (event.type === "thread.metadata.updated" && event.payload.name) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } + + if (event.type === "turn.diff.updated") { + const turnId = toTurnId(event.turnId); + if (turnId && (yield* isGitRepoForThread(thread.id))) { + // Skip if a checkpoint already exists for this turn. A real + // (non-placeholder) capture from CheckpointReactor should not + // be clobbered, and dispatching a duplicate placeholder for the + // same turnId would produce an unstable checkpointTurnCount. + if (thread.checkpoints.some((c) => c.turnId === turnId)) { + // Already tracked; no-op. + } else { + const assistantMessageId = MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ); + const maxTurnCount = thread.checkpoints.reduce( + (max, c) => Math.max(max, c.checkpointTurnCount), + 0, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.turn.diff.complete", + commandId: providerCommandId(event, "thread-turn-diff-complete"), + threadId: thread.id, + turnId, + completedAt: now, + checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), + status: "missing", + files: [], + assistantMessageId, + checkpointTurnCount: maxTurnCount + 1, + createdAt: now, + }); + } + } + } + + const activities = runtimeEventToActivities(event); + yield* Effect.forEach(activities, (activity) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: providerCommandId(event, "thread-activity-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }), + ).pipe(Effect.asVoid); + }); const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; @@ -1664,21 +1549,22 @@ const make = Effect.fn("make")(function* () { const worker = yield* makeDrainableWorker(processInputSafely); - const start: ProviderRuntimeIngestionShape["start"] = Effect.fn("start")(function* () { - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => - worker.enqueue({ source: "runtime", event }), - ), - ); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.turn-start-requested") { - return Effect.void; - } - return worker.enqueue({ source: "domain", event }); - }), - ); - }); + const start: ProviderRuntimeIngestionShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + Stream.runForEach(providerService.streamEvents, (event) => + worker.enqueue({ source: "runtime", event }), + ), + ); + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.void; + } + return worker.enqueue({ source: "domain", event }); + }), + ); + }); return { start, @@ -1688,5 +1574,5 @@ const make = Effect.fn("make")(function* () { export const ProviderRuntimeIngestionLive = Layer.effect( ProviderRuntimeIngestionService, - make(), + make, ).pipe(Layer.provide(ProjectionTurnRepositoryLive)); 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 a85e21c53f3..23566099196 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -5,7 +5,9 @@ import { MessageId, ProjectId, ThreadId, + ProviderInstanceId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { describe, expect, it } from "vitest"; import { Effect } from "effect"; @@ -137,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, @@ -162,14 +164,10 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -191,14 +189,10 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.make("thread-1"), messageId: asMessageId("message-user-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), runtimeMode: "approval-required", }); }); @@ -246,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, @@ -328,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/orchestration/redactEvent.ts b/apps/server/src/orchestration/redactEvent.ts index e68e4e06a18..10087eb77b7 100644 --- a/apps/server/src/orchestration/redactEvent.ts +++ b/apps/server/src/orchestration/redactEvent.ts @@ -1,48 +1,23 @@ -import type { - OrchestrationEvent, - ProviderStartOptions, - ProviderStartOptionsRedacted, -} from "@t3tools/contracts"; - -/** Strip sensitive fields (username, password) from provider start options. */ -export function redactProviderStartOptions( - opts: ProviderStartOptions, -): ProviderStartOptionsRedacted { - const redacted = { ...opts } as Record; - if (opts.opencode) { - const { username: _u, password: _p, ...rest } = opts.opencode; - redacted.opencode = rest; - } - if (opts.kilo) { - const { username: _u, password: _p, ...rest } = opts.kilo; - redacted.kilo = rest; - } - return redacted as ProviderStartOptionsRedacted; -} - /** - * Redact sensitive fields from an orchestration event payload. + * redactEvent — strip sensitive fields from orchestration events at the + * persistence/broadcast boundary. * - * Currently strips `username`/`password` from opencode and kilo provider - * options on `thread.turn-start-requested` events. Use this at persistence - * and client-broadcast boundaries so credentials never leave the server - * runtime. + * After upstream's PR #2277 the contract for provider start options + * collapsed: there is no longer a typed `ProviderStartOptions` envelope + * with per-driver subfields like `opencode.password`. The new + * `providerInstances` map carries credentials inside opaque `config` + * blobs that don't appear on event payloads. This module therefore is now + * a passthrough; callers may still wrap events through it so we have a + * single boundary point if/when sensitive fields reappear. + * + * TODO(sync): once the fork's adapters are reimplemented as drivers and + * we know which credentials (if any) bleed into thread events, restore + * the field-by-field redaction. */ +import type { OrchestrationEvent } from "@t3tools/contracts"; + export function redactEventForBoundary>( event: T, ): T { - if (event.type !== "thread.turn-start-requested") { - return event; - } - const payload = event.payload as Record; - if (!payload.providerOptions) { - return event; - } - return { - ...event, - payload: { - ...payload, - providerOptions: redactProviderStartOptions(payload.providerOptions as ProviderStartOptions), - }, - } as T; + return event; } diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 4255f770abe..80526986a70 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -116,78 +116,4 @@ layer("OrchestrationEventStore", (it) => { } }), ); - - it.effect("normalizes legacy provider names while replaying stored events", () => - Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const existingRows = yield* sql<{ readonly maxSequence: number }>` - SELECT COALESCE(MAX(sequence), 0) AS "maxSequence" - FROM orchestration_events - `; - const maxSequence = existingRows[0]?.maxSequence ?? 0; - - yield* sql` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json - ) - VALUES ( - ${EventId.make("evt-store-legacy-provider")}, - ${"thread"}, - ${"thread-legacy-provider"}, - ${0}, - ${"thread.created"}, - ${now}, - ${CommandId.make("cmd-store-legacy-provider")}, - ${null}, - ${null}, - ${"server"}, - ${JSON.stringify({ - threadId: "thread-legacy-provider", - projectId: "project-legacy-provider", - title: "Legacy Provider Thread", - modelSelection: { - provider: "gemini", - model: "gemini-2.5-pro", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - })}, - ${"{}"} - ) - `; - - const replayed = yield* Stream.runCollect(eventStore.readFromSequence(maxSequence, 10)).pipe( - Effect.map((chunk) => Array.from(chunk)), - ); - - assert.equal(replayed.length, 1); - const replayedEvent = replayed[0]; - assert.isDefined(replayedEvent); - assert.equal(replayedEvent?.type, "thread.created"); - if (!replayedEvent || replayedEvent.type !== "thread.created") { - return; - } - assert.deepStrictEqual(replayedEvent.payload.modelSelection, { - provider: "geminiCli", - model: "gemini-2.5-pro", - }); - }), - ); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index c7bf8838f81..4d81cf5e8d7 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -15,8 +15,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema, Stream } from "effect"; -import { redactEventForBoundary } from "../../orchestration/redactEvent.ts"; -import { normalizePersistedProviderKindName } from "../../provider/providerKind.ts"; import { toPersistenceDecodeError, toPersistenceSqlError, @@ -27,63 +25,7 @@ import { type OrchestrationEventStoreShape, } from "../Services/OrchestrationEventStore.ts"; -const decodeEventRaw = Schema.decodeUnknownEffect(OrchestrationEvent); - -/** - * Normalize legacy provider names in event payloads before Schema decoding. - * This handles data written before provider renames that migrations may not - * have caught. - */ -function normalizeLegacyProviderNames(row: unknown): unknown { - if (typeof row !== "object" || row === null) return row; - const obj = row as Record; - const payload = obj.payload; - if (typeof payload !== "object" || payload === null) return row; - const p = payload as Record; - let patched = false; - const patchStringProvider = (field: string) => { - const value = p[field]; - if (typeof value !== "string") { - return; - } - const normalizedProvider = normalizePersistedProviderKindName(value); - if (normalizedProvider === null || normalizedProvider === value) { - return; - } - p[field] = normalizedProvider; - patched = true; - }; - const patchProvider = (field: string) => { - const sel = p[field] as Record | undefined; - if (!sel || typeof sel !== "object" || typeof sel.provider !== "string") { - return; - } - const normalizedProvider = normalizePersistedProviderKindName(sel.provider); - if (normalizedProvider === null) { - return; - } - if (normalizedProvider !== sel.provider) { - p[field] = { ...sel, provider: normalizedProvider }; - patched = true; - } - }; - patchProvider("modelSelection"); - patchProvider("defaultModelSelection"); - patchStringProvider("provider"); - patchStringProvider("defaultProvider"); - patchStringProvider("providerName"); - patchStringProvider("defaultProviderName"); - patchStringProvider("providerKind"); - patchStringProvider("defaultProviderKind"); - patchStringProvider("sessionProvider"); - patchStringProvider("selectedProvider"); - patchStringProvider("activeProvider"); - patchStringProvider("stickyProvider"); - patchStringProvider("stickyActiveProvider"); - return patched ? { ...obj, payload: { ...p } } : row; -} - -const decodeEvent = (row: unknown) => decodeEventRaw(normalizeLegacyProviderNames(row)); +const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); @@ -236,20 +178,19 @@ const makeEventStore = Effect.gen(function* () { `, }); - const append: OrchestrationEventStoreShape["append"] = (event) => { - const safeEvent = redactEventForBoundary(event); - return appendEventRow({ - eventId: safeEvent.eventId, - aggregateKind: safeEvent.aggregateKind, - streamId: safeEvent.aggregateId, - type: safeEvent.type, - causationEventId: safeEvent.causationEventId, - correlationId: safeEvent.correlationId, - actorKind: inferActorKind(safeEvent), - occurredAt: safeEvent.occurredAt, - commandId: safeEvent.commandId, - payloadJson: safeEvent.payload, - metadataJson: safeEvent.metadata, + const append: OrchestrationEventStoreShape["append"] = (event) => + appendEventRow({ + eventId: event.eventId, + aggregateKind: event.aggregateKind, + streamId: event.aggregateId, + type: event.type, + causationEventId: event.causationEventId, + correlationId: event.correlationId, + actorKind: inferActorKind(event), + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: event.payload, + metadataJson: event.metadata, }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -263,7 +204,6 @@ const makeEventStore = Effect.gen(function* () { ), ), ); - }; const readFromSequence: OrchestrationEventStoreShape["readFromSequence"] = ( sequenceExclusive, 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 d6158dd60d3..a3ec0afcb39 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,11 +35,20 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; +// Fork-only migrations (filenames re-use 020/021 prefixes; IDs 23/24 keep them +// installed after the fork's first deployment). import Migration0023 from "./Migrations/020_NormalizeLegacyProviderKinds.ts"; import Migration0024 from "./Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.ts"; +// Upstream migrations renumbered to sit after the fork's 23/24. import Migration0025 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0026 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; import Migration0027 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; +import Migration0028 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; +import Migration0029 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; +import Migration0030 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; +// Fork-side backfill: ensure existing fork providers (amp/copilot/geminiCli/kilo) +// gain the new providerInstanceId columns introduced upstream. +import Migration0031 from "./Migrations/029_BackfillForkProviderInstanceIds.ts"; /** * Migration loader with all migrations defined inline. @@ -79,6 +88,10 @@ export const migrationEntries = [ [25, "ProjectionThreadShellSummary", Migration0025], [26, "BackfillProjectionThreadShellSummary", Migration0026], [27, "CleanupInvalidProjectionPendingApprovals", Migration0027], + [28, "CanonicalizeModelSelectionOptions", Migration0028], + [29, "ProviderSessionRuntimeInstanceId", Migration0029], + [30, "ProjectionThreadSessionInstanceId", Migration0030], + [31, "BackfillForkProviderInstanceIds", Migration0031], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts b/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts index e14344bc485..287f0e25c85 100644 --- a/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts +++ b/apps/server/src/persistence/Migrations/021_RepairProjectionThreadProposedPlanImplementationColumns.test.ts @@ -39,7 +39,11 @@ layer("021_RepairProjectionThreadProposedPlanImplementationColumns", (it) => { !columnsBeforeRepair.some((column) => column.name === "implementation_thread_id"), ); - yield* runMigrations(); + // Only run through migration 24 (the repair migration). Later + // migrations (e.g. 28_CanonicalizeModelSelectionOptions) depend on + // columns added by migrations 14-23 which were marked as completed + // above without actually being executed. + yield* runMigrations({ toMigrationInclusive: 24 }); const columnsAfterRepair = yield* sql<{ readonly name: string }>` PRAGMA table_info(projection_thread_proposed_plans) diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts new file mode 100644 index 00000000000..43bb1a2a651 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -0,0 +1,453 @@ +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("026_CanonicalizeModelSelectionOptions", (it) => { + it.effect("converts legacy object-shape options into array-shape on projections and events", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Note: After fork renumbering, CanonicalizeModelSelectionOptions runs as + // migration 28 (the file is still named 026_*). Set up everything up to 27 + // so that the next runMigrations call only executes migration 28. + yield* runMigrations({ toMigrationInclusive: 27 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ( + 'project-legacy', + 'Legacy options project', + '/tmp/legacy', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-no-options', + 'No options project', + '/tmp/no-options', + '{"provider":"codex","model":"gpt-5.4"}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-null-selection', + 'Null model selection project', + '/tmp/null-selection', + NULL, + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-already-array', + 'Already-canonical options project', + '/tmp/already-array', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"high"}]}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at, + runtime_mode, + interaction_mode + ) + VALUES + ( + 'thread-legacy', + 'project-legacy', + 'Legacy thread', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false,"contextWindow":"1m"}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-empty-options', + 'project-legacy', + 'Empty options thread', + '{"provider":"codex","model":"gpt-5.4","options":{}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-drop-garbage', + 'project-legacy', + 'Thread with non-scalar entries', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","thinking":{"enabled":true,"budgetTokens":2000},"emptyStr":" ","nullish":null}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-no-options', + 'project-legacy', + 'No options thread', + '{"provider":"codex","model":"gpt-5.4"}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-already-array', + 'project-legacy', + 'Already array thread', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"fastMode","value":true}]}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ( + 'event-project-created', + 'project', + 'project-legacy', + 1, + 'project.created', + '2026-01-01T00:00:00.000Z', + 'cmd-pc', + NULL, + 'corr-pc', + 'user', + '{"projectId":"project-legacy","title":"Project","workspaceRoot":"/tmp/legacy","defaultModelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-meta-updated', + 'project', + 'project-legacy', + 2, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-pmu', + NULL, + 'corr-pmu', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":{"provider":"codex","model":"gpt-5.4","options":{"reasoningEffort":"low"}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-null-selection', + 'project', + 'project-legacy', + 3, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-null', + NULL, + 'corr-null', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":null,"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-created', + 'thread', + 'thread-legacy', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-tc', + NULL, + 'corr-tc', + 'user', + '{"threadId":"thread-legacy","projectId":"project-legacy","title":"Thread","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-meta-updated', + 'thread', + 'thread-legacy', + 2, + 'thread.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-tmu', + NULL, + 'corr-tmu', + 'user', + '{"threadId":"thread-legacy","modelSelection":{"provider":"codex","model":"gpt-5.4","options":{"fastMode":true}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-turn-start', + 'thread', + 'thread-legacy', + 3, + 'thread.turn-start-requested', + '2026-01-01T00:00:00.000Z', + 'cmd-tts', + NULL, + 'corr-tts', + 'user', + '{"threadId":"thread-legacy","messageId":"msg-1","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","contextWindow":"1m"}},"runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-already-array', + 'thread', + 'thread-legacy', + 4, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-taa', + NULL, + 'corr-taa', + 'user', + '{"threadId":"thread-already-array","projectId":"project-legacy","title":"Already Array","modelSelection":{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"medium"}]},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-activity-append', + 'thread', + 'thread-legacy', + 5, + 'thread.activity-appended', + '2026-01-01T00:00:00.000Z', + 'cmd-aa', + NULL, + 'corr-aa', + 'user', + '{"threadId":"thread-legacy","activity":{"id":"a","tone":"info","kind":"k","summary":"s","payload":null,"turnId":null,"createdAt":"2026-01-01T00:00:00.000Z"}}', + '{}' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + // Projection projects + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultModelSelection: string | null; + }>` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual( + projectRows.map((row) => ({ + projectId: row.projectId, + selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, + })), + [ + { + projectId: "project-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }, + { + projectId: "project-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }, + }, + { + projectId: "project-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + { projectId: "project-null-selection", selection: null }, + ], + ); + + // Projection threads + const threadRows = yield* sql<{ + readonly threadId: string; + readonly modelSelection: string | null; + }>` + SELECT + thread_id AS "threadId", + model_selection_json AS "modelSelection" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual( + threadRows.map((row) => ({ + threadId: row.threadId, + selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, + })), + [ + { + threadId: "thread-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }, + }, + { + threadId: "thread-drop-garbage", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + // Only the scalar string survives; nested object, whitespace + // string, and null are dropped. + options: [{ id: "effort", value: "high" }], + }, + }, + { + threadId: "thread-empty-options", + selection: { provider: "codex", model: "gpt-5.4", options: [] }, + }, + { + threadId: "thread-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + { id: "contextWindow", value: "1m" }, + ], + }, + }, + { + threadId: "thread-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + ], + ); + + // Orchestration events + const eventRows = yield* sql<{ + readonly eventId: string; + readonly payloadJson: string; + }>` + SELECT event_id AS "eventId", payload_json AS "payloadJson" + FROM orchestration_events + ORDER BY event_id + `; + + const payloads = Object.fromEntries( + eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), + ); + + assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }); + + assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "low" }], + }); + + assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); + + assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + ], + }); + + assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }); + + assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + }); + + // Already-array records are left untouched. + assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + }); + + // Events with no modelSelection at all are untouched. + assert.isUndefined(payloads["event-activity-append"].modelSelection); + assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts new file mode 100644 index 00000000000..15c08debf64 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts @@ -0,0 +1,138 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Canonicalize `modelSelection.options` / `defaultModelSelection.options` from + * the legacy object shape (`{ effort: "max", fastMode: true, ... }`) to the + * current array-of-selections shape (`[{ id: "effort", value: "max" }, ...]`). + * + * Migration 016 introduced `modelSelection` with `options` stored as a + * per-provider object. Later the schema was reshaped so that options are a + * generic `Array<{ id, value }>` of user-selected option entries. Stored rows + * from before the reshape still have the object shape and fail to decode. + * + * For each value in the legacy object: + * - string values are kept if non-empty after trim + * - boolean values are always kept (true | false) + * - any other value type (number, null, nested object/array) is dropped, + * matching the permissive client-side normalizer in composerDraftStore. + * + * Touched storage: + * - `projection_threads.model_selection_json.options` + * - `projection_projects.default_model_selection_json.options` + * - `orchestration_events.payload_json.$.modelSelection.options` + * (thread.created | thread.meta-updated | thread.turn-start-requested) + * - `orchestration_events.payload_json.$.defaultModelSelection.options` + * (project.created | project.meta-updated) + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET model_selection_json = json_set( + model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE model_selection_json IS NOT NULL + AND json_type(model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE projection_projects + SET default_model_selection_json = json_set( + default_model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(default_model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE default_model_selection_json IS NOT NULL + AND json_type(default_model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.modelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.modelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ( + 'thread.created', + 'thread.meta-updated', + 'thread.turn-start-requested' + ) + AND json_type(payload_json, '$.modelSelection.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.defaultModelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.defaultModelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection.options') = 'object' + `; +}); 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..6ee8cfc02e1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -0,0 +1,78 @@ +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())); + +// Fork renumber: upstream's 027/028 land at IDs 29/30 in this fork because +// the fork's 23 (NormalizeLegacyProviderKinds) + 24 (Repair...) sit in the +// 23/24 slots. Filenames keep the upstream "027_"/"028_" prefixes for +// review legibility but the registered ids and ordering have shifted. +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: 28 }); + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + + yield* runMigrations({ toMigrationInclusive: 30 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id IN (29, 30) + ORDER BY migration_id + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 29, + name: "ProviderSessionRuntimeInstanceId", + }, + { + migration_id: 30, + 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/Migrations/029_BackfillForkProviderInstanceIds.ts b/apps/server/src/persistence/Migrations/029_BackfillForkProviderInstanceIds.ts new file mode 100644 index 00000000000..b18467456db --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_BackfillForkProviderInstanceIds.ts @@ -0,0 +1,53 @@ +/** + * Backfill provider_instance_id for fork-only driver kinds. + * + * Upstream's multi-provider refactor (PR #2277) introduced + * `provider_instance_id` columns on `provider_session_runtime` and + * `projection_thread_sessions`. The upstream migration intentionally leaves + * those columns NULL because for built-in providers (codex/claudeAgent/ + * cursor/opencode) the persistence boundary handles the fallback at read + * time. + * + * The fork ships four extra drivers — amp, copilot, geminiCli, kilo — and + * seeds a default instance id for each (e.g. `amp_default`) in the + * `BUILT_IN_DRIVERS` catalog. To keep existing fork users' sessions + * routable after the upgrade, this migration assigns those default instance + * ids to any pre-existing rows that name one of the fork drivers but lack a + * provider_instance_id. + * + * Built-in upstream driver rows are intentionally left NULL so they keep + * matching the read-time fallback. + */ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +// Default instance ids for the fork drivers. These match +// `defaultInstanceIdForDriver(driverKind)` from +// packages/contracts/src/providerInstance.ts which uses the bare driver +// kind slug as the back-compat default instance id (so existing threads, +// bindings, and cache files stay routable across the migration). +const FORK_DRIVERS_AND_DEFAULT_INSTANCE_IDS = [ + ["amp", "amp"], + ["copilot", "copilot"], + ["geminiCli", "geminiCli"], + ["kilo", "kilo"], +] as const; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + for (const [driverKind, instanceId] of FORK_DRIVERS_AND_DEFAULT_INSTANCE_IDS) { + yield* sql` + UPDATE provider_session_runtime + SET provider_instance_id = ${instanceId} + WHERE provider_instance_id IS NULL + AND provider_name = ${driverKind} + `; + yield* sql` + UPDATE projection_thread_sessions + SET provider_instance_id = ${instanceId} + WHERE provider_instance_id IS NULL + AND provider_name = ${driverKind} + `; + } +}); 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/AmpDriver.ts b/apps/server/src/provider/Drivers/AmpDriver.ts new file mode 100644 index 00000000000..700203c6dc5 --- /dev/null +++ b/apps/server/src/provider/Drivers/AmpDriver.ts @@ -0,0 +1,179 @@ +/** + * AmpDriver — `ProviderDriver` for the fork's Amp provider. + * + * Bundles per-instance `snapshot` / `adapter` / `textGeneration` closures + * over `GenericProviderSettings`. Multi-instance safe: two configurations + * yield two independent `AmpServerManager` processes with their own + * binary paths and runtime event queues. + * + * Text generation is not supported by the Amp CLI today — the + * `textGeneration` shape returns a `TextGenerationError` for every call so + * routing can degrade gracefully instead of crashing. + * + * @module provider/Drivers/AmpDriver + */ +import { + GenericProviderSettings, + ProviderDriverKind, + type ServerProvider, + TextGenerationError, +} from "@t3tools/contracts"; +import { Duration, Effect, Schema, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAmpAdapter } from "../Layers/AmpAdapter.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("amp"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type AmpDriverEnv = 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 }, + }); + +function makePendingSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Amp" }, + enabled: config.enabled, + checkedAt: new Date(0).toISOString(), + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Probing Amp installation…", + }, + }); +} + +function buildSnapshot(config: GenericProviderSettings): ServerProviderDraft { + // The fork historically did a runtime probe via `amp --version`. Until that + // probe is ported, render an "available when enabled" snapshot — matches + // the pre-sync UI behavior of "rely on the binary at session start" and + // keeps the row visible. + return buildServerProvider({ + presentation: { displayName: "Amp" }, + enabled: config.enabled, + checkedAt: new Date().toISOString(), + models: [], + skills: [], + probe: { + installed: true, + version: null, + status: config.enabled ? "ready" : "error", + auth: { status: "unknown" }, + // TODO(sync): port the real `amp --version` probe so installed/version + // reflect the actual binary state instead of being optimistic. + }, + }); +} + +const unsupportedTextGen = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Amp does not support text generation.", + }), + ); + +const ampTextGeneration: TextGenerationShape = { + generateCommitMessage: () => unsupportedTextGen("generateCommitMessage"), + generatePrContent: () => unsupportedTextGen("generatePrContent"), + generateBranchName: () => unsupportedTextGen("generateBranchName"), + generateThreadTitle: () => unsupportedTextGen("generateThreadTitle"), +}; + +export const AmpDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Amp", + supportsMultipleInstances: true, + }, + configSchema: GenericProviderSettings, + defaultConfig: (): GenericProviderSettings => Schema.decodeSync(GenericProviderSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const processEnv = mergeProviderInstanceEnvironment(environment); + void processEnv; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GenericProviderSettings; + + const adapter = yield* makeAmpAdapter(effectiveConfig, { instanceId }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to construct Amp adapter: ${String((cause as Error)?.message ?? cause)}`, + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingSnapshot(settings)), + checkProvider: Effect.succeed(stampIdentity(buildSnapshot(effectiveConfig))), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Amp snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: ampTextGeneration, + } satisfies ProviderInstance; + }), +}; 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/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts new file mode 100644 index 00000000000..5704e33f1e4 --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -0,0 +1,182 @@ +/** + * CopilotDriver — `ProviderDriver` for the fork's GitHub Copilot CLI. + * + * Per-instance closures over `GenericProviderSettings`. Delegates the heavy + * 1.7k-line adapter body (in `Layers/CopilotAdapter.ts`) to + * `makeCopilotAdapterImpl(config, options)` so each driver instance owns + * its own `CopilotClient`, session map, and runtime-event queue. + * + * The adapter still lives behind a transitional shim — it was a singleton + * `Context.Service` factory pre-sync, and the registry now hands it back + * directly to the registry as a `ProviderInstance`. See the TODO(sync) + * comment in `Layers/CopilotAdapter.ts` for the cleanup plan. + * + * Text generation is not supported — Copilot CLI does not expose a + * suitable surface — so the four `TextGenerationShape` methods fail fast. + * + * @module provider/Drivers/CopilotDriver + */ +import { + GenericProviderSettings, + ProviderDriverKind, + type ServerProvider, + TextGenerationError, +} from "@t3tools/contracts"; +import { Duration, Effect, Schema, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCopilotAdapterImpl } from "../Layers/CopilotAdapter.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("copilot"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type CopilotDriverEnv = 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 }, + }); + +function makePendingSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Copilot" }, + enabled: config.enabled, + checkedAt: new Date(0).toISOString(), + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Probing GitHub Copilot installation…", + }, + }); +} + +function buildSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Copilot" }, + enabled: config.enabled, + checkedAt: new Date().toISOString(), + models: [], + skills: [], + probe: { + installed: true, + version: null, + status: config.enabled ? "ready" : "error", + auth: { status: "unknown" }, + // TODO(sync): port the real `copilot --version` probe (the bundled + // CLI path resolution lives in `copilotCliPath.ts`). + }, + }); +} + +const unsupportedTextGen = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Copilot does not support text generation.", + }), + ); + +const copilotTextGeneration: TextGenerationShape = { + generateCommitMessage: () => unsupportedTextGen("generateCommitMessage"), + generatePrContent: () => unsupportedTextGen("generatePrContent"), + generateBranchName: () => unsupportedTextGen("generateBranchName"), + generateThreadTitle: () => unsupportedTextGen("generateThreadTitle"), +}; + +export const CopilotDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Copilot", + supportsMultipleInstances: true, + }, + configSchema: GenericProviderSettings, + defaultConfig: (): GenericProviderSettings => Schema.decodeSync(GenericProviderSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + void processEnv; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GenericProviderSettings; + + const adapter = yield* makeCopilotAdapterImpl(effectiveConfig, { + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to construct Copilot adapter: ${String((cause as Error)?.message ?? cause)}`, + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingSnapshot(settings)), + checkProvider: Effect.succeed(stampIdentity(buildSnapshot(effectiveConfig))), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Copilot snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: copilotTextGeneration, + } satisfies ProviderInstance; + }), +}; 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/GeminiCliDriver.ts b/apps/server/src/provider/Drivers/GeminiCliDriver.ts new file mode 100644 index 00000000000..3cb5442ff53 --- /dev/null +++ b/apps/server/src/provider/Drivers/GeminiCliDriver.ts @@ -0,0 +1,171 @@ +/** + * GeminiCliDriver — `ProviderDriver` for the fork's Gemini CLI provider. + * + * Mirrors `AmpDriver`: per-instance closures over `GenericProviderSettings`, + * one `GeminiCliServerManager` per instance, no shared state. + * + * Text generation is not supported — the underlying CLI doesn't expose a + * suitable surface — so all four `TextGenerationShape` methods fail fast. + * + * @module provider/Drivers/GeminiCliDriver + */ +import { + GenericProviderSettings, + ProviderDriverKind, + type ServerProvider, + TextGenerationError, +} from "@t3tools/contracts"; +import { Duration, Effect, Schema, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeGeminiCliAdapter } from "../Layers/GeminiCliAdapter.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("geminiCli"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type GeminiCliDriverEnv = 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 }, + }); + +function makePendingSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Gemini CLI" }, + enabled: config.enabled, + checkedAt: new Date(0).toISOString(), + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Probing Gemini CLI installation…", + }, + }); +} + +function buildSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Gemini CLI" }, + enabled: config.enabled, + checkedAt: new Date().toISOString(), + models: [], + skills: [], + probe: { + installed: true, + version: null, + status: config.enabled ? "ready" : "error", + auth: { status: "unknown" }, + // TODO(sync): port the real `gemini --version` probe. + }, + }); +} + +const unsupportedTextGen = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Gemini CLI does not support text generation.", + }), + ); + +const geminiCliTextGeneration: TextGenerationShape = { + generateCommitMessage: () => unsupportedTextGen("generateCommitMessage"), + generatePrContent: () => unsupportedTextGen("generatePrContent"), + generateBranchName: () => unsupportedTextGen("generateBranchName"), + generateThreadTitle: () => unsupportedTextGen("generateThreadTitle"), +}; + +export const GeminiCliDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Gemini CLI", + supportsMultipleInstances: true, + }, + configSchema: GenericProviderSettings, + defaultConfig: (): GenericProviderSettings => Schema.decodeSync(GenericProviderSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const processEnv = mergeProviderInstanceEnvironment(environment); + void processEnv; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GenericProviderSettings; + + const adapter = yield* makeGeminiCliAdapter(effectiveConfig, { instanceId }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to construct Gemini CLI adapter: ${String((cause as Error)?.message ?? cause)}`, + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingSnapshot(settings)), + checkProvider: Effect.succeed(stampIdentity(buildSnapshot(effectiveConfig))), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Gemini CLI snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: geminiCliTextGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/KiloDriver.ts b/apps/server/src/provider/Drivers/KiloDriver.ts new file mode 100644 index 00000000000..68ea0c08fb3 --- /dev/null +++ b/apps/server/src/provider/Drivers/KiloDriver.ts @@ -0,0 +1,168 @@ +/** + * KiloDriver — `ProviderDriver` for the fork's Kilo provider. + * + * Mirrors `AmpDriver` / `GeminiCliDriver`: per-instance closures over + * `GenericProviderSettings`, one `KiloServerManager` per instance. + * + * @module provider/Drivers/KiloDriver + */ +import { + GenericProviderSettings, + ProviderDriverKind, + type ServerProvider, + TextGenerationError, +} from "@t3tools/contracts"; +import { Duration, Effect, Schema, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeKiloAdapter } from "../Layers/KiloAdapter.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("kilo"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type KiloDriverEnv = 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 }, + }); + +function makePendingSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Kilo" }, + enabled: config.enabled, + checkedAt: new Date(0).toISOString(), + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Probing Kilo installation…", + }, + }); +} + +function buildSnapshot(config: GenericProviderSettings): ServerProviderDraft { + return buildServerProvider({ + presentation: { displayName: "Kilo" }, + enabled: config.enabled, + checkedAt: new Date().toISOString(), + models: [], + skills: [], + probe: { + installed: true, + version: null, + status: config.enabled ? "ready" : "error", + auth: { status: "unknown" }, + // TODO(sync): port the real `kilo --version` probe. + }, + }); +} + +const unsupportedTextGen = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kilo does not support text generation.", + }), + ); + +const kiloTextGeneration: TextGenerationShape = { + generateCommitMessage: () => unsupportedTextGen("generateCommitMessage"), + generatePrContent: () => unsupportedTextGen("generatePrContent"), + generateBranchName: () => unsupportedTextGen("generateBranchName"), + generateThreadTitle: () => unsupportedTextGen("generateThreadTitle"), +}; + +export const KiloDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Kilo", + supportsMultipleInstances: true, + }, + configSchema: GenericProviderSettings, + defaultConfig: (): GenericProviderSettings => Schema.decodeSync(GenericProviderSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const processEnv = mergeProviderInstanceEnvironment(environment); + void processEnv; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GenericProviderSettings; + + const adapter = yield* makeKiloAdapter(effectiveConfig, { instanceId }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to construct Kilo adapter: ${String((cause as Error)?.message ?? cause)}`, + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingSnapshot(settings)), + checkProvider: Effect.succeed(stampIdentity(buildSnapshot(effectiveConfig))), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Kilo snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: kiloTextGeneration, + } 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/AmpAdapter.test.ts b/apps/server/src/provider/Layers/AmpAdapter.test.ts index 447b04a449d..bc358bfbbfa 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.test.ts @@ -1,24 +1,20 @@ import assert from "node:assert/strict"; import { - ApprovalRequestId, EventId, + GenericProviderSettings, RuntimeItemId, ThreadId, TurnId, - type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, type ProviderTurnStartResult, - type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Layer, Stream } from "effect"; +import { Effect, Schema, Stream } from "effect"; import { AmpServerManager } from "../../ampServerManager.ts"; -import { AmpAdapter } from "../Services/AmpAdapter.ts"; -import { makeAmpAdapterLive } from "./AmpAdapter.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAmpAdapter } from "./AmpAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -47,13 +43,6 @@ class FakeAmpManager extends AmpServerManager { }), ); - public interruptTurnImpl = vi.fn(async (): Promise => undefined); - public respondToRequestImpl = vi.fn(async (): Promise => undefined); - public respondToUserInputImpl = vi.fn(async (): Promise => undefined); - public readThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); - public rollbackThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); - public stopAllImpl = vi.fn(() => undefined); - override startSession(input: { threadId: ThreadId }): Promise { return this.startSessionImpl(input.threadId); } @@ -62,73 +51,38 @@ class FakeAmpManager extends AmpServerManager { return this.sendTurnImpl(input.threadId); } - override interruptTurn(_threadId: ThreadId): Promise { - return this.interruptTurnImpl(); - } - - override respondToRequest( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _decision: ProviderApprovalDecision, - ): Promise { - return this.respondToRequestImpl(); - } - - override respondToUserInput( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _answers: ProviderUserInputAnswers, - ): Promise { - return this.respondToUserInputImpl(); - } - - override readThread(threadId: ThreadId) { - return this.readThreadImpl(threadId); - } - - override rollbackThread(threadId: ThreadId) { - return this.rollbackThreadImpl(threadId); - } - override stopSession(_threadId: ThreadId): void {} - override listSessions(): ProviderSession[] { return []; } - override hasSession(_threadId: ThreadId): boolean { return false; } - - override stopAll(): void { - this.stopAllImpl(); - } + override stopAll(): void {} } -const manager = new FakeAmpManager(); -const layer = it.layer( - makeAmpAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), -); +const enabledConfig = Schema.decodeSync(GenericProviderSettings)({}); -layer("AmpAdapterLive", (it) => { - it.effect("delegates session startup to the manager", () => +it.effect("AmpAdapter delegates startSession to its AmpServerManager", () => + Effect.scoped( Effect.gen(function* () { - manager.startSessionImpl.mockClear(); - const adapter = yield* AmpAdapter; - + const manager = new FakeAmpManager(); + const adapter = yield* makeAmpAdapter(enabledConfig, { manager }); const session = yield* adapter.startSession({ threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); - assert.equal(session.provider, "amp"); assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }), - ); + ), +); - it.effect("rejects attachments until AMP attachment wiring exists", () => +it.effect("AmpAdapter rejects attachments until wiring exists", () => + Effect.scoped( Effect.gen(function* () { - const adapter = yield* AmpAdapter; + const manager = new FakeAmpManager(); + const adapter = yield* makeAmpAdapter(enabledConfig, { manager }); const result = yield* adapter .sendTurn({ threadId: asThreadId("thread-attachments"), @@ -136,19 +90,18 @@ layer("AmpAdapterLive", (it) => { attachments: [{ id: "attachment-1" }] as never, }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } + if (result._tag !== "Failure") return; assert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), - ); + ), +); - it.effect("forwards manager runtime events through the adapter stream", () => +it.effect("AmpAdapter forwards manager runtime events through the stream", () => + Effect.scoped( Effect.gen(function* () { - const adapter = yield* AmpAdapter; - + const manager = new FakeAmpManager(); + const adapter = yield* makeAmpAdapter(enabledConfig, { manager }); const event = { type: "content.delta", eventId: asEventId("evt-amp-delta"), @@ -162,24 +115,30 @@ layer("AmpAdapterLive", (it) => { delta: "hello", }, } as unknown as ProviderRuntimeEvent; - - // Emit first — the event is buffered in the unbounded queue via the - // listener that was registered during layer construction. manager.emit("event", event); - - // Now consume the head. Since the queue already has an item, this - // resolves immediately without a race condition. const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); - if (received._tag !== "Some") { - return; - } + if (received._tag !== "Some") return; assert.equal(received.value.type, "content.delta"); - if (received.value.type !== "content.delta") { - return; - } - assert.equal(received.value.payload.delta, "hello"); }), - ); -}); + ), +); + +it.effect("AmpAdapter refuses startSession when the config is disabled", () => + Effect.scoped( + Effect.gen(function* () { + const manager = new FakeAmpManager(); + const disabled = { ...enabledConfig, enabled: false }; + const adapter = yield* makeAmpAdapter(disabled, { manager }); + const result = yield* adapter + .startSession({ + threadId: asThreadId("thread-disabled"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") return; + assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + }), + ), +); diff --git a/apps/server/src/provider/Layers/AmpAdapter.ts b/apps/server/src/provider/Layers/AmpAdapter.ts index 5a703ccb8c5..22abf379353 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.ts @@ -1,143 +1,159 @@ -import { type ProviderRuntimeEvent } from "@t3tools/contracts"; -import { Effect, Layer, Queue, Stream } from "effect"; +/** + * AmpAdapter — per-instance adapter factory for the fork's Amp provider. + * + * Wraps the existing Node-EventEmitter `AmpServerManager` (which still owns + * all the JSONL protocol parsing and child-process management) into a value + * matching `ProviderAdapterShape`. One adapter, one manager, one + * unbounded runtime-event queue — all captured in the closures returned + * here. + * + * The pre-sync code held a singleton `AmpAdapter` Service tag that fanned + * config in via `ServerSettingsService`. Per-instance drivers can't share a + * singleton, so this module resolves config from the typed + * `GenericProviderSettings` argument instead. Two driver instances therefore + * mean two manager processes with independent `binaryPath` and `configDir`. + * + * @module provider/Layers/AmpAdapter + */ +import { + type GenericProviderSettings, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { Effect, Queue, Stream } from "effect"; import { AmpServerManager } from "../../ampServerManager.ts"; -import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; -import { AmpAdapter, type AmpAdapterShape } from "../Services/AmpAdapter.ts"; +import { + type ProviderAdapterError, + ProviderAdapterValidationError, +} from "../Errors.ts"; import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { type ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -const PROVIDER = "amp" as const; +const PROVIDER = ProviderDriverKind.make("amp"); const { toRequestError } = makeErrorHelpers(PROVIDER); -export interface AmpAdapterLiveOptions { +export interface AmpAdapterOptions { + readonly instanceId?: ProviderInstanceId; readonly manager?: AmpServerManager; readonly makeManager?: () => AmpServerManager; } -export function makeAmpAdapterLive(options: AmpAdapterLiveOptions = {}) { - return Layer.effect( - AmpAdapter, - Effect.gen(function* () { - const manager = options.manager ?? options.makeManager?.() ?? new AmpServerManager(); - const runtimeEventQueue = yield* Queue.unbounded(); - const serverSettingsService = yield* ServerSettingsService; +/** + * Construct an Amp adapter bound to a single configuration. Returns an + * Effect that runs in a `Scope`; closing the scope detaches the manager + * listener and stops every session it owns. + */ +export const makeAmpAdapter = Effect.fn("makeAmpAdapter")(function* ( + config: GenericProviderSettings, + options: AmpAdapterOptions = {}, +) { + const manager = options.manager ?? options.makeManager?.() ?? new AmpServerManager(); + // Apply per-instance binary path. The manager re-reads this on every + // `startSession` so changes propagate without a restart. + manager.binaryPath = config.binaryPath.trim() || undefined; - yield* Effect.acquireRelease( - Effect.sync(() => { - const listener = (event: ProviderRuntimeEvent) => { - Effect.runFork(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid)); - }; - manager.on("event", listener); - return listener; - }), - (listener) => - Effect.gen(function* () { - manager.off("event", listener); - manager.stopAll(); - yield* Queue.shutdown(runtimeEventQueue); - }), - ); + const runtimeEventQueue = yield* Queue.unbounded(); - const service = { - provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), - startSession: (input) => - Effect.gen(function* () { - const providerSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((s) => s.providers.amp), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - if (!providerSettings.enabled) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "AMP provider is disabled in server settings.", - }); - } - manager.binaryPath = providerSettings.binaryPath.trim() || undefined; - return yield* Effect.tryPromise({ - try: () => manager.startSession(input), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), - }); - }), - sendTurn: (input) => { - if ((input.attachments?.length ?? 0) > 0) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "AMP attachments are not supported yet.", - }), - ); - } + yield* Effect.acquireRelease( + Effect.sync(() => { + const listener = (event: ProviderRuntimeEvent) => { + Effect.runFork(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid)); + }; + manager.on("event", listener); + return listener; + }), + (listener) => + Effect.gen(function* () { + manager.off("event", listener); + manager.stopAll(); + yield* Queue.shutdown(runtimeEventQueue); + }), + ); - return Effect.tryPromise({ - try: () => manager.sendTurn(input), - catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + const adapter: ProviderAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession: (input) => + Effect.gen(function* () { + if (!config.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Amp provider is disabled.", }); - }, - interruptTurn: (threadId) => - Effect.tryPromise({ - try: () => manager.interruptTurn(threadId), - catch: (cause) => toRequestError(threadId, "session/interrupt", cause), - }), - respondToRequest: (threadId, requestId, decision) => - Effect.tryPromise({ - try: () => manager.respondToRequest(threadId, requestId, decision), - catch: (cause) => toRequestError(threadId, "permission/reply", cause), - }), - respondToUserInput: (threadId, requestId, answers) => - Effect.tryPromise({ - try: () => manager.respondToUserInput(threadId, requestId, answers), - catch: (cause) => toRequestError(threadId, "question/reply", cause), - }), - stopSession: (threadId) => - Effect.sync(() => { - manager.stopSession(threadId); + } + // Refresh binary path from current config in case the driver + // recreated us from updated settings. + manager.binaryPath = config.binaryPath.trim() || undefined; + return yield* Effect.tryPromise({ + try: () => manager.startSession(input), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); + }), + sendTurn: (input) => { + if ((input.attachments?.length ?? 0) > 0) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "AMP attachments are not supported yet.", }), - listSessions: () => Effect.sync(() => manager.listSessions()), - hasSession: (threadId) => Effect.sync(() => manager.hasSession(threadId)), - readThread: (threadId) => - Effect.tryPromise({ - try: () => manager.readThread(threadId), - catch: (cause) => toRequestError(threadId, "session/messages", cause), + ); + } + return Effect.tryPromise({ + try: () => manager.sendTurn(input), + catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + }); + }, + interruptTurn: (threadId) => + Effect.tryPromise({ + try: () => manager.interruptTurn(threadId), + catch: (cause) => toRequestError(threadId, "session/interrupt", cause), + }), + respondToRequest: (threadId, requestId, decision) => + Effect.tryPromise({ + try: () => manager.respondToRequest(threadId, requestId, decision), + catch: (cause) => toRequestError(threadId, "permission/reply", cause), + }), + respondToUserInput: (threadId, requestId, answers) => + Effect.tryPromise({ + try: () => manager.respondToUserInput(threadId, requestId, answers), + catch: (cause) => toRequestError(threadId, "question/reply", cause), + }), + stopSession: (threadId) => + Effect.sync(() => { + manager.stopSession(threadId); + }), + listSessions: () => Effect.sync(() => manager.listSessions()), + hasSession: (threadId) => Effect.sync(() => manager.hasSession(threadId)), + readThread: (threadId) => + Effect.tryPromise({ + try: () => manager.readThread(threadId), + catch: (cause) => toRequestError(threadId, "session/messages", cause), + }), + rollbackThread: (threadId, numTurns) => { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", }), - rollbackThread: (threadId, numTurns) => { - if (!Number.isInteger(numTurns) || numTurns < 1) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }), - ); - } - - return Effect.tryPromise({ - try: () => manager.rollbackThread(threadId), - catch: (cause) => toRequestError(threadId, "session/revert", cause), - }); - }, - stopAll: () => - Effect.sync(() => { - manager.stopAll(); - }), - streamEvents: Stream.fromQueue(runtimeEventQueue), - } satisfies AmpAdapterShape; - - return service; - }), - ); -} + ); + } + return Effect.tryPromise({ + try: () => manager.rollbackThread(threadId), + catch: (cause) => toRequestError(threadId, "session/revert", cause), + }); + }, + stopAll: () => + Effect.sync(() => { + manager.stopAll(); + }), + streamEvents: Stream.fromQueue(runtimeEventQueue), + }; -export const AmpAdapterLive = makeAmpAdapterLive(); + return adapter; +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2b53f21f1e2..4c71a016f32 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -12,20 +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 = []; @@ -136,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: @@ -146,6 +157,7 @@ function makeHarness(config?: { | undefined; const adapterOptions: ClaudeAdapterLiveOptions = { + ...(config?.instanceId ? { instanceId: config.instanceId } : {}), createQuery: (input) => { createInput = input; return query; @@ -163,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", @@ -209,11 +227,10 @@ async function readFirstPromptText( if (next.done) { return undefined; } - const msg = next.value.message as any; - if (typeof msg.content === "string") { - return msg.content; + if (typeof next.value.message.content === "string") { + return next.value.message.content; } - const content = msg.content[0]; + const content = next.value.message.content[0]; if (!content || content.type !== "text") { return undefined; } @@ -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,14 +354,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -352,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", @@ -380,14 +421,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-7", - options: { - effort: "xhigh", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-7", + [{ id: "effort", value: "xhigh" }], + ), runtimeMode: "full-access", }); @@ -405,14 +444,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -430,14 +467,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "high", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "effort", value: "high" }], + ), runtimeMode: "full-access", }); @@ -455,14 +490,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "thinking", value: false }], + ), runtimeMode: "full-access", }); @@ -482,14 +515,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - thinking: false, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "thinking", value: false }], + ), runtimeMode: "full-access", }); @@ -507,14 +538,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), runtimeMode: "full-access", }); @@ -534,14 +563,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - fastMode: true, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "fastMode", value: true }], + ), runtimeMode: "full-access", }); @@ -559,14 +586,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ), runtimeMode: "full-access", }); @@ -574,13 +599,11 @@ describe("ClaudeAdapterLive", () => { threadId: session.threadId, input: "Investigate the edge cases", attachments: [], - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ), }); const createInput = harness.getLastCreateQueryInput(); @@ -625,7 +648,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -638,7 +661,7 @@ describe("ClaudeAdapterLive", () => { const createInput = harness.getLastCreateQueryInput(); const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput)); assert.isDefined(promptMessage); - assert.deepEqual((promptMessage?.message as any).content, [ + assert.deepEqual(promptMessage?.message.content, [ { type: "text", text: "What's in this image?", @@ -670,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", @@ -847,7 +870,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1026,7 +1049,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1117,7 +1140,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1193,7 +1216,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1259,7 +1282,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1310,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), @@ -1332,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, }); @@ -1387,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), @@ -1419,7 +1454,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1456,7 +1491,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1503,7 +1538,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1557,7 +1592,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1624,7 +1659,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1689,7 +1724,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1770,7 +1805,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1861,7 +1896,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2027,7 +2062,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2096,7 +2131,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2318,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); @@ -2391,7 +2426,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2500,7 +2535,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2573,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", @@ -2601,6 +2636,96 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves durable resume ids across Claude resume hooks", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const durableSessionId = "550e8400-e29b-41d4-a716-446655440000"; + const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719"; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: durableSessionId, + resumeSessionAt: "assistant-99", + turnCount: 3, + }, + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "hook_started", + hook_id: "resume-hook-1", + hook_name: "SessionStart:resume", + hook_event: "SessionStart", + session_id: transientHookSessionId, + uuid: "resume-hook-started", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "hook_response", + hook_id: "resume-hook-1", + hook_name: "SessionStart:resume", + hook_event: "SessionStart", + output: "", + stdout: "", + stderr: "", + outcome: "success", + session_id: transientHookSessionId, + uuid: "resume-hook-response", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "init", + apiKeySource: "none", + claude_code_version: "test", + cwd: "/tmp/claude-adapter-test", + tools: [], + mcp_servers: [], + model: "claude-sonnet-4-5", + permissionMode: "bypassPermissions", + slash_commands: [], + output_style: "default", + skills: [], + plugins: [], + session_id: durableSessionId, + uuid: "resume-init", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started"); + assert.equal(threadStartedEvents.length, 1); + const threadStarted = threadStartedEvents[0]; + assert.equal(threadStarted?.type, "thread.started"); + if (threadStarted?.type === "thread.started") { + assert.deepEqual(threadStarted.payload, { + providerThreadId: durableSessionId, + }); + } + + const activeSessions = yield* adapter.listSessions(); + const resumeCursor = activeSessions[0]?.resumeCursor as + | { + readonly resume?: string; + } + | undefined; + assert.equal(resumeCursor?.resume, durableSessionId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("uses an app-generated Claude session id for fresh sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2608,7 +2733,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2642,7 +2767,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2722,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: [], @@ -2742,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", () => { @@ -2749,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", }); @@ -2788,27 +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: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - contextWindow: "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: [], @@ -2828,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({ @@ -2858,7 +3009,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode, }); @@ -2910,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({ @@ -2933,7 +3084,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2999,7 +3150,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -3071,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", }); @@ -3145,6 +3296,9 @@ describe("ClaudeAdapterLive", () => { assert.equal(typeof requestId, "string"); assert.equal(requestedEvent.value.payload.questions.length, 1); assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?"); + // Regression for #2388: `id` must equal the full question text so the + // UI's draft-answer key matches what the SDK looks up downstream. + assert.equal(requestedEvent.value.payload.questions[0]?.id, "Which framework?"); assert.deepEqual(requestedEvent.value.providerRefs, { providerItemId: ProviderItemId.make("tool-ask-1"), }); @@ -3179,6 +3333,34 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(updatedInput.answers, { "Which framework?": "React" }); // Original questions should be passed through. assert.deepEqual(updatedInput.questions, askInput.questions); + + // Compatibility check for #2388: the answers shape we hand to the SDK + // must produce a non-empty rendered tool_result on BOTH SDK iteration + // patterns we have seen, so we don't regress the issue and we don't + // break users still on the older Claude CLI. + const sdkAnswers = updatedInput.answers as Record; + const sdkQuestions = updatedInput.questions as ReadonlyArray<{ + readonly question: string; + }>; + + // Claude CLI 2.1.119 — key-agnostic Object.entries iteration. Any key + // works here, but it must at least round-trip into a non-empty string. + const v119Rendered = Object.entries(sdkAnswers) + .map(([key, value]) => `"${key}"="${String(value)}"`) + .join(", "); + assert.equal(v119Rendered, '"Which framework?"="React"'); + + // Claude CLI 2.1.121 — lookup by full question text. This is the path + // that regressed in #2388 when the answers were keyed by `header`. + const v121Rendered = sdkQuestions + .map(({ question }) => { + const answer = sdkAnswers[question]; + return answer === undefined ? null : `"${question}"="${String(answer)}"`; + }) + .filter((entry): entry is string => entry !== null) + .join(", "); + assert.notEqual(v121Rendered, "", "Expected non-empty SDK 2.1.121 tool_result (#2388)"); + assert.equal(v121Rendered, '"Which framework?"="React"'); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -3194,7 +3376,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", }); @@ -3260,7 +3442,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -3347,7 +3529,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 3b32646ec40..2fd40f6ecc5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,88 +17,18 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, + type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; - -/** Inline types from SDK — not yet re-exported from the public entry. */ -type ModelUsage = { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - webSearchRequests: number; - contextWindow: number; -}; -type NonNullableUsage = Record; - -/** Inline type aliases for SDK message subtypes that aren't re-exported publicly. */ -type SDKHookStartedMessage = { - type: "system"; - subtype: "hook_started"; - hook_id: string; - hook_name: string; - hook_event: string; - [k: string]: unknown; -}; -type SDKHookProgressMessage = { - type: "system"; - subtype: "hook_progress"; - hook_id: string; - output: string; - stdout: string; - stderr: string; - [k: string]: unknown; -}; -type SDKHookResponseMessage = { - type: "system"; - subtype: "hook_response"; - hook_id: string; - outcome: "error" | "cancelled" | "success"; - output: string; - stdout: string; - stderr: string; - exit_code?: number; - [k: string]: unknown; -}; -type SDKTaskStartedMessage = { - type: "system"; - subtype: "task_started"; - task_id: string; - description: string; - task_type?: string; - [k: string]: unknown; -}; -type SDKTaskProgressMessage = { - type: "system"; - subtype: "task_progress"; - task_id: string; - description: string; - summary?: string; - usage?: Record; - last_tool_name?: string; - [k: string]: unknown; -}; -type SDKTaskNotificationMessage = { - type: "system"; - subtype: "task_notification"; - task_id: string; - status: "completed" | "failed" | "stopped"; - summary?: string; - usage?: Record; - [k: string]: unknown; -}; -type SDKToolUseSummaryMessage = { - type: "tool_use_summary"; - summary: string; - preceding_tool_use_ids?: readonly string[]; - [k: string]: unknown; -}; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, type CanonicalRequestType, + type ClaudeSettings, EventId, type ProviderApprovalDecision, + ProviderDriverKind, + ProviderInstanceId, ProviderItemId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, @@ -113,9 +43,14 @@ import { ThreadId, TurnId, type UserInputQuestion, - type ClaudeAgentEffort, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, + resolvePromptInjectedEffort, +} from "@t3tools/shared/model"; import { Cause, DateTime, @@ -124,7 +59,7 @@ import { Exit, FileSystem, Fiber, - Layer, + Path, Queue, Random, Ref, @@ -133,8 +68,13 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -143,11 +83,10 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; -import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.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, @@ -246,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; @@ -297,19 +238,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeAgentEffort( - effort: ClaudeAgentEffort | null | undefined, -): ClaudeSdkEffort | null { - if (!effort) { - return null; - } - if (effort === "ultrathink") { - return null; - } - if (effort === "xhigh") { - return "max"; - } - return effort; +function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { + const normalized = normalizeClaudeCliEffort(effort); + return normalized ? (normalized as ClaudeSdkEffort) : null; } function isClaudeInterruptedMessage(message: string): boolean { @@ -639,18 +570,19 @@ 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.options?.effort : null; + 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); - // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). - // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. - const trimmedEffort = trimOrNull(rawEffort); - const promptEffort = - trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; + const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -663,7 +595,7 @@ function buildUserMessage(input: { parent_tool_use_id: null, message: { role: "user", - content: input.sdkContent as unknown as Array>, + content: input.sdkContent as unknown as SDKUserMessage["message"]["content"], }, } as SDKUserMessage; } @@ -687,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) { @@ -1039,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 @@ -1065,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)); @@ -1469,9 +1407,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( errorMessage?: string, result?: SDKResultMessage, ) { - const resultContextWindow = maxClaudeContextWindowFromModelUsage( - result?.modelUsage as Record | undefined, - ); + const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; } @@ -1648,26 +1584,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - type StreamEvent = { - type: string; - delta: { - type: string; - text?: string; - thinking?: string; - partial_json?: string; - [k: string]: unknown; - }; - index: number; - content_block?: { - type: string; - id?: string; - name?: string; - input?: unknown; - [k: string]: unknown; - }; - [k: string]: unknown; - }; - const { event } = message as { event: StreamEvent }; + const { event } = message; if (event.type === "content_block_delta") { if ( @@ -1676,7 +1593,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { const deltaText = event.delta.type === "text_delta" - ? (event.delta.text ?? "") + ? event.delta.text : typeof event.delta.thinking === "string" ? event.delta.thinking : ""; @@ -1823,9 +1740,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (event.type === "content_block_start") { const { index, content_block: block } = event; - if (!block) { - return; - } if (block.type === "text") { yield* ensureAssistantTextBlock(context, index, { fallbackText: extractContentBlockText(block), @@ -1840,13 +1754,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - const toolName = block.name ?? "unknown"; + const toolName = block.name; const itemType = classifyToolItemType(toolName); const toolInput = typeof block.input === "object" && block.input !== null ? (block.input as Record) : {}; - const itemId = block.id ?? ""; + const itemId = block.id; const detail = summarizeToolRequest(toolName, toolInput); const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; @@ -2118,13 +2032,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const status = turnStatusFromResult(message); - const errors = (message as { errors?: unknown[] }).errors; - const errorMessage = - message.subtype === "success" - ? undefined - : typeof errors?.[0] === "string" - ? errors[0] - : undefined; + const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -2188,69 +2096,58 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "hook_started": { - const hookStarted = message as SDKHookStartedMessage; + case "hook_started": yield* offerRuntimeEvent({ ...base, type: "hook.started", payload: { - hookId: hookStarted.hook_id, - hookName: hookStarted.hook_name, - hookEvent: hookStarted.hook_event, + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, }, }); return; - } - case "hook_progress": { - const hookProgress = message as SDKHookProgressMessage; + case "hook_progress": yield* offerRuntimeEvent({ ...base, type: "hook.progress", payload: { - hookId: hookProgress.hook_id, - output: hookProgress.output, - stdout: hookProgress.stdout, - stderr: hookProgress.stderr, + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, }, }); return; - } - case "hook_response": { - const hookResponse = message as SDKHookResponseMessage; + case "hook_response": yield* offerRuntimeEvent({ ...base, type: "hook.completed", payload: { - hookId: hookResponse.hook_id, - outcome: hookResponse.outcome, - output: hookResponse.output, - stdout: hookResponse.stdout, - stderr: hookResponse.stderr, - ...(typeof hookResponse.exit_code === "number" - ? { exitCode: hookResponse.exit_code } - : {}), + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), }, }); return; - } - case "task_started": { - const taskStarted = message as SDKTaskStartedMessage; + case "task_started": yield* offerRuntimeEvent({ ...base, type: "task.started", payload: { - taskId: RuntimeTaskId.make(taskStarted.task_id), - description: taskStarted.description, - ...(taskStarted.task_type ? { taskType: taskStarted.task_type } : {}), + taskId: RuntimeTaskId.make(message.task_id), + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), }, }); return; - } - case "task_progress": { - const taskProgress = message as SDKTaskProgressMessage; - if (taskProgress.usage) { + case "task_progress": + if (message.usage) { const normalizedUsage = normalizeClaudeTokenUsage( - taskProgress.usage, + message.usage, context.lastKnownContextWindow, ); if (normalizedUsage) { @@ -2271,20 +2168,18 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, type: "task.progress", payload: { - taskId: RuntimeTaskId.make(taskProgress.task_id), - description: taskProgress.description, - ...(taskProgress.summary ? { summary: taskProgress.summary } : {}), - ...(taskProgress.usage ? { usage: taskProgress.usage } : {}), - ...(taskProgress.last_tool_name ? { lastToolName: taskProgress.last_tool_name } : {}), + taskId: RuntimeTaskId.make(message.task_id), + description: message.description, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), }, }); return; - } - case "task_notification": { - const taskNotification = message as SDKTaskNotificationMessage; - if (taskNotification.usage) { + case "task_notification": + if (message.usage) { const normalizedUsage = normalizeClaudeTokenUsage( - taskNotification.usage, + message.usage, context.lastKnownContextWindow, ); if (normalizedUsage) { @@ -2305,14 +2200,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, type: "task.completed", payload: { - taskId: RuntimeTaskId.make(taskNotification.task_id), - status: taskNotification.status, - ...(taskNotification.summary ? { summary: taskNotification.summary } : {}), - ...(taskNotification.usage ? { usage: taskNotification.usage } : {}), + taskId: RuntimeTaskId.make(message.task_id), + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), }, }); return; - } case "files_persisted": yield* offerRuntimeEvent({ ...base, @@ -2380,14 +2274,15 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (message.type === "tool_use_summary") { - const toolSummary = message as SDKToolUseSummaryMessage; yield* offerRuntimeEvent({ ...base, type: "tool.summary", payload: { - summary: toolSummary.summary, - ...(toolSummary.preceding_tool_use_ids && toolSummary.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: toolSummary.preceding_tool_use_ids } + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { + precedingToolUseIds: message.preceding_tool_use_ids, + } : {}), }, }); @@ -2395,19 +2290,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (message.type === "auth_status") { - const authMsg = message as { - isAuthenticating?: boolean; - output?: string; - error?: string; - [k: string]: unknown; - }; yield* offerRuntimeEvent({ ...base, type: "auth.status", payload: { - isAuthenticating: authMsg.isAuthenticating, - ...(authMsg.output ? { output: [authMsg.output] } : {}), - ...(authMsg.error ? { error: authMsg.error } : {}), + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), }, }); return; @@ -2674,10 +2563,14 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); // Parse questions from the SDK's AskUserQuestion input. + // `id` MUST equal the full question text — Claude SDK >= 2.1.121 looks + // up answers by question text in `mapToolResultToToolResultBlockParam`, + // so the key the UI uses to keep its draft answer must match the SDK's + // expected lookup key. See https://github.com/pingdotgg/t3code/issues/2388 const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; const questions: Array = rawQuestions.map( (q: Record, idx: number) => ({ - id: typeof q.header === "string" ? q.header : `q-${idx}`, + id: typeof q.question === "string" && q.question.length > 0 ? q.question : `q-${idx}`, header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, question: typeof q.question === "string" ? q.question : "", options: Array.isArray(q.options) @@ -2945,31 +2838,27 @@ 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; - const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeAgentEffort | null; - const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; - const thinking = - typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle - ? modelSelection.options.thinking - : undefined; + const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); + const effort = resolveClaudeEffort(caps, rawEffort) ?? null; + const fastModeSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + const thinkingSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", + ); + const fastMode = + getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && + fastModeSupported; + const thinking = thinkingSupported + ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") + : undefined; const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", @@ -2980,7 +2869,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 } : {}), @@ -3002,7 +2890,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 } : {}), }; @@ -3027,8 +2915,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.include_partial_messages": true, "claude.query.additional_directories": input.cwd ? [input.cwd] : [], "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], - "claude.query.settings_keys": Object.keys(settings).sort(), - "claude.query.extra_args_count": Object.keys(extraArgs).length, + "claude.query.settings_json": JSON.stringify(settings), + "claude.query.extra_args_json": JSON.stringify(extraArgs), "claude.query.path_to_executable": claudeBinaryPath, }); @@ -3050,6 +2938,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 } : {}), @@ -3161,7 +3050,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 @@ -3235,6 +3126,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const message = yield* buildUserMessageEffect(input, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, + boundInstanceId, }); yield* Queue.offer(context.promptQueue, { @@ -3353,7 +3245,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return { provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, @@ -3370,9 +3264,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 d50f9761730..43505967002 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,52 +1,45 @@ -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 { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; - -/** Inline type — not yet re-exported from the public SDK entry. */ -type ClaudeSlashCommand = { - name: string; - description: string; - argumentHint: string; -}; +import { + createModelCapabilities, + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import { + query as claudeQuery, + type SlashCommand as ClaudeSlashCommand, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, - AUTH_PROBE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, detailFromResult, - extractAuthBoolean, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, - collectStreamAsString, - type CommandResult, + spawnAndCollect, + 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"; - -const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; -const PROVIDER = "claudeAgent" as const; +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, @@ -57,93 +50,128 @@ const BUILT_IN_MODELS: ReadonlyArray = [ slug: "claude-opus-4-7", name: "Claude Opus 4.7", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-opus-4-5", name: "Claude Opus 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + }), }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildBooleanOptionDescriptor({ + id: "thinking", + label: "Thinking", + }), + ], + }), }, ]; @@ -173,188 +201,46 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } -export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - switch (modelSelection.options?.contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - 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.", - }; +export function resolveClaudeEffort( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); + const value = getProviderOptionCurrentValue(effortDescriptor); + return typeof value === "string" ? value : undefined; } -// ── 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`. + * Normalize a resolved Claude effort value into one suitable for the Claude + * CLI's `--effort` flag. + * + * Mirrors the mapping used when invoking the Claude Agent SDK + * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): the Opus 4.7 + * capability `"xhigh"` is rewritten to the accepted CLI value `"max"`, and + * `"ultrathink"` is filtered out because it is a prompt-prefix mode rather + * than a CLI-effort value. Returns `undefined` when no flag should be passed. */ -function findSubscriptionType(value: unknown): Option.Option { - if (globalThis.Array.isArray(value)) { - return Option.firstSomeOf(value.map(findSubscriptionType)); +export function normalizeClaudeCliEffort(effort: string | null | undefined): string | undefined { + if (!effort || effort === "ultrathink") { + return undefined; } - - 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)); + if (effort === "xhigh") { + return "max"; } - - 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)); + return effort; } -function extractClaudeAuthMethodFromOutput(result: CommandResult): string | undefined { - const parsed = decodeUnknownJson(result.stdout.trim()); - if (Result.isFailure(parsed)) return undefined; - return Option.getOrUndefined(findAuthMethod(parsed.success)); +export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { + switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } } function toTitleCaseWords(value: string): string { @@ -370,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": @@ -391,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; @@ -407,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), }; } @@ -426,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 { @@ -437,9 +368,7 @@ function parseClaudeInitializationCommands( } const description = nonEmptyProbeString(command.description); - const argumentHint = command.argumentHint - ? nonEmptyProbeString(command.argumentHint) - : undefined; + const argumentHint = nonEmptyProbeString(command.argumentHint); return [ { @@ -513,34 +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, - // @ts-expect-error SDK 0.2.77 types diverge under exactOptionalPropertyTypes - 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!(); - const account = (init as { account?: { subscriptionType?: string } }).account; - return { - subscriptionType: account?.subscriptionType, - slashCommands: parseClaudeInitializationCommands( - init.commands as ClaudeSlashCommand[] | undefined, - ), - }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -556,44 +497,30 @@ const probeClaudeCapabilities = (binaryPath: string) => { ); }; -const runClaudeCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.claudeAgent), - ); - const command = ChildProcess.make(claudeSettings.binaryPath.trim() || "claude", [...args], { - shell: process.platform === "win32", - }); - - const child = yield* spawner.spawn(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); +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, @@ -604,7 +531,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (!claudeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, @@ -619,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, ); @@ -627,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, @@ -646,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, @@ -667,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, @@ -694,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, @@ -764,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, @@ -781,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, @@ -806,7 +675,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid if (!claudeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, @@ -822,7 +690,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid } return buildServerProvider({ - provider: PROVIDER, presentation: CLAUDE_PRESENTATION, enabled: true, checkedAt, @@ -837,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 bd7b3bddaef..11f6cc552f3 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, @@ -14,16 +17,17 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +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, @@ -31,7 +35,12 @@ import { type CodexSessionRuntimeShape, type CodexThreadSnapshot, } from "./CodexSessionRuntime.ts"; -import { fetchCodexUsage, 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); @@ -44,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, @@ -202,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), @@ -216,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", }) @@ -226,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'.", }), @@ -234,22 +251,17 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.equal(validationRuntimeFactory.factory.mock.calls.length, 0); }), ); - it.effect("maps codex model options before starting a session", () => Effect.gen(function* () { validationRuntimeFactory.factory.mockClear(); const adapter = yield* CodexAdapter; yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -257,26 +269,26 @@ 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", }); }), ); - - it.effect("fetchCodexUsage returns empty usage after manager removal", () => - Effect.gen(function* () { - const usage = yield* Effect.promise(() => fetchCodexUsage()); - assert.equal(usage.provider, "codex"); - assert.equal(usage.quota, undefined); - assert.equal(usage.quotas, undefined); - }), - ); }); 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), @@ -307,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", }); @@ -319,14 +331,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { adapter.sendTurn({ threadId: asThreadId("sess-missing"), input: "hello", - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), attachments: [], }), ); @@ -339,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), @@ -355,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", }); @@ -374,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"), @@ -416,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"), @@ -457,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"), @@ -494,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", @@ -525,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", @@ -563,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", @@ -597,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", @@ -633,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", @@ -668,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", @@ -703,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", @@ -743,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", @@ -788,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", @@ -815,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", @@ -855,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(), @@ -914,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), @@ -929,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", }); @@ -950,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), @@ -966,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", }) @@ -991,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), @@ -1004,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", }); @@ -1016,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 d65741e45f5..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,11 +24,16 @@ 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"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; + import { ProviderAdapterRequestError, ProviderAdapterProcessError, @@ -34,12 +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 { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; -import type { ProviderUsageResult } from "@t3tools/contracts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, @@ -50,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< @@ -317,15 +324,11 @@ function toUserInputQuestions(questions: ReadonlyArray { const label = trimText(option.label); - // Description is optional — keep label-only options rather than - // dropping them, otherwise Codex tool prompts that only provide - // labels (no per-option description) lose all choices and the - // surrounding `options.length === 0` guard rejects the question. const description = trimText(option.description); - if (!label) { + if (!label || !description) { return undefined; } - return description ? { label, description } : { label }; + return { label, description }; }) .filter((option) => option !== undefined) ?? []; @@ -1319,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); @@ -1334,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(); @@ -1354,31 +1367,22 @@ 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.options?.fastMode + ...(input.modelSelection?.instanceId === boundInstanceId && + getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), }; @@ -1402,10 +1406,6 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - // Keep the Codex event pump in the session scope so it is - // interrupted automatically when the session's scope closes, - // rather than leaking into the surrounding `Effect.scoped` fiber - // which exits as soon as `startSession` returns. const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); @@ -1421,7 +1421,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkIn(sessionScope)); + ).pipe(Effect.forkChild); const started = yield* runtime.start().pipe( Effect.mapError( @@ -1495,19 +1495,26 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const session = yield* requireSession(input.threadId); + const reasoningEffort = + input.modelSelection?.instanceId === boundInstanceId + ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") + : undefined; + const fastMode = + 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 } : {}), - ...(input.modelSelection?.provider === "codex" && - input.modelSelection.options?.reasoningEffort !== undefined - ? { effort: input.modelSelection.options.reasoningEffort } - : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode - ? { serviceTier: "fast" } + ...(reasoningEffort + ? { + effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + } : {}), + ...(fastMode === true ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) @@ -1653,7 +1660,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( return { provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, @@ -1671,18 +1680,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } satisfies CodexAdapterShape; }); -/** - * Fetches Codex usage information. After upstream's effect-codex-app-server - * refactor (PR #1942), the manager-based rate limit readout is no longer - * available at the adapter layer. Returns an empty usage record until - * rate-limit events are surfaced through the new session runtime. - */ -export async function fetchCodexUsage(): Promise { - return { provider: "codex" }; -} - -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 4dee22bdd3c..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"; @@ -25,16 +14,18 @@ import type { } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { buildServerProvider } from "../providerSnapshot.ts"; -import { CodexProvider } from "../Services/CodexProvider.ts"; -import { ServerConfig } from "../../config.ts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +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", + showInteractionModeToggle: true, +} as const; export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; @@ -85,20 +76,52 @@ 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 { - return { - reasoningEffortLevels: model.supportedReasoningEfforts.map(({ reasoningEffort }) => ({ - value: reasoningEffort, - label: REASONING_EFFORT_LABELS[reasoningEffort], - ...(reasoningEffort === model.defaultReasoningEffort ? { isDefault: true } : {}), - })), - supportsFastMode: (model.additionalSpeedTiers ?? []).includes("fast"), - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; + const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => + reasoningEffort === model.defaultReasoningEffort + ? { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + isDefault: true, + } + : { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + }, + ); + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + const supportsFastMode = (model.additionalSpeedTiers ?? []).includes("fast"); + return createModelCapabilities({ + optionDescriptors: [ + ...(reasoningOptions.length > 0 + ? [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }, + ] + : []), + ...(supportsFastMode + ? [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean" as const, + }, + ] + : []), + ], + }); } const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { @@ -213,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( @@ -273,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 @@ -286,13 +324,13 @@ 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, models, @@ -308,7 +346,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider } return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: true, checkedAt, models, @@ -329,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) { @@ -351,32 +391,30 @@ 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 | ServerConfig | ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner > { - const codexSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.codex), - ); - const serverConfig = yield* Effect.service(ServerConfig); const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); if (!codexSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models: emptyModels, @@ -394,15 +432,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, - cwd: serverConfig.cwd, + 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, models: emptyModels, @@ -421,7 +460,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Option.isNone(probeResult.success)) { return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, @@ -440,7 +479,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const accountStatus = accountProbeStatus(snapshot.account); return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: snapshot.models, @@ -455,30 +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 serverConfig = yield* Effect.service(ServerConfig); - const checkProvider = checkCodexProviderStatus().pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(ServerConfig, serverConfig), - ); - - 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/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts deleted file mode 100644 index 36fa447fcff..00000000000 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import assert from "node:assert/strict"; - -import { ThreadId } from "@t3tools/contracts"; -import { type SessionEvent } from "@github/copilot-sdk"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { afterAll, it, vi } from "@effect/vitest"; - -import { Effect, Fiber, Layer, Stream } from "effect"; - -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; -import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; - -const asThreadId = (value: string): ThreadId => ThreadId.make(value); - -class FakeCopilotSession { - public readonly sessionId: string; - - public readonly modeSetImpl = vi.fn( - async ({ mode }: { mode: "interactive" | "plan" | "autopilot" }) => ({ - mode, - }), - ); - - public readonly planReadImpl = vi.fn( - async (): Promise<{ - exists: boolean; - content: string | null; - path: string | null; - }> => ({ - exists: false, - content: null, - path: null, - }), - ); - - public readonly sendImpl = vi.fn( - async (_options: { prompt: string; attachments?: unknown; mode?: string }) => "message-1", - ); - - public readonly abortImpl = vi.fn(async () => undefined); - public readonly destroyImpl = vi.fn(async () => undefined); - public readonly getMessagesImpl = vi.fn(async () => [] as SessionEvent[]); - - private readonly handlers = new Set<(event: SessionEvent) => void>(); - - public readonly rpc = { - mode: { - set: this.modeSetImpl, - }, - plan: { - read: this.planReadImpl, - }, - }; - - constructor(sessionId: string) { - this.sessionId = sessionId; - } - - on(handler: (event: SessionEvent) => void) { - this.handlers.add(handler); - return () => { - this.handlers.delete(handler); - }; - } - - send(options: { prompt: string; attachments?: unknown; mode?: string }) { - return this.sendImpl(options); - } - - abort() { - return this.abortImpl(); - } - - destroy() { - return this.destroyImpl(); - } - - getMessages() { - return this.getMessagesImpl(); - } - - emit(event: SessionEvent) { - for (const handler of this.handlers) { - handler(event); - } - } -} - -class FakeCopilotClient { - public readonly startImpl = vi.fn(async () => undefined); - public readonly listModelsImpl = vi.fn(async () => []); - public readonly createSessionImpl = vi.fn(async () => this.session); - public readonly resumeSessionImpl = vi.fn(async () => this.session); - public readonly stopImpl = vi.fn(async () => [] as Error[]); - private readonly session: FakeCopilotSession; - - constructor(session: FakeCopilotSession) { - this.session = session; - } - - start() { - return this.startImpl(); - } - - listModels() { - return this.listModelsImpl(); - } - - createSession(_config: unknown) { - return this.createSessionImpl(); - } - - resumeSession(_sessionId: string, _config: unknown) { - return this.resumeSessionImpl(); - } - - stop() { - return this.stopImpl(); - } -} - -const modeSession = new FakeCopilotSession("copilot-session-mode"); -const modeClient = new FakeCopilotClient(modeSession); -const modeLayer = it.layer( - makeCopilotAdapterLive({ - clientFactory: () => modeClient, - }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), - ), -); - -modeLayer("CopilotAdapterLive interaction mode", (it) => { - // Skip: @github/copilot-sdk has broken ESM resolution (vscode-jsonrpc/node) in CI - it.effect.skip("switches the Copilot session mode when interactionMode changes", () => - Effect.gen(function* () { - modeSession.modeSetImpl.mockClear(); - modeSession.sendImpl.mockClear(); - - const adapter = yield* CopilotAdapter; - const session = yield* adapter.startSession({ - provider: "copilot", - threadId: asThreadId("thread-mode"), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "Plan the work", - interactionMode: "plan", - attachments: [], - }); - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "Now execute it", - interactionMode: "default", - attachments: [], - }); - - assert.deepStrictEqual(modeSession.modeSetImpl.mock.calls, [ - [{ mode: "plan" }], - [{ mode: "interactive" }], - ]); - assert.equal(modeSession.sendImpl.mock.calls[0]?.[0]?.mode, "immediate"); - assert.equal(modeSession.sendImpl.mock.calls[1]?.[0]?.mode, "immediate"); - }), - ); -}); - -const planSession = new FakeCopilotSession("copilot-session-plan"); -const planClient = new FakeCopilotClient(planSession); -const planLayer = it.layer( - makeCopilotAdapterLive({ - clientFactory: () => planClient, - }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), - ), -); - -planLayer("CopilotAdapterLive proposed plan events", (it) => { - // Skip: @github/copilot-sdk has broken ESM resolution (vscode-jsonrpc/node) in CI - it.effect.skip("emits a proposed-plan completion event from Copilot plan updates", () => - Effect.gen(function* () { - planSession.modeSetImpl.mockClear(); - planSession.planReadImpl.mockReset(); - planSession.planReadImpl.mockResolvedValue({ - exists: true, - content: "# Ship it\n\n- first\n- second", - path: "/tmp/copilot-session-plan/plan.md", - }); - - const adapter = yield* CopilotAdapter; - const session = yield* adapter.startSession({ - provider: "copilot", - threadId: asThreadId("thread-plan"), - runtimeMode: "full-access", - }); - - yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "Draft a plan", - interactionMode: "plan", - attachments: [], - }); - - const eventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - planSession.emit({ - id: "evt-plan-changed", - timestamp: new Date().toISOString(), - parentId: null, - type: "session.plan_changed", - data: { - operation: "update", - }, - } satisfies SessionEvent); - - const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events[0]?.type, "turn.plan.updated"); - if (events[0]?.type === "turn.plan.updated") { - assert.equal(events[0].turnId, turn.turnId); - assert.equal(events[0].payload.explanation, "Plan updated"); - } - - assert.equal(events[1]?.type, "turn.proposed.completed"); - if (events[1]?.type === "turn.proposed.completed") { - assert.equal(events[1].turnId, turn.turnId); - assert.equal(events[1].payload.planMarkdown, "# Ship it\n\n- first\n- second"); - } - }), - ); -}); - -afterAll(() => { - void modeSession.destroy(); - void modeClient.stop(); - void planSession.destroy(); - void planClient.stop(); -}); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 5d00bf1e5a8..7b17e8051f1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; import { - type CodexReasoningEffort, EventId, + type GenericProviderSettings, type ProviderApprovalDecision, ProviderItemId, + ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, type ProviderTurnStartResult, @@ -23,18 +24,19 @@ import type { PermissionRequestResult, SessionEvent, } from "@github/copilot-sdk"; -import { Effect, Layer, Queue, Stream } from "effect"; +import { Effect, Queue, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; +// Inline capabilities — `getProviderCapabilities` was a fork-side helper +// that died with the upstream merge; the values it returned for "copilot" +// are reproduced inline at the adapter shape construction below. import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { assistantUsageFields, @@ -54,8 +56,16 @@ import type { ProviderThreadTurnSnapshot, } from "../Services/ProviderAdapter.ts"; -const PROVIDER = "copilot" as const; +const PROVIDER = ProviderDriverKind.make("copilot"); const USER_INPUT_QUESTION_ID = "answer"; + +// Local re-declaration of the codex reasoning-effort literal — the contracts +// package no longer exports `CodexReasoningEffort` after the upstream merge, +// so we mirror the literal set here. Strictly internal; never crosses a wire +// or storage boundary. +// TODO(sync): align with upstream's new model-options envelope once the +// fork's reasoning-effort UX is rewired against `ModelSelection.options`. +type CodexReasoningEffort = "xhigh" | "high" | "medium" | "low"; const USER_INPUT_QUESTION_HEADER = "Question"; export interface CopilotAdapterLiveOptions { @@ -441,10 +451,12 @@ function createSessionRecord(input: { }; } -const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => +const makeCopilotAdapter = ( + config: GenericProviderSettings, + options?: CopilotAdapterLiveOptions, +) => Effect.gen(function* () { const serverConfig = yield* ServerConfig; - const serverSettingsService = yield* ServerSettingsService; const nativeEventLogger = options?.nativeEventLogger; const runtimeEventQueue = yield* Queue.unbounded(); const sessions = new Map(); @@ -1265,18 +1277,11 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }); } - const copilotSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((s) => s.providers.copilot), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); + // Per-instance config captured in the driver factory closure. + // Replaces the legacy `ServerSettingsService.getSettings` lookup + // so two driver instances can run side-by-side with independent + // binary paths and config dirs. + const copilotSettings = config; const existing = sessions.get(input.threadId); if (existing) { return { @@ -1314,7 +1319,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const pendingApprovalResolvers = new Map(); const pendingUserInputResolvers = new Map(); const copilotOptions = - input.modelSelection?.provider === "copilot" ? input.modelSelection.options : undefined; + input.modelSelection?.options; const model = input.modelSelection?.model; const reasoningEffort = getCopilotReasoningEffort(copilotOptions); let sessionRecord: ActiveCopilotSession | undefined; @@ -1428,7 +1433,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => Effect.gen(function* () { const record = yield* getSessionRecord(input.threadId); const turnCopilotOptions = - input.modelSelection?.provider === "copilot" ? input.modelSelection.options : undefined; + input.modelSelection?.options; const turnModel = input.modelSelection?.model; const explicitReasoningEffort = getCopilotReasoningEffort(turnCopilotOptions); const nextModel = turnModel ?? record.model; @@ -1687,7 +1692,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => return { provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), + capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, interruptTurn, @@ -1703,11 +1708,23 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } satisfies CopilotAdapterShape; }); -export const CopilotAdapterLive = Layer.effect(CopilotAdapter, makeCopilotAdapter()); - -export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { - return Layer.effect(CopilotAdapter, makeCopilotAdapter(options)); -} +/** + * Per-instance Copilot adapter factory used by `CopilotDriver.create()`. + * + * Returns the `CopilotAdapterShape` value directly — no Layer wrapper — + * so the driver can compose it with snapshot/textGeneration closures and + * hand the result back to the registry as one `ProviderInstance`. + * + * TODO(sync): the indirection through `makeCopilotAdapter` (no `Live` + * suffix) and the legacy `CopilotAdapter` Service tag is a transitional + * shim. A future cleanup should inline the body straight into + * `CopilotDriver.ts` and delete the Service tag entirely. Tracked in the + * sync-multiprovider PR description. + */ +export const makeCopilotAdapterImpl = ( + config: GenericProviderSettings, + options?: CopilotAdapterLiveOptions, +) => makeCopilotAdapter(config, options); // ── Dynamic model discovery & usage (consumed by wsServer) ───────── diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index e6bbd7569a4..375eec049a2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -5,14 +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 { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; + +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"); @@ -90,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(), { @@ -119,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"); @@ -204,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); @@ -243,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" }, @@ -275,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", }) @@ -301,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({ @@ -357,19 +393,15 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { providers: { cursor: { binaryPath: wrapperPath } }, }); - const modelSelection = { - provider: "cursor" as const, - model: "gpt-5.4", - options: { - reasoning: "xhigh" as const, - contextWindow: "1m", - fastMode: true, - }, - }; + const modelSelection = createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ]); yield* adapter.startSession({ threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection, @@ -463,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({ @@ -563,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(), { @@ -618,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({ @@ -717,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({ @@ -839,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 @@ -909,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 @@ -952,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 @@ -995,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 @@ -1038,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)); @@ -1076,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({ @@ -1092,7 +1131,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "second turn after switching model", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: true }, + ]), }); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); @@ -1137,24 +1178,28 @@ 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: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: true }, + ]), }); yield* adapter.sendTurn({ threadId, input: "second turn without fast mode", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } }, + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: false }, + ]), }); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); @@ -1171,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 03e12a174a1..34d1221b022 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,13 +7,16 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, - type CursorModelOptions, + type CursorSettings, + type ProviderOptionSelection, EventId, type ProviderApprovalDecision, type ProviderInteractionMode, 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,20 +73,37 @@ import { extractPlanMarkdown, extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; -import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.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 { @@ -223,7 +241,7 @@ function applyRequestedSessionConfiguration(input: { readonly modelSelection: | { readonly model: string; - readonly options?: CursorModelOptions | null | undefined; + readonly options?: ReadonlyArray | null | undefined; } | undefined; readonly mapError: (context: { @@ -236,7 +254,7 @@ function applyRequestedSessionConfiguration(input: { yield* applyCursorAcpModelSelection({ runtime: input.runtime, model: input.modelSelection.model, - modelOptions: input.modelSelection.options, + selections: input.modelSelection.options, mapError: ({ cause }) => input.mapError({ cause, @@ -281,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 @@ -441,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"); @@ -476,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 } : {}), @@ -667,6 +688,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const now = yield* nowIso; const session: ProviderSession = { provider: PROVIDER, + providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, cwd, @@ -816,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({ @@ -1034,7 +1056,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return { provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), + capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, interruptTurn, @@ -1050,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 6cbfb1078b9..33ef20acf21 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,18 +1,17 @@ -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"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { buildCursorProviderSnapshot, buildCursorCapabilitiesFromConfigOptions, buildCursorDiscoveredModelsFromConfigOptions, + checkCursorProviderStatus, discoverCursorModelCapabilitiesViaAcp, discoverCursorModelsViaAcp, getCursorFallbackModels, @@ -24,11 +23,50 @@ 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))); -async function makeMockAgentWrapper(extraEnv?: Record) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); +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, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} + +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)}`) @@ -37,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; -} +}); -async function waitForFileContent(filePath: string, attempts = 40): Promise { +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; +}); + +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 = [ { @@ -239,13 +334,7 @@ const baseCursorSettings: CursorSettings = { customModels: [], }; -const emptyCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} as const; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { @@ -309,52 +398,57 @@ describe("buildCursorProviderSnapshot", () => { describe("buildCursorCapabilitiesFromConfigOptions", () => { it("derives model capabilities from parameterized Cursor ACP config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272K", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }); + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ]), + selectDescriptor("contextWindow", "Context", [ + { id: "272k", label: "272K", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + booleanDescriptor("fastMode", "Fast", false), + ], + }), + ); }); it("detects boolean thinking toggles from model_config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); }); it("prefers the newer model_option effort control over legacy thought_level", () => { expect( buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), - ).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "max", label: "Max", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Effort", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast", true), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); }); }); @@ -365,81 +459,76 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { slug: "default", name: "Auto", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "composer-2", name: "Composer 2", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], + }), }, { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "claude-sonnet-4-6", name: "Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "gpt-5.3-codex-spark", name: "Codex 5.3 Spark", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ]); }); }); +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({ @@ -459,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({ @@ -474,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 }, @@ -517,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); }); }); @@ -539,6 +624,7 @@ describe("parseCursorAboutOutput", () => { status: "ready", auth: { status: "authenticated", + email: "jmarminge@gmail.com", type: "Team", label: "Cursor Team Subscription", }, @@ -645,11 +731,11 @@ describe("resolveCursorAcpBaseModelId", () => { describe("resolveCursorAcpConfigUpdates", () => { it("maps Cursor model options onto separate ACP config option updates", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }), + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), ).toEqual([ { configId: "reasoning", value: "extra-high" }, { configId: "context", value: "1m" }, @@ -659,28 +745,28 @@ describe("resolveCursorAcpConfigUpdates", () => { it("maps boolean thinking toggles when the model exposes them separately", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, { - thinking: false, - }), + resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, [ + { id: "thinking", value: false }, + ]), ).toEqual([{ configId: "thinking", value: false }]); }); it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - fastMode: false, - }), + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "fastMode", value: false }, + ]), ).toEqual([{ configId: "fast", value: "false" }]); }); it("writes Cursor effort changes through the newer model_option config when available", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, { - reasoning: "high", - thinking: false, - }), + resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, [ + { id: "reasoning", value: "max" }, + { id: "thinking", value: false }, + ]), ).toEqual([ - { configId: "effort", value: "high" }, + { configId: "effort", value: "max" }, { configId: "thinking", value: "false" }, ]); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 70d5656b3ec..ad52f63fbb2 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,46 +1,49 @@ -import * as nodeFs from "node:fs"; import * as nodeOs from "node:os"; -import * as nodePath from "node:path"; import type { - CursorModelOptions, CursorSettings, ModelCapabilities, + ProviderOptionSelection, ServerProvider, 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, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, +} from "@t3tools/shared/model"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, collectStreamAsString, 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 EMPTY_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; + +const PROVIDER = ProviderDriverKind.make("cursor"); +const CURSOR_PRESENTATION = { + displayName: "Cursor", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); 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: { @@ -48,13 +51,15 @@ 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, models, @@ -69,7 +74,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser } return buildServerProvider({ - provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: true, checkedAt, models, @@ -102,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) => ({ @@ -198,6 +208,28 @@ function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): return values.has("true") && values.has("false"); } +function getBooleanCurrentValue( + option: EffectAcpSchema.SessionConfigOption | undefined, +): boolean | undefined { + if (!option) { + return undefined; + } + if (option.type === "boolean") { + return option.currentValue; + } + if (option.type !== "select") { + return undefined; + } + const normalized = option.currentValue?.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + return undefined; +} + export function buildCursorCapabilitiesFromConfigOptions( configOptions: ReadonlyArray | null | undefined, ): ModelCapabilities { @@ -251,14 +283,60 @@ export function buildCursorCapabilitiesFromConfigOptions( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); + const fastCurrentValue = getBooleanCurrentValue(fastOption); + const thinkingCurrentValue = getBooleanCurrentValue(thinkingOption); + const optionDescriptors = [ + ...(reasoningEffortLevels.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: reasoningConfig?.name?.trim() || "Reasoning", + options: reasoningEffortLevels, + }), + ] + : []), + ...(contextWindowOptions.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "contextWindow", + label: contextOption?.name?.trim() || "Context Window", + options: contextWindowOptions, + }), + ] + : []), + ...(fastOption && isBooleanLikeConfigOption(fastOption) + ? [ + typeof fastCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + currentValue: fastCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + }), + ] + : []), + ...(thinkingOption && isBooleanLikeConfigOption(thinkingOption) + ? [ + typeof thinkingCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + currentValue: thinkingCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + }), + ] + : []), + ]; - return { - reasoningEffortLevels, - supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false, - supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false, - contextWindowOptions, - promptInjectedEffortLevels: [], - }; + return createModelCapabilities({ + optionDescriptors, + }); } function buildCursorDiscoveredModels( @@ -282,13 +360,7 @@ function buildCursorDiscoveredModels( } function hasCursorModelCapabilities(model: Pick): boolean { - return ( - (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0 - ); + return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; } export function buildCursorDiscoveredModelsFromConfigOptions( @@ -320,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( @@ -332,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" }, @@ -345,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 ( @@ -387,16 +468,24 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s export function resolveCursorAcpConfigUpdates( configOptions: ReadonlyArray | null | undefined, - modelOptions: CursorModelOptions | null | undefined, -): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { + selections: ReadonlyArray | null | undefined, +): 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(modelOptions?.reasoning); + const requestedReasoning = normalizeCursorReasoningValue( + getProviderOptionStringSelectionValue(selections, "reasoning"), + ); if (reasoningOption && requestedReasoning) { const value = findCursorSelectOptionValue(reasoningOption, (option) => { const normalizedValue = normalizeCursorReasoningValue(option.value); @@ -411,14 +500,15 @@ export function resolveCursorAcpConfigUpdates( const contextOption = configOptions.find( (option) => option.category === "model_config" && isCursorContextConfigOption(option), ); - if (contextOption && modelOptions?.contextWindow) { + const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow"); + if (contextOption && requestedContextWindow) { const value = findCursorSelectOptionValue( contextOption, (option) => normalizeCursorConfigOptionToken(option.value) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow) || + normalizeCursorConfigOptionToken(requestedContextWindow) || normalizeCursorConfigOptionToken(option.name) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow), + normalizeCursorConfigOptionToken(requestedContextWindow), ); if (value) { updates.push({ configId: contextOption.id, value }); @@ -428,8 +518,9 @@ export function resolveCursorAcpConfigUpdates( const fastOption = configOptions.find( (option) => option.category === "model_config" && isCursorFastConfigOption(option), ); - if (fastOption && typeof modelOptions?.fastMode === "boolean") { - const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); + const requestedFastMode = getProviderOptionBooleanSelectionValue(selections, "fastMode"); + if (fastOption && typeof requestedFastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); if (value !== undefined) { updates.push({ configId: fastOption.id, value }); } @@ -438,8 +529,9 @@ export function resolveCursorAcpConfigUpdates( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); - if (thinkingOption && typeof modelOptions?.thinking === "boolean") { - const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking); + const requestedThinking = getProviderOptionBooleanSelectionValue(selections, "thinking"); + if (thinkingOption && typeof requestedThinking === "boolean") { + const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); if (value !== undefined) { updates.push({ configId: thinkingOption.id, value }); } @@ -448,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(), @@ -492,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( @@ -606,10 +719,10 @@ 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, models: providerModelsFromSettings( @@ -733,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; @@ -856,6 +968,7 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult status: "ready", auth: { status: "authenticated", + email: userEmail, ...authMetadata, }, }; @@ -911,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", }); @@ -938,199 +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, - 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, - 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, - 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, - 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/GeminiCliAdapter.test.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts index ce0bdfa3014..2d27ed2a923 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts @@ -1,31 +1,27 @@ import assert from "node:assert/strict"; import { - ApprovalRequestId, EventId, + GenericProviderSettings, RuntimeItemId, ThreadId, TurnId, - type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, type ProviderTurnStartResult, - type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Layer, Stream } from "effect"; +import { Effect, Schema, Stream } from "effect"; import { GeminiCliServerManager } from "../../geminiCliServerManager.ts"; -import { GeminiCliAdapter } from "../Services/GeminiCliAdapter.ts"; -import { makeGeminiCliAdapterLive } from "./GeminiCliAdapter.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeGeminiCliAdapter } from "./GeminiCliAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asItemId = (value: string): RuntimeItemId => RuntimeItemId.make(value); -class FakeGeminiCliManager extends GeminiCliServerManager { +class FakeGeminiManager extends GeminiCliServerManager { public startSessionImpl = vi.fn(async (threadId: ThreadId): Promise => { const now = new Date().toISOString(); return { @@ -36,7 +32,6 @@ class FakeGeminiCliManager extends GeminiCliServerManager { cwd: process.cwd(), createdAt: now, updatedAt: now, - resumeCursor: { sessionId: `session-${threadId}` }, } as unknown as ProviderSession; }); @@ -47,108 +42,60 @@ class FakeGeminiCliManager extends GeminiCliServerManager { }), ); - public interruptTurnImpl = vi.fn(async (): Promise => undefined); - public respondToRequestImpl = vi.fn(async (): Promise => undefined); - public respondToUserInputImpl = vi.fn(async (): Promise => undefined); - public readThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); - public rollbackThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); - public stopAllImpl = vi.fn(() => undefined); - override startSession(input: { threadId: ThreadId }): Promise { return this.startSessionImpl(input.threadId); } - override sendTurn(input: { threadId: ThreadId }): Promise { return this.sendTurnImpl(input.threadId); } - - override interruptTurn(_threadId: ThreadId): Promise { - return this.interruptTurnImpl(); - } - - override respondToRequest( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _decision: ProviderApprovalDecision, - ): Promise { - return this.respondToRequestImpl(); - } - - override respondToUserInput( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _answers: ProviderUserInputAnswers, - ): Promise { - return this.respondToUserInputImpl(); - } - - override readThread(threadId: ThreadId) { - return this.readThreadImpl(threadId); - } - - override rollbackThread(threadId: ThreadId) { - return this.rollbackThreadImpl(threadId); - } - override stopSession(_threadId: ThreadId): void {} - override listSessions(): ProviderSession[] { return []; } - override hasSession(_threadId: ThreadId): boolean { return false; } - - override stopAll(): void { - this.stopAllImpl(); - } + override stopAll(): void {} } -const manager = new FakeGeminiCliManager(); -const layer = it.layer( - makeGeminiCliAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), -); +const enabledConfig = Schema.decodeSync(GenericProviderSettings)({}); -layer("GeminiCliAdapterLive", (it) => { - it.effect("delegates session startup to the manager", () => +it.effect("GeminiCliAdapter delegates startSession", () => + Effect.scoped( Effect.gen(function* () { - manager.startSessionImpl.mockClear(); - const adapter = yield* GeminiCliAdapter; - + const manager = new FakeGeminiManager(); + const adapter = yield* makeGeminiCliAdapter(enabledConfig, { manager }); const session = yield* adapter.startSession({ threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); - assert.equal(session.provider, "geminiCli"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }), - ); + ), +); - it.effect("rejects attachments until Gemini CLI attachment wiring exists", () => +it.effect("GeminiCliAdapter rejects attachments", () => + Effect.scoped( Effect.gen(function* () { - const adapter = yield* GeminiCliAdapter; + const manager = new FakeGeminiManager(); + const adapter = yield* makeGeminiCliAdapter(enabledConfig, { manager }); const result = yield* adapter .sendTurn({ threadId: asThreadId("thread-attachments"), - input: "hello", - attachments: [{ id: "attachment-1" }] as never, + input: "hi", + attachments: [{ id: "x" }] as never, }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), - ); + ), +); - it.effect("forwards manager runtime events through the adapter stream", () => +it.effect("GeminiCliAdapter forwards runtime events through the stream", () => + Effect.scoped( Effect.gen(function* () { - const adapter = yield* GeminiCliAdapter; - + const manager = new FakeGeminiManager(); + const adapter = yield* makeGeminiCliAdapter(enabledConfig, { manager }); const event = { type: "content.delta", eventId: asEventId("evt-gemini-delta"), @@ -157,29 +104,24 @@ layer("GeminiCliAdapterLive", (it) => { threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("item-1"), - payload: { - streamKind: "assistant_text", - delta: "hello", - }, + payload: { streamKind: "assistant_text", delta: "hello" }, } as unknown as ProviderRuntimeEvent; - - // Emit first — the event is buffered in the unbounded queue via the - // listener that was registered during layer construction. manager.emit("event", event); - - // Now consume the head. Since the queue already has an item, this - // resolves immediately without a race condition. const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); - if (received._tag !== "Some") { - return; - } - assert.equal(received.value.type, "content.delta"); - if (received.value.type !== "content.delta") { - return; - } - assert.equal(received.value.payload.delta, "hello"); }), - ); -}); + ), +); + +it.effect("GeminiCliAdapter refuses startSession when disabled", () => + Effect.scoped( + Effect.gen(function* () { + const manager = new FakeGeminiManager(); + const adapter = yield* makeGeminiCliAdapter({ ...enabledConfig, enabled: false }, { manager }); + const result = yield* adapter + .startSession({ threadId: asThreadId("t"), runtimeMode: "full-access" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ), +); diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.ts index 1c14bf9743b..6c1ca9906b4 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.ts @@ -1,145 +1,145 @@ -import { type ProviderRuntimeEvent } from "@t3tools/contracts"; -import { Effect, Layer, Queue, Stream } from "effect"; +/** + * GeminiCliAdapter — per-instance adapter factory for the fork's Gemini CLI provider. + * + * Same pattern as `AmpAdapter`: wrap the existing Node-EventEmitter + * `GeminiCliServerManager` into a `ProviderAdapterShape` value bound to one + * `GenericProviderSettings` config. Multi-instance safe — each driver + * `create()` call constructs its own manager. + * + * @module provider/Layers/GeminiCliAdapter + */ +import { + type GenericProviderSettings, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { Effect, Queue, Stream } from "effect"; import { GeminiCliServerManager } from "../../geminiCliServerManager.ts"; -import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; -import { GeminiCliAdapter, type GeminiCliAdapterShape } from "../Services/GeminiCliAdapter.ts"; +import { + type ProviderAdapterError, + ProviderAdapterValidationError, +} from "../Errors.ts"; import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { type ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -const PROVIDER = "geminiCli" as const; +const PROVIDER = ProviderDriverKind.make("geminiCli"); const { toRequestError } = makeErrorHelpers(PROVIDER, { sessionNotFoundHints: ["unknown gemini cli session", "unknown session"], }); -export interface GeminiCliAdapterLiveOptions { +export interface GeminiCliAdapterOptions { + readonly instanceId?: ProviderInstanceId; readonly manager?: GeminiCliServerManager; readonly makeManager?: () => GeminiCliServerManager; } -export function makeGeminiCliAdapterLive(options: GeminiCliAdapterLiveOptions = {}) { - return Layer.effect( - GeminiCliAdapter, - Effect.gen(function* () { - const manager = options.manager ?? options.makeManager?.() ?? new GeminiCliServerManager(); - const runtimeEventQueue = yield* Queue.unbounded(); - const serverSettingsService = yield* ServerSettingsService; +export const makeGeminiCliAdapter = Effect.fn("makeGeminiCliAdapter")(function* ( + config: GenericProviderSettings, + options: GeminiCliAdapterOptions = {}, +) { + const manager = options.manager ?? options.makeManager?.() ?? new GeminiCliServerManager(); + manager.binaryPath = config.binaryPath.trim() || undefined; - yield* Effect.acquireRelease( - Effect.sync(() => { - const listener = (event: ProviderRuntimeEvent) => { - Effect.runFork(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid)); - }; - manager.on("event", listener); - return listener; - }), - (listener) => - Effect.gen(function* () { - manager.off("event", listener); - manager.stopAll(); - yield* Queue.shutdown(runtimeEventQueue); - }), - ); + const runtimeEventQueue = yield* Queue.unbounded(); - const service = { - provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), - startSession: (input) => - Effect.gen(function* () { - const providerSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((s) => s.providers.geminiCli), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - if (!providerSettings.enabled) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "Gemini CLI provider is disabled in server settings.", - }); - } - manager.binaryPath = providerSettings.binaryPath.trim() || undefined; - return yield* Effect.tryPromise({ - try: () => manager.startSession(input), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), - }); - }), - sendTurn: (input) => { - if ((input.attachments?.length ?? 0) > 0) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Gemini CLI attachments are not supported yet.", - }), - ); - } + yield* Effect.acquireRelease( + Effect.sync(() => { + const listener = (event: ProviderRuntimeEvent) => { + Effect.runFork(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid)); + }; + manager.on("event", listener); + return listener; + }), + (listener) => + Effect.gen(function* () { + manager.off("event", listener); + manager.stopAll(); + yield* Queue.shutdown(runtimeEventQueue); + }), + ); - return Effect.tryPromise({ - try: () => manager.sendTurn(input), - catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + const adapter: ProviderAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession: (input) => + Effect.gen(function* () { + if (!config.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Gemini CLI provider is disabled.", }); - }, - interruptTurn: (threadId) => - Effect.tryPromise({ - try: () => manager.interruptTurn(threadId), - catch: (cause) => toRequestError(threadId, "session/interrupt", cause), + } + manager.binaryPath = config.binaryPath.trim() || undefined; + return yield* Effect.tryPromise({ + try: () => manager.startSession(input), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); + }), + sendTurn: (input) => { + if ((input.attachments?.length ?? 0) > 0) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Gemini CLI attachments are not supported yet.", }), - respondToRequest: (threadId, requestId, decision) => - Effect.tryPromise({ - try: () => manager.respondToRequest(threadId, requestId, decision), - catch: (cause) => toRequestError(threadId, "permission/reply", cause), + ); + } + return Effect.tryPromise({ + try: () => manager.sendTurn(input), + catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + }); + }, + interruptTurn: (threadId) => + Effect.tryPromise({ + try: () => manager.interruptTurn(threadId), + catch: (cause) => toRequestError(threadId, "session/interrupt", cause), + }), + respondToRequest: (threadId, requestId, decision) => + Effect.tryPromise({ + try: () => manager.respondToRequest(threadId, requestId, decision), + catch: (cause) => toRequestError(threadId, "permission/reply", cause), + }), + respondToUserInput: (threadId, requestId, answers) => + Effect.tryPromise({ + try: () => manager.respondToUserInput(threadId, requestId, answers), + catch: (cause) => toRequestError(threadId, "question/reply", cause), + }), + stopSession: (threadId) => + Effect.sync(() => { + manager.stopSession(threadId); + }), + listSessions: () => Effect.sync(() => manager.listSessions()), + hasSession: (threadId) => Effect.sync(() => manager.hasSession(threadId)), + readThread: (threadId) => + Effect.tryPromise({ + try: () => manager.readThread(threadId), + catch: (cause) => toRequestError(threadId, "session/messages", cause), + }), + rollbackThread: (threadId, numTurns) => { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", }), - respondToUserInput: (threadId, requestId, answers) => - Effect.tryPromise({ - try: () => manager.respondToUserInput(threadId, requestId, answers), - catch: (cause) => toRequestError(threadId, "question/reply", cause), - }), - stopSession: (threadId) => - Effect.sync(() => { - manager.stopSession(threadId); - }), - listSessions: () => Effect.sync(() => manager.listSessions()), - hasSession: (threadId) => Effect.sync(() => manager.hasSession(threadId)), - readThread: (threadId) => - Effect.tryPromise({ - try: () => manager.readThread(threadId), - catch: (cause) => toRequestError(threadId, "session/messages", cause), - }), - rollbackThread: (threadId, numTurns) => { - if (!Number.isInteger(numTurns) || numTurns < 1) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }), - ); - } + ); + } + return Effect.tryPromise({ + try: () => manager.rollbackThread(threadId), + catch: (cause) => toRequestError(threadId, "session/revert", cause), + }); + }, + stopAll: () => + Effect.sync(() => { + manager.stopAll(); + }), + streamEvents: Stream.fromQueue(runtimeEventQueue), + }; - return Effect.tryPromise({ - try: () => manager.rollbackThread(threadId), - catch: (cause) => toRequestError(threadId, "session/revert", cause), - }); - }, - stopAll: () => - Effect.sync(() => { - manager.stopAll(); - }), - streamEvents: Stream.fromQueue(runtimeEventQueue), - } satisfies GeminiCliAdapterShape; - - return service; - }), - ); -} - -export const GeminiCliAdapterLive = makeGeminiCliAdapterLive(); + return adapter; +}); diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 227fd36a04b..9188a1f7973 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -1,24 +1,20 @@ import assert from "node:assert/strict"; import { - ApprovalRequestId, EventId, + GenericProviderSettings, RuntimeItemId, ThreadId, TurnId, - type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, type ProviderTurnStartResult, - type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Layer, Stream } from "effect"; +import { Effect, Schema, Stream } from "effect"; import { KiloServerManager } from "../../kiloServerManager.ts"; -import { KiloAdapter } from "../Services/KiloAdapter.ts"; -import { makeKiloAdapterLive } from "./KiloAdapter.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeKiloAdapter } from "./KiloAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -36,7 +32,6 @@ class FakeKiloManager extends KiloServerManager { cwd: process.cwd(), createdAt: now, updatedAt: now, - resumeCursor: { sessionId: `session-${threadId}` }, } as unknown as ProviderSession; }); @@ -47,139 +42,86 @@ class FakeKiloManager extends KiloServerManager { }), ); - public interruptTurnImpl = vi.fn(async (): Promise => undefined); - public respondToRequestImpl = vi.fn(async (): Promise => undefined); - public respondToUserInputImpl = vi.fn(async (): Promise => undefined); - public readThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); - public rollbackThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); - public stopAllImpl = vi.fn(() => undefined); - override startSession(input: { threadId: ThreadId }): Promise { return this.startSessionImpl(input.threadId); } - override sendTurn(input: { threadId: ThreadId }): Promise { return this.sendTurnImpl(input.threadId); } - - override interruptTurn(_threadId: ThreadId): Promise { - return this.interruptTurnImpl(); - } - - override respondToRequest( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _decision: ProviderApprovalDecision, - ): Promise { - return this.respondToRequestImpl(); - } - - override respondToUserInput( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _answers: ProviderUserInputAnswers, - ): Promise { - return this.respondToUserInputImpl(); - } - - override readThread(threadId: ThreadId) { - return this.readThreadImpl(threadId); - } - - override rollbackThread(threadId: ThreadId) { - return this.rollbackThreadImpl(threadId); - } - override stopSession(_threadId: ThreadId): void {} - override listSessions(): ProviderSession[] { return []; } - override hasSession(_threadId: ThreadId): boolean { return false; } - - override stopAll(): void { - this.stopAllImpl(); - } + override stopAll(): void {} } -const manager = new FakeKiloManager(); -const layer = it.layer( - makeKiloAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), -); +const enabledConfig = Schema.decodeSync(GenericProviderSettings)({}); -layer("KiloAdapterLive", (it) => { - it.effect("delegates session startup to the manager", () => +it.effect("KiloAdapter delegates startSession and stamps binaryPath", () => + Effect.scoped( Effect.gen(function* () { - manager.startSessionImpl.mockClear(); - const adapter = yield* KiloAdapter; - + const manager = new FakeKiloManager(); + const adapter = yield* makeKiloAdapter(enabledConfig, { manager }); const session = yield* adapter.startSession({ threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); - assert.equal(session.provider, "kilo"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }), - ); + ), +); - it.effect("rejects attachments until Kilo attachment wiring exists", () => +it.effect("KiloAdapter rejects attachments", () => + Effect.scoped( Effect.gen(function* () { - const adapter = yield* KiloAdapter; + const manager = new FakeKiloManager(); + const adapter = yield* makeKiloAdapter(enabledConfig, { manager }); const result = yield* adapter .sendTurn({ - threadId: asThreadId("thread-attachments"), - input: "hello", - attachments: [{ id: "attachment-1" }] as never, + threadId: asThreadId("t"), + input: "hi", + attachments: [{ id: "x" }] as never, }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), - ); + ), +); - it.effect("forwards manager runtime events through the adapter stream", () => +it.effect("KiloAdapter forwards events through the stream", () => + Effect.scoped( Effect.gen(function* () { - const adapter = yield* KiloAdapter; - + const manager = new FakeKiloManager(); + const adapter = yield* makeKiloAdapter(enabledConfig, { manager }); const event = { type: "content.delta", - eventId: asEventId("evt-kilo-delta"), + eventId: asEventId("evt-kilo"), provider: "kilo", createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("item-1"), - payload: { - streamKind: "assistant_text", - delta: "hello", - }, + payload: { streamKind: "assistant_text", delta: "x" }, } as unknown as ProviderRuntimeEvent; - - // Emit first — the event is buffered in the unbounded queue via the - // listener that was registered during layer construction. manager.emit("event", event); - - // Now consume the head. Since the queue already has an item, this - // resolves immediately without a race condition. const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); - if (received._tag !== "Some") { - return; - } - assert.equal(received.value.type, "content.delta"); - if (received.value.type !== "content.delta") { - return; - } - assert.equal(received.value.payload.delta, "hello"); }), - ); -}); + ), +); + +it.effect("KiloAdapter refuses startSession when disabled", () => + Effect.scoped( + Effect.gen(function* () { + const manager = new FakeKiloManager(); + const adapter = yield* makeKiloAdapter({ ...enabledConfig, enabled: false }, { manager }); + const result = yield* adapter + .startSession({ threadId: asThreadId("t"), runtimeMode: "full-access" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ), +); diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index 88564676879..c746e765eb1 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -1,145 +1,142 @@ -import { type ProviderRuntimeEvent } from "@t3tools/contracts"; -import { Effect, Layer, Queue, Stream } from "effect"; +/** + * KiloAdapter — per-instance adapter factory for the fork's Kilo provider. + * + * Same captured-closure pattern as `AmpAdapter` / `GeminiCliAdapter`. Wraps + * `KiloServerManager`, which talks to a Kilo server child process per + * session. + * + * @module provider/Layers/KiloAdapter + */ +import { + type GenericProviderSettings, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { Effect, Queue, Stream } from "effect"; import { KiloServerManager } from "../../kiloServerManager.ts"; import type { KiloSessionStartInput } from "../../kilo/types.ts"; -import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; -import { KiloAdapter, type KiloAdapterShape } from "../Services/KiloAdapter.ts"; +import { + type ProviderAdapterError, + ProviderAdapterValidationError, +} from "../Errors.ts"; import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { type ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -const PROVIDER = "kilo" as const; +const PROVIDER = ProviderDriverKind.make("kilo"); const { toRequestError } = makeErrorHelpers(PROVIDER); -export interface KiloAdapterLiveOptions { +export interface KiloAdapterOptions { + readonly instanceId?: ProviderInstanceId; readonly manager?: KiloServerManager; readonly makeManager?: () => KiloServerManager; } -export function makeKiloAdapterLive(options: KiloAdapterLiveOptions = {}) { - return Layer.effect( - KiloAdapter, - Effect.gen(function* () { - const manager = options.manager ?? options.makeManager?.() ?? new KiloServerManager(); - const runtimeEventQueue = yield* Queue.unbounded(); - const serverSettingsService = yield* ServerSettingsService; +export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( + config: GenericProviderSettings, + options: KiloAdapterOptions = {}, +) { + const manager = options.manager ?? options.makeManager?.() ?? new KiloServerManager(); + const runtimeEventQueue = yield* Queue.unbounded(); - yield* Effect.acquireRelease( - Effect.sync(() => { - const listener = (event: ProviderRuntimeEvent) => { - Effect.runFork(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid)); - }; - manager.on("event", listener); - return listener; - }), - (listener) => - Effect.gen(function* () { - manager.off("event", listener); - manager.stopAll(); - yield* Queue.shutdown(runtimeEventQueue); - }), - ); - - const service = { - provider: PROVIDER, - capabilities: getProviderCapabilities(PROVIDER), - startSession: (input) => - Effect.gen(function* () { - const providerSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((s) => s.providers.kilo), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - if (!providerSettings.enabled) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "Kilo provider is disabled in server settings.", - }); - } - const binaryPath = providerSettings.binaryPath.trim() || "kilo"; - return yield* Effect.tryPromise({ - try: () => - manager.startSession({ ...input, kilo: { binaryPath } } as KiloSessionStartInput), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), - }); - }), - sendTurn: (input) => { - if ((input.attachments?.length ?? 0) > 0) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Kilo attachments are not wired yet.", - }), - ); - } + yield* Effect.acquireRelease( + Effect.sync(() => { + const listener = (event: ProviderRuntimeEvent) => { + Effect.runFork(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid)); + }; + manager.on("event", listener); + return listener; + }), + (listener) => + Effect.gen(function* () { + manager.off("event", listener); + manager.stopAll(); + yield* Queue.shutdown(runtimeEventQueue); + }), + ); - return Effect.tryPromise({ - try: () => manager.sendTurn(input), - catch: (cause) => toRequestError(input.threadId, "session/prompt_async", cause), + const adapter: ProviderAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession: (input) => + Effect.gen(function* () { + if (!config.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Kilo provider is disabled.", }); - }, - interruptTurn: (threadId) => - Effect.tryPromise({ - try: () => manager.interruptTurn(threadId), - catch: (cause) => toRequestError(threadId, "session/abort", cause), - }), - respondToRequest: (threadId, requestId, decision) => - Effect.tryPromise({ - try: () => manager.respondToRequest(threadId, requestId, decision), - catch: (cause) => toRequestError(threadId, "permission/reply", cause), - }), - respondToUserInput: (threadId, requestId, answers) => - Effect.tryPromise({ - try: () => manager.respondToUserInput(threadId, requestId, answers), - catch: (cause) => toRequestError(threadId, "question/reply", cause), - }), - stopSession: (threadId) => - Effect.sync(() => { - manager.stopSession(threadId); + } + const binaryPath = config.binaryPath.trim() || "kilo"; + return yield* Effect.tryPromise({ + try: () => + manager.startSession({ ...input, kilo: { binaryPath } } as KiloSessionStartInput), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); + }), + sendTurn: (input) => { + if ((input.attachments?.length ?? 0) > 0) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Kilo attachments are not wired yet.", }), - listSessions: () => Effect.sync(() => manager.listSessions()), - hasSession: (threadId) => Effect.sync(() => manager.hasSession(threadId)), - readThread: (threadId) => - Effect.tryPromise({ - try: () => manager.readThread(threadId), - catch: (cause) => toRequestError(threadId, "session/messages", cause), + ); + } + return Effect.tryPromise({ + try: () => manager.sendTurn(input), + catch: (cause) => toRequestError(input.threadId, "session/prompt_async", cause), + }); + }, + interruptTurn: (threadId) => + Effect.tryPromise({ + try: () => manager.interruptTurn(threadId), + catch: (cause) => toRequestError(threadId, "session/abort", cause), + }), + respondToRequest: (threadId, requestId, decision) => + Effect.tryPromise({ + try: () => manager.respondToRequest(threadId, requestId, decision), + catch: (cause) => toRequestError(threadId, "permission/reply", cause), + }), + respondToUserInput: (threadId, requestId, answers) => + Effect.tryPromise({ + try: () => manager.respondToUserInput(threadId, requestId, answers), + catch: (cause) => toRequestError(threadId, "question/reply", cause), + }), + stopSession: (threadId) => + Effect.sync(() => { + manager.stopSession(threadId); + }), + listSessions: () => Effect.sync(() => manager.listSessions()), + hasSession: (threadId) => Effect.sync(() => manager.hasSession(threadId)), + readThread: (threadId) => + Effect.tryPromise({ + try: () => manager.readThread(threadId), + catch: (cause) => toRequestError(threadId, "session/messages", cause), + }), + rollbackThread: (threadId, numTurns) => { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", }), - rollbackThread: (threadId, numTurns) => { - if (!Number.isInteger(numTurns) || numTurns < 1) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }), - ); - } - - return Effect.tryPromise({ - try: () => manager.rollbackThread(threadId), - catch: (cause) => toRequestError(threadId, "session/revert", cause), - }); - }, - stopAll: () => - Effect.sync(() => { - manager.stopAll(); - }), - streamEvents: Stream.fromQueue(runtimeEventQueue), - } satisfies KiloAdapterShape; - - return service; - }), - ); -} + ); + } + return Effect.tryPromise({ + try: () => manager.rollbackThread(threadId), + catch: (cause) => toRequestError(threadId, "session/revert", cause), + }); + }, + stopAll: () => + Effect.sync(() => { + manager.stopAll(); + }), + streamEvents: Stream.fromQueue(runtimeEventQueue), + }; -export const KiloAdapterLive = makeKiloAdapterLive(); + return adapter; +}); 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 9412146036b..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,12 +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, @@ -25,8 +26,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.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,955 +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 - ? input.modelSelection.options?.agent - : undefined; - const variant = - input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.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: { + message: "OpenCode session started", + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), + 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: getProviderCapabilities(PROVIDER), - 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 ffce7084342..7abe0be9816 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -2,24 +2,36 @@ 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, + versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, + closeCalls: 0, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -27,7 +39,9 @@ const runtimeMock = { }, reset() { 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[], @@ -42,10 +56,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 @@ -56,7 +79,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: runtimeMock.state.runVersionError, }), ) - : Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }), + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: () => ({}) as unknown as ReturnType, loadOpenCodeInventory: () => @@ -75,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); @@ -99,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); @@ -140,38 +168,49 @@ 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); + const variantDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "variant" && descriptor.type === "select", + ); + assert.ok(variantDescriptor && variantDescriptor.type === "select"); assert.equal( - model.capabilities?.variantOptions?.find((option) => option.isDefault)?.value, + 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( - model.capabilities?.agentOptions?.find((option) => option.isDefault)?.value, + agentDescriptor.options.find((option) => option.isDefault === true)?.id, "build", ); }), ); + + it.effect("closes the local OpenCode server scope after provider refresh", () => + Effect.gen(function* () { + 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); @@ -187,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 5e51eae0282..c7487d7d526 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,20 +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 { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { compareCliVersions } from "../cliVersion.ts"; import { OpenCodeRuntime, openCodeRuntimeErrorDetail, @@ -22,7 +23,12 @@ 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, +} as const; +const MINIMUM_OPENCODE_VERSION = "1.14.19"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause: unknown; @@ -156,13 +162,9 @@ function inferDefaultAgent(agents: ReadonlyArray): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); function openCodeCapabilitiesForModel(input: { readonly providerID: string; @@ -171,27 +173,46 @@ function openCodeCapabilitiesForModel(input: { }): ModelCapabilities { const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions: ModelCapabilities["variantOptions"] = variantValues.map((value) => - Object.assign( - { value, label: titleCaseSlug(value) }, - defaultVariant === value ? { isDefault: true } : {}, - ), + const variantOptions = variantValues.map((value) => + defaultVariant === value + ? { id: value, label: titleCaseSlug(value), isDefault: true as const } + : { id: value, label: titleCaseSlug(value) }, ); const primaryAgents = input.agents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ); const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions: ModelCapabilities["agentOptions"] = primaryAgents.map((agent) => - Object.assign( - { value: agent.name, label: titleCaseSlug(agent.name) }, - defaultAgent === agent.name ? { isDefault: true } : {}, - ), + const agentOptions = primaryAgents.map((agent) => + defaultAgent === agent.name + ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } + : { id: agent.name, label: titleCaseSlug(agent.name) }, ); - return { - ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ...(variantOptions.length > 0 ? { variantOptions } : {}), - ...(agentOptions.length > 0 ? { agentOptions } : {}), - }; + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Variant", + type: "select" as const, + options: variantOptions, + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); } function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { @@ -204,10 +225,16 @@ 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( [], @@ -232,7 +261,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server if (!openCodeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models, @@ -250,7 +279,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server } return buildServerProvider({ - provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, @@ -264,177 +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, - 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, - 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; - } - - 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, - enabled: true, + presentation: OPENCODE_PRESENTATION, + 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/ProviderAdapterConformance.test.ts b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts deleted file mode 100644 index 24053e5844e..00000000000 --- a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Layer, Option } from "effect"; -import { describe, expect, it } from "vitest"; - -import { AmpServerManager } from "../../ampServerManager.ts"; -import { GeminiCliServerManager } from "../../geminiCliServerManager.ts"; -import { ServerConfig } from "../../config.ts"; -import { makeAmpAdapterLive } from "./AmpAdapter.ts"; -import { makeClaudeAdapterLive } from "./ClaudeAdapter.ts"; -import { makeCodexAdapterLive } from "./CodexAdapter.ts"; -import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; -import { makeCursorAdapterLive } from "./CursorAdapter.ts"; -import { makeGeminiCliAdapterLive } from "./GeminiCliAdapter.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { - getProviderCapabilities, - validateProviderAdapterConformance, -} from "../Services/ProviderAdapter.ts"; -import { AmpAdapter } from "../Services/AmpAdapter.ts"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; -import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { GeminiCliAdapter } from "../Services/GeminiCliAdapter.ts"; - -const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { - upsert: () => Effect.void, - getProvider: () => - Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in conformance tests")), - getBinding: () => Effect.succeed(Option.none()), - listThreadIds: () => Effect.succeed([]), - listBindings: () => Effect.succeed([]), -}); - -const codexLayer = makeCodexAdapterLive().pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), -); - -const copilotLayer = makeCopilotAdapterLive({ - clientFactory: () => - ({ - start: async () => undefined, - listModels: async () => [], - createSession: async () => { - throw new Error("createSession should not be called in conformance tests"); - }, - resumeSession: async () => { - throw new Error("resumeSession should not be called in conformance tests"); - }, - stop: async () => [], - }) as never, -}).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), -); - -const claudeLayer = makeClaudeAdapterLive({ - createQuery: () => - ({ - [Symbol.asyncIterator]: async function* () { - yield* [] as never[]; - }, - interrupt: async () => undefined, - setModel: async () => undefined, - setPermissionMode: async () => undefined, - setMaxThinkingTokens: async () => undefined, - close: () => undefined, - }) as never, -}).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), -); - -const cursorLayer = makeCursorAdapterLive().pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), -); - -const geminiLayer = makeGeminiCliAdapterLive({ - manager: new GeminiCliServerManager(), -}).pipe(Layer.provideMerge(ServerSettingsService.layerTest())); - -const ampLayer = makeAmpAdapterLive({ - manager: new AmpServerManager(), -}).pipe(Layer.provideMerge(ServerSettingsService.layerTest())); - -describe("provider adapter conformance", () => { - const cases = [ - { - provider: "codex" as const, - load: () => - Effect.runPromise( - Effect.gen(function* () { - return yield* CodexAdapter; - }).pipe(Effect.provide(codexLayer)), - ), - }, - { - provider: "copilot" as const, - load: () => - Effect.runPromise( - Effect.gen(function* () { - return yield* CopilotAdapter; - }).pipe(Effect.provide(copilotLayer)), - ), - }, - { - provider: "claudeAgent" as const, - load: () => - Effect.runPromise( - Effect.gen(function* () { - return yield* ClaudeAdapter; - }).pipe(Effect.provide(claudeLayer)), - ), - }, - { - provider: "cursor" as const, - load: () => - Effect.runPromise( - Effect.gen(function* () { - return yield* CursorAdapter; - }).pipe(Effect.provide(cursorLayer)), - ), - }, - { - provider: "geminiCli" as const, - load: () => - Effect.runPromise( - Effect.gen(function* () { - return yield* GeminiCliAdapter; - }).pipe(Effect.provide(geminiLayer)), - ), - }, - { - provider: "amp" as const, - load: () => - Effect.runPromise( - Effect.gen(function* () { - return yield* AmpAdapter; - }).pipe(Effect.provide(ampLayer)), - ), - }, - ]; - - it.each(cases)("declares the shared harness matrix for $provider", async ({ provider, load }) => { - const adapter = await load(); - - expect(validateProviderAdapterConformance(adapter)).toEqual([]); - expect(adapter.provider).toBe(provider); - expect(adapter.capabilities).toEqual(getProviderCapabilities(provider)); - }); -}); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 05f52a73b38..05735f3a50d 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,25 +1,31 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; -import type { ProviderKind } from "@t3tools/contracts"; -import { Effect, Layer, Stream } from "effect"; - -import { ProviderUnsupportedError } from "../Errors.ts"; -import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; -import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import { GeminiCliAdapter, type GeminiCliAdapterShape } from "../Services/GeminiCliAdapter.ts"; -import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { AmpAdapter, type AmpAdapterShape } from "../Services/AmpAdapter.ts"; -import { KiloAdapter, type KiloAdapterShape } from "../Services/KiloAdapter.ts"; -import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; + +import { Effect, Layer, PubSub, Stream } from "effect"; + +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import type { CursorAdapterShape } from "../Services/CursorAdapter.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 * 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", - capabilities: getProviderCapabilities("codex"), + provider: CODEX_DRIVER, + capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -35,8 +41,8 @@ const fakeCodexAdapter: CodexAdapterShape = { }; const fakeClaudeAdapter: ClaudeAdapterShape = { - provider: "claudeAgent", - capabilities: getProviderCapabilities("claudeAgent"), + provider: CLAUDE_AGENT_DRIVER, + capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -51,9 +57,9 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const fakeCopilotAdapter: CopilotAdapterShape = { - provider: "copilot", - capabilities: getProviderCapabilities("copilot"), +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { + provider: OPENCODE_DRIVER, + capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -69,8 +75,8 @@ const fakeCopilotAdapter: CopilotAdapterShape = { }; const fakeCursorAdapter: CursorAdapterShape = { - provider: "cursor", - capabilities: getProviderCapabilities("cursor"), + provider: CURSOR_DRIVER, + capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -85,133 +91,94 @@ const fakeCursorAdapter: CursorAdapterShape = { streamEvents: Stream.empty, }; -const fakeOpenCodeAdapter: OpenCodeAdapterShape = { - provider: "opencode", - capabilities: getProviderCapabilities("opencode"), - startSession: vi.fn(), - sendTurn: vi.fn(), - interruptTurn: vi.fn(), - respondToRequest: vi.fn(), - respondToUserInput: vi.fn(), - stopSession: vi.fn(), - listSessions: vi.fn(), - hasSession: vi.fn(), - readThread: vi.fn(), - rollbackThread: vi.fn(), - stopAll: vi.fn(), - streamEvents: Stream.empty, +// 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 fakeGeminiCliAdapter: GeminiCliAdapterShape = { - provider: "geminiCli", - capabilities: getProviderCapabilities("geminiCli"), - startSession: vi.fn(), - sendTurn: vi.fn(), - interruptTurn: vi.fn(), - respondToRequest: vi.fn(), - respondToUserInput: vi.fn(), - stopSession: vi.fn(), - listSessions: vi.fn(), - hasSession: vi.fn(), - readThread: vi.fn(), - rollbackThread: vi.fn(), - stopAll: vi.fn(), - streamEvents: Stream.empty, -}; +const fakeInstances: ReadonlyArray = [ + makeFakeInstance("codex", fakeCodexAdapter), + makeFakeInstance("claudeAgent", fakeClaudeAdapter), + makeFakeInstance("opencode", fakeOpenCodeAdapter), + makeFakeInstance("cursor", fakeCursorAdapter), +]; -const fakeAmpAdapter: AmpAdapterShape = { - provider: "amp", - capabilities: getProviderCapabilities("amp"), - startSession: vi.fn(), - sendTurn: vi.fn(), - interruptTurn: vi.fn(), - respondToRequest: vi.fn(), - respondToUserInput: vi.fn(), - stopSession: vi.fn(), - listSessions: vi.fn(), - hasSession: vi.fn(), - readThread: vi.fn(), - rollbackThread: vi.fn(), - stopAll: vi.fn(), - streamEvents: Stream.empty, -}; - -const fakeKiloAdapter: KiloAdapterShape = { - provider: "kilo", - capabilities: getProviderCapabilities("kilo"), - startSession: vi.fn(), - sendTurn: vi.fn(), - interruptTurn: vi.fn(), - respondToRequest: vi.fn(), - respondToUserInput: vi.fn(), - stopSession: vi.fn(), - listSessions: vi.fn(), - hasSession: vi.fn(), - readThread: vi.fn(), - rollbackThread: vi.fn(), - stopAll: vi.fn(), - streamEvents: Stream.empty, -}; +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 = it.layer( - ProviderAdapterRegistryLive.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(CodexAdapter, fakeCodexAdapter), - Layer.succeed(CopilotAdapter, fakeCopilotAdapter), - Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), - Layer.succeed(CursorAdapter, fakeCursorAdapter), - Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), - Layer.succeed(GeminiCliAdapter, fakeGeminiCliAdapter), - Layer.succeed(AmpAdapter, fakeAmpAdapter), - Layer.succeed(KiloAdapter, fakeKiloAdapter), - ), - ), - Layer.provideMerge(NodeServices.layer), - ), +const layer = Layer.mergeAll( + Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer), + NodeServices.layer, ); -layer("ProviderAdapterRegistryLive", (it) => { - it.effect("resolves registered provider adapters", () => +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 copilot = yield* registry.getByProvider("copilot"); - const claude = yield* registry.getByProvider("claudeAgent"); - const cursor = yield* registry.getByProvider("cursor"); - const opencode = yield* registry.getByProvider("opencode"); - const geminiCli = yield* registry.getByProvider("geminiCli"); - const amp = yield* registry.getByProvider("amp"); - const kilo = yield* registry.getByProvider("kilo"); - - assert.equal(codex, fakeCodexAdapter); - assert.equal(copilot, fakeCopilotAdapter); - assert.equal(claude, fakeClaudeAdapter); - assert.equal(cursor, fakeCursorAdapter); - assert.equal(opencode, fakeOpenCodeAdapter); - assert.equal(geminiCli, fakeGeminiCliAdapter); - assert.equal(amp, fakeAmpAdapter); - assert.equal(kilo, fakeKiloAdapter); + const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER); - const providers = yield* registry.listProviders(); - assert.deepEqual(providers, [ - "codex", - "claudeAgent", - "copilot", - "cursor", - "geminiCli", - "opencode", - "amp", - "kilo", + const adapter = yield* registry.getByInstance(claudeInstanceId); + assert.strictEqual(adapter, fakeClaudeAdapter); + + 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), ]); - }), - ); - 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 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 4295ef43937..f2eeaa1aae8 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -1,65 +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 { AmpAdapter } from "../Services/AmpAdapter.ts"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; -import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { GeminiCliAdapter } from "../Services/GeminiCliAdapter.ts"; -import { KiloAdapter } from "../Services/KiloAdapter.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; -export interface ProviderAdapterRegistryLiveOptions { - readonly adapters?: ReadonlyArray>; -} +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 makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* ( - options?: ProviderAdapterRegistryLiveOptions, -) { - const adapters = - options?.adapters !== undefined - ? options.adapters - : [ - yield* CodexAdapter, - yield* ClaudeAdapter, - yield* CopilotAdapter, - yield* CursorAdapter, - yield* GeminiCliAdapter, - yield* OpenCodeAdapter, - yield* AmpAdapter, - yield* KiloAdapter, - ]; - const byProvider = new Map(adapters.map((adapter) => [adapter.provider, 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 getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { - const adapter = byProvider.get(provider); - if (!adapter) { - return Effect.fail(new ProviderUnsupportedError({ provider })); - } - return Effect.succeed(adapter); - }; + 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; }); @@ -67,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 e29d1ae1957..8c56f06a8b9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,19 +1,28 @@ 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"; import { ChildProcessSpawner } from "effect/unstable/process"; 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, @@ -21,14 +30,67 @@ 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 ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, + }; +} + +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), @@ -46,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, @@ -57,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, @@ -66,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))); }), ); @@ -88,16 +184,15 @@ function failingSpawnerLayer(description: string) { ); } -const codexModelCapabilities = { - reasoningEffortLevels: [ - { value: "high", label: "High", isDefault: true }, - { value: "low", label: "Low" }, +const codexModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + { id: "low", label: "Low" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} satisfies NonNullable; +}) satisfies NonNullable; function makeCodexProbeSnapshot( input: Partial = {}, @@ -151,638 +246,1078 @@ function makeMutableServerSettingsService( }); } -it.layer( - Layer.mergeAll( - NodeServices.layer, - ServerSettingsService.layerTest(), - ServerConfig.layerTest(process.cwd(), { prefix: "provider-registry-test-" }).pipe( - Layer.provide(NodeServices.layer), - ), - ), -)("ProviderRegistry", (it) => { - describe("checkCodexProviderStatus", () => { - it.effect("uses the app-server account and model list for provider status", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( + "ProviderRegistry", + (it) => { + describe("checkCodexProviderStatus", () => { + it.effect("uses the app-server account and model list for provider status", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ), + ); + 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", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }), + ); + + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, }, - ], - }), - ), - ); - - 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.deepStrictEqual(status.models, [ + }), + ), + ); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }), + ); + + it.effect( + "returns ready with unknown auth when app-server does not require OpenAI auth", + () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), + ); + + it.effect("returns an api key label for codex api key auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "OpenAI API Key"); + }), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: "codex app-server", + cause: new Error("spawn codex ENOENT"), + }), + ), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI (`codex`) is not installed or not on PATH.", + ); + }), + ); + }); + + describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ { - slug: "gpt-live-codex", - name: "GPT Live Codex", - isCustom: false, - capabilities: codexModelCapabilities, + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], }, - ]); - assert.deepStrictEqual(status.skills, [ { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], }, - ]); - }), - ); - - it.effect("returns unauthenticated when app-server requires OpenAI auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: true, - }, - }), - ), - ); - - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }), - ); - - it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "unknown"); - }), - ); - - it.effect("returns an api key label for codex api key auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "apiKey" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "OpenAI API Key"); - }), - ); - - it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(() => - Effect.fail( - new CodexErrors.CodexAppServerSpawnError({ - command: "codex app-server", - cause: new Error("spawn codex ENOENT"), - }), - ), - ); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); - }), - ); - }); + ] as const satisfies ReadonlyArray; - describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ - { - provider: "codex", + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + }); + + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], slashCommands: [], skills: [], - }, - { - provider: "claudeAgent", - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; + } satisfies ServerProvider; - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); - it.skip("ignores checkedAt-only changes when comparing provider snapshots", () => { - const previousProviders = [ - { - provider: "codex", + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - message: "Ready", - models: [], + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], slashCommands: [], skills: [], - }, - ] as const satisfies ReadonlyArray; - const nextProviders = [ - { - ...previousProviders[0], - checkedAt: "2026-03-25T00:01:00.000Z", - }, - ] as const satisfies ReadonlyArray; - - assert.strictEqual(haveProvidersChanged(previousProviders, nextProviders), false); - }); + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), + }, + ], + } satisfies ServerProvider; - it("preserves previously discovered provider models when a refresh returns none", () => { - const previousProvider = { - provider: "cursor", - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + 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", }, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); + 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("fills missing capabilities from the previous provider snapshot", () => { - const previousProvider = { - provider: "cursor", - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + 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}`, }, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + displayName: undefined, + enabled: true, + snapshot: { + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, }, - }, - ], - } satisfies ServerProvider; + 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(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); + 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* () { + 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( + 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), + ); - it.effect("probes enabled providers in the background during registry startup", () => - Effect.gen(function* () { - let spawnCount = 0; - const serverSettings = yield* makeMutableServerSettingsService( - Schema.decodeSync(ServerSettings)( - deepMerge(DEFAULT_SERVER_SETTINGS, { + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + // 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: false }, - cursor: { enabled: false }, + codex: { enabled: true, binaryPath: secondMissing }, }, - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - 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 }; + }); + + // 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 attempts = 0; attempts < 60; attempts += 1) { + const providers = yield* registry.getProviders; + const codex = providers.find((provider) => provider.instanceId === "codex"); + if (codex !== undefined && codex.checkedAt !== initialCheckedAt) { + return providers; + } + yield* Effect.sleep("50 millis"); } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - assert.strictEqual(spawnCount > 0, true); - const refreshed = yield* Effect.gen(function* () { - for (let remainingAttempts = 50; remainingAttempts > 0; remainingAttempts -= 1) { + return yield* registry.getProviders; + }); + + 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)); + }), + ); + + it.effect( + "keeps cursor disabled and skips probing when the provider setting is disabled", + () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + codex: { + enabled: false, + }, + cursor: { + enabled: false, + }, + }, + }), + ), + ); + let cursorSpawned = 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( + 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") { + cursorSpawned = true; + } + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: `${command} 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}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; const providers = yield* registry.getProviders; - const claudeProvider = providers.find( - (provider) => provider.provider === "claudeAgent", + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), ); - if (claudeProvider?.status === "ready") { - return providers; - } - yield* Effect.sleep("10 millis"); - } - return yield* registry.getProviders; - }); - assert.strictEqual( - refreshed.find((provider) => provider.provider === "claudeAgent")?.status, - "ready", + + assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "amp", + "claudeAgent", + "codex", + "copilot", + "cursor", + "geminiCli", + "kilo", + "opencode", + ]); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual( + cursorProvider?.message, + "Cursor is disabled in T3 Code settings.", + ); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("keeps cursor disabled and skips probing when the provider setting is disabled", () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - Schema.decodeSync(ServerSettings)( - deepMerge(DEFAULT_SERVER_SETTINGS, { - providers: { - codex: { - enabled: false, - }, - cursor: { - enabled: false, - }, - }, - }), - ), - ); - let cursorSpawned = false; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { - cursorSpawned = true; - } + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") { - return { stdout: `${command} 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}`); + 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}`); }), ), - ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, + ), + ); + + it.effect( + "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", + () => + Effect.gen(function* () { + 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."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + const effortDescriptor = opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "xhigh", label: "Extra High", isDefault: true }, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.111\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}`); + }), + ), ), - ).pipe(Scope.provide(scope)); + ); - 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"], + it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, ); - assert.strictEqual(cursorProvider?.enabled, false); - assert.strictEqual(cursorProvider?.status, "disabled"); - assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect.skip("probes Copilot from its default command when binary path is unset", () => - Effect.gen(function* () { - const serverSettingsLayer = ServerSettingsService.layerTest(); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(serverSettingsLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\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}`); }), ), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { + ), + ); + + it.effect("returns a display label for claude subscription types", () => + Effect.gen(function* () { + 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"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") { - if (command === "codex") { - return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - } - if (command === "claude") { - return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; - } - if (command === "copilot") { - return { stdout: "copilot 2.3.4\n", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "spawn ENOENT", code: 1 }; - } - if (joined === "login status") { - return { stdout: "Logged in\n", stderr: "", code: 0 }; - } - if (joined === "auth status") { - return { stdout: "Authenticated\n", stderr: "", code: 0 }; - } - throw new Error(`Unexpected command: ${command} ${joined}`); + 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}`); }), ), - ); - - const providers = yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - return yield* registry.getProviders; - }).pipe(Effect.provide(providerRegistryLayer)); - - const copilot = providers.find((provider) => provider.provider === "copilot"); - assert.isDefined(copilot); - assert.strictEqual(copilot?.status, "ready"); - assert.strictEqual(copilot?.installed, true); - assert.notStrictEqual( - copilot?.message, - "Copilot is enabled, but no binary path is configured for probing.", - ); - }), - ); - - it.effect.skip("reports cursor as unavailable when its CLI command is missing", () => - Effect.gen(function* () { - const serverSettingsLayer = ServerSettingsService.layerTest({ - providers: { - cursor: { - enabled: true, - binaryPath: "/tmp/t3-missing-cursor-cli", - }, - }, - }); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(serverSettingsLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", + ), + ); + + it.effect("does not duplicate Claude in full subscription labels", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max Subscription", }), - ), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { + ); + 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") { - if (command === "codex") { - return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - } - if (command === "claude") { - return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "spawn ENOENT", code: 1 }; - } - if (joined === "login status") { - return { stdout: "Logged in\n", stderr: "", code: 0 }; - } - if (joined === "auth status") { - return { stdout: "Authenticated\n", stderr: "", code: 0 }; - } - throw new Error(`Unexpected command: ${command} ${joined}`); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); }), ), - ); - - const providers = yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - return yield* registry.getProviders; - }).pipe(Effect.provide(providerRegistryLayer)); - - const cursor = providers.find((provider) => provider.provider === "cursor"); - assert.isDefined(cursor); - assert.strictEqual(cursor?.status, "warning"); - assert.strictEqual(cursor?.installed, false); - assert.strictEqual(cursor?.message, "Cursor CLI not found on PATH."); - }), - ); - - it.effect("serves cached provider snapshots from getProviders without re-probing", () => - Effect.gen(function* () { - let probeCount = 0; - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", + ), + ); + + it.effect("does not duplicate Claude in provider-prefixed subscription names", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max", }), - ), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - probeCount += 1; + ); + 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") { - if (command === "codex") { - return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - } - if (command === "claude") { - return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "spawn ENOENT", code: 1 }; - } - if (joined === "login status") { - return { stdout: "Logged in\n", stderr: "", code: 0 }; - } - if (joined === "auth status") { - return { stdout: "Authenticated\n", stderr: "", code: 0 }; - } - throw new Error(`Unexpected command: ${command} ${joined}`); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); }), ), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - yield* registry.getProviders; - const initialProbeCount = probeCount; - yield* registry.getProviders; - assert.strictEqual(probeCount, initialProbeCount); - }).pipe(Effect.provide(providerRegistryLayer)); - }), - ); - - 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( + 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( - Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), + 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}`); + }), ), - ); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.enabled, false); - assert.strictEqual(status.status, "disabled"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); - }), - ); - }); - - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - }).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"}\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}`); + }); - it.effect( - "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - 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."); - } - if (!opus47.capabilities) { - assert.fail( - "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", - ); - } + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + homePath: claudeHome, + }, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( - opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), - { value: "xhigh", label: "Extra High", isDefault: true }, + 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( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ], + }), ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; if (joined === "auth status") return { stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', @@ -793,306 +1328,147 @@ it.layer( }), ), ), - ); - - it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\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}`); - }), - ), - ), - ); - - 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"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); - 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 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes probed claude slash commands in the provider snapshot", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - () => Effect.succeed("maxplan"), - () => - Effect.succeed([ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]), - ); + ); - assert.deepStrictEqual(status.slashCommands, [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]); - }).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"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("deduplicates probed claude slash commands by name", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - () => Effect.succeed("maxplan"), - () => - Effect.succeed([ - { - name: "ui", - description: "Explore and refine UI", - }, - { - name: "ui", - input: { hint: "component-or-screen" }, - }, - ]), - ); + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ], + }), + ); - assert.deepStrictEqual(status.slashCommands, [ - { - name: "ui", - description: "Explore and refine UI", - input: { hint: "component-or-screen" }, - }, - ]); - }).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"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns an api key label for claude api key auth", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "Claude API Key"); - }).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":"api-key"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", - ); - }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), - ); - - 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"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - }).pipe( - Effect.provide( - 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: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - 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", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - 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.", - ); - }).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: "", stderr: "error: unknown command 'auth'", code: 2 }; - throw new Error(`Unexpected args: ${joined}`); - }), + assert.deepStrictEqual(status.slashCommands, [ + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).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"}\n', + stderr: "", + code: 0, + }; + 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.effect("returns an api key label for claude api key auth", () => + Effect.gen(function* () { + 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"); + assert.strictEqual(status.auth.label, "Claude API Key"); + }).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":"api-key"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); - 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.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); - 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.effect("returns error when version check fails with non-zero exit code", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { + stdout: "", + stderr: "Something went wrong", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); - 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"); + it.effect("returns warning when the Claude initialization result is unavailable", () => + Effect.gen(function* () { + 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, + "Could not verify Claude authentication status from initialization result.", + ); + }).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: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); }); - }); -}); + }, +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index b1d20b4d603..4f586d881e3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -1,51 +1,66 @@ /** - * 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"; - -type ProviderSnapshotSource = { - readonly provider: ProviderKind; - readonly getSnapshot: Effect.Effect; - readonly refresh: Effect.Effect; - readonly streamChanges: Stream.Stream; -}; +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?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0; + (model.capabilities?.optionDescriptors?.length ?? 0) > 0; const mergeProviderModels = ( previousModels: ReadonlyArray, @@ -86,85 +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 cursorProvider = yield* CursorProvider; + const instanceRegistry = yield* ProviderInstanceRegistry; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const providerSources = [ - { - provider: "codex", - getSnapshot: codexProvider.getSnapshot, - refresh: codexProvider.refresh, - streamChanges: codexProvider.streamChanges, - }, - { - provider: "claudeAgent", - getSnapshot: claudeProvider.getSnapshot, - refresh: claudeProvider.refresh, - streamChanges: claudeProvider.streamChanges, - }, - { - provider: "opencode", - getSnapshot: openCodeProvider.getSnapshot, - refresh: openCodeProvider.refresh, - streamChanges: openCodeProvider.streamChanges, - }, - { - provider: "cursor", - getSnapshot: cursorProvider.getSnapshot, - refresh: cursorProvider.refresh, - streamChanges: cursorProvider.streamChanges, - }, - ] 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); - if (!filePath || !fallbackProvider) { - return Effect.succeed(undefined); - } - 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) => @@ -175,39 +228,64 @@ const ProviderRegistryLiveBase = Layer.effect( ); const providersRef = yield* Ref.make>(cachedProviders); - const persistProvider = (provider: ServerProvider) => { - const filePath = cachePathByProvider.get( - provider.provider as (typeof PROVIDER_CACHE_IDS)[number], - ); - if (!filePath) return Effect.void; - return writeProviderStatusCache({ - filePath, - provider, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.tapError(Effect.logError), - Effect.ignore, - ); - }; + // 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) => + 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), ); } @@ -217,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); } @@ -238,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(CodexProviderLive), - Layer.provideMerge(ClaudeProviderLive), - Layer.provideMerge(CursorProviderLive), - 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 9bb40596a06..7e771251437 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -12,28 +12,34 @@ import type { import { ApprovalRequestId, EventId, - type ProviderKind, + ProviderDriverKind, + ProviderInstanceId, ProviderSessionStartInput, ThreadId, TurnId, } from "@t3tools/contracts"; +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 { getProviderCapabilities } 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, @@ -177,7 +194,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const adapter: ProviderAdapterShape = { provider, - capabilities: getProviderCapabilities(provider), + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, @@ -243,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( @@ -270,6 +283,7 @@ function makeProviderServiceLayer() { Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ), directoryLayer, @@ -286,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), ); @@ -314,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", }); @@ -330,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", () => @@ -342,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), ); @@ -368,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* () { @@ -375,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", @@ -398,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( @@ -415,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)); @@ -425,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* () { @@ -439,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); @@ -469,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), @@ -485,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"), @@ -497,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, @@ -513,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)) { @@ -522,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), ); @@ -537,6 +733,7 @@ it.effect( Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); secondCodex.startSession.mockClear(); @@ -580,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", @@ -660,35 +858,13 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => - Effect.gen(function* () { - const provider = yield* ProviderService; - - const session = yield* provider.startSession(asThreadId("thread-claude"), { - provider: "claudeAgent", - threadId: asThreadId("thread-claude"), - cwd: "/tmp/project-claude", - runtimeMode: "full-access", - }); - - assert.equal(session.provider, "claudeAgent"); - assert.equal(routing.claude.startSession.mock.calls.length, 1); - 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 }; - assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude"); - } - }), - ); - it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { 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", @@ -729,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", @@ -774,13 +951,74 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeAgent"); + assert.equal(routing.claude.startSession.mock.calls.length, 1); + 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; + 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", @@ -790,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", @@ -816,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", @@ -856,16 +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: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -892,13 +1131,12 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); - assert.deepEqual(startPayload.modelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }); + assert.deepEqual( + startPayload.modelSelection, + createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + { id: "effort", value: "max" }, + ]), + ); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -911,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", }); @@ -934,10 +1174,11 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", - threadId: asThreadId("thread-1"), - cwd: "/tmp/project-send-turn", + const threadId = asThreadId("thread-runtime-status"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, runtimeMode: "full-access", }); yield* provider.sendTurn({ @@ -963,7 +1204,7 @@ routing.layer("ProviderServiceLive routing", (it) => { lastError: string | null; lastRuntimeEvent: string | null; }; - assert.equal(runtimePayload.cwd, "/tmp/project-send-turn"); + assert.equal(runtimePayload.cwd, session.cwd); assert.equal(runtimePayload.model, null); assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); @@ -982,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), ); @@ -998,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", @@ -1015,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), ); @@ -1031,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(); @@ -1038,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", @@ -1064,6 +1301,90 @@ routing.layer("ProviderServiceLive routing", (it) => { fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect( + "reuses persisted cwd when startSession resumes a claude session without cwd input", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, + }); + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + 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: 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(CLAUDE_AGENT_DRIVER); + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, + }); + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + secondClaude.startSession.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(initial.threadId, { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId: initial.threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.equal(secondClaude.startSession.mock.calls.length, 1); + const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.cwd, "/tmp/project-claude-cwd"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const fanout = makeProviderServiceLayer(); @@ -1072,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", }); @@ -1086,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"), @@ -1103,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, + ); }), ); @@ -1110,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", }); @@ -1125,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"), @@ -1135,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"), @@ -1145,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"), @@ -1165,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", }); @@ -1190,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"), @@ -1201,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"), @@ -1210,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"), @@ -1237,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", @@ -1266,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", }), @@ -1274,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", }), @@ -1282,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", }), @@ -1290,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", }), @@ -1298,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", }), @@ -1314,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", @@ -1330,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", }), @@ -1338,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, @@ -1349,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; @@ -1383,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, @@ -1395,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 d19eab25eb6..2ff26320e57 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); @@ -272,7 +275,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadId = ThreadId.make("thread-cursor"); yield* directory.upsert({ - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), threadId, }); @@ -281,7 +284,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const resolvedBinding = yield* directory.getBinding(threadId); assertSome(resolvedBinding, { threadId, - provider: "cursor", + provider: ProviderDriverKind.make("cursor"), }); if (Option.isSome(resolvedBinding)) { assert.equal(resolvedBinding.value.threadId, threadId); @@ -323,7 +326,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const binding = yield* directory.getBinding(threadId); assertSome(binding, { threadId, - provider: "geminiCli", + provider: ProviderDriverKind.make("geminiCli"), }); })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 50a3ffe3fe2..e1479082f2f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,11 +1,9 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Cache, Duration, Effect, Layer, Option } from "effect"; -import * as Semaphore from "effect/Semaphore"; +import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, Option, Schema } from "effect"; import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; -import { normalizePersistedProviderKindName } from "../providerKind.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, @@ -22,19 +20,19 @@ function toPersistenceError(operation: string) { }); } -function decodeProviderKind( +function decodeProviderDriverKind( providerName: string, operation: string, -): Effect.Effect { - const normalizedProvider = normalizePersistedProviderKindName(providerName); - if (normalizedProvider !== null) { - return Effect.succeed(normalizedProvider); - } - return Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - }), +): Effect.Effect { + return Schema.decodeUnknownEffect(ProviderDriverKind)(providerName).pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + cause, + }), + ), ); } @@ -59,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, @@ -79,12 +82,6 @@ function toRuntimeBinding( const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - const upsertLocks = yield* Cache.make({ - capacity: 10_000, - timeToLive: Duration.minutes(60), - lookup: () => Semaphore.make(1), - }); - const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), @@ -92,71 +89,59 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((provider) => - Option.some({ - threadId: value.threadId, - provider, - adapterKey: value.adapterKey, - runtimeMode: value.runtimeMode, - status: value.status, - resumeCursor: value.resumeCursor, - runtimePayload: value.runtimePayload, - }), - ), - // Gracefully treat unknown persisted providers as "no binding" - Effect.orElseSucceed(() => Option.none()), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( + Effect.map((binding) => Option.some(binding)), ), }), ), ); const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { - const threadId = binding.threadId; - if (!threadId) { + const existing = yield* repository + .getByThreadId({ threadId: binding.threadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { return yield* new ProviderValidationError({ operation: "ProviderSessionDirectory.upsert", issue: "threadId must be a non-empty string.", }); } - const lock = yield* Cache.get(upsertLocks, threadId); - yield* Semaphore.withPermit(lock)( - Effect.gen(function* () { - const existing = yield* repository - .getByThreadId({ threadId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")), - ); - - const existingRuntime = Option.getOrUndefined(existing); - const now = new Date().toISOString(); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; - yield* repository - .upsert({ - threadId, - providerName: binding.provider, - adapterKey: - binding.adapterKey ?? - (providerChanged - ? binding.provider - : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); - }), - ); + 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)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index abde9b5446e..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; @@ -139,17 +145,20 @@ describe("ProviderSessionReaper", () => { respondToUserInput: () => unsupported(), stopSession, listSessions: () => Effect.succeed([]), - getCapabilities: () => - Effect.succeed({ - sessionModelSwitch: "in-session" as const, - transport: "app-server-json-rpc" as const, - modelDiscovery: "native" as const, - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: false, - supportsAttachments: false, - persistentRuntime: true, - }), + 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, }; @@ -207,6 +216,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -254,6 +264,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -300,6 +311,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -346,6 +358,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "stopped", @@ -414,6 +427,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: failedThreadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -428,6 +442,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: reapedThreadId, providerName: "codex", + providerInstanceId: null, adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -493,6 +508,7 @@ describe("ProviderSessionReaper", () => { repository.upsert({ threadId: defectThreadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", @@ -507,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/AmpAdapter.ts b/apps/server/src/provider/Services/AmpAdapter.ts deleted file mode 100644 index 96763e7629b..00000000000 --- a/apps/server/src/provider/Services/AmpAdapter.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Context } from "effect"; - -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -export interface AmpAdapterShape extends Omit< - ProviderAdapterShape, - "provider" -> { - readonly provider: "amp"; -} - -export class AmpAdapter extends Context.Service()( - "t3/provider/Services/AmpAdapter", -) {} 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/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts index edacaef4ce6..adbffb41740 100644 --- a/apps/server/src/provider/Services/CopilotAdapter.ts +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -1,12 +1,32 @@ +/** + * CopilotAdapter — legacy Service-tag wrapper for the fork's Copilot + * adapter shape. + * + * The driver-based architecture no longer instantiates this Service tag + * (drivers return `ProviderAdapterShape` values directly to the registry). + * The shape interface is kept as the typed return contract for + * `makeCopilotAdapterImpl` so the existing 1.7k-line adapter body and its + * tests can keep referencing `CopilotAdapterShape` without churn. + * + * TODO(sync): once the adapter body is inlined into `CopilotDriver.ts`, + * delete the Service tag entirely and have the body declare its own + * concrete type. + * + * @module provider/Services/CopilotAdapter + */ import { Context } from "effect"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; -export interface CopilotAdapterShape extends ProviderAdapterShape { - readonly provider: "copilot"; -} +export interface CopilotAdapterShape extends ProviderAdapterShape {} +/** + * Vestigial Service tag — kept around so any code path that still imports + * `CopilotAdapter` (e.g. transition-period tests) continues to compile. + * Driver-based instantiation does not provide this layer; consumers should + * read instances from `ProviderInstanceRegistry` instead. + */ export class CopilotAdapter extends Context.Service()( "t3/provider/Services/CopilotAdapter", ) {} 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/GeminiCliAdapter.ts b/apps/server/src/provider/Services/GeminiCliAdapter.ts deleted file mode 100644 index f6b49b97098..00000000000 --- a/apps/server/src/provider/Services/GeminiCliAdapter.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Context } from "effect"; - -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -export interface GeminiCliAdapterShape extends Omit< - ProviderAdapterShape, - "provider" -> { - readonly provider: "geminiCli"; -} - -export class GeminiCliAdapter extends Context.Service()( - "t3/provider/Services/GeminiCliAdapter", -) {} diff --git a/apps/server/src/provider/Services/KiloAdapter.ts b/apps/server/src/provider/Services/KiloAdapter.ts deleted file mode 100644 index eba18b6e85a..00000000000 --- a/apps/server/src/provider/Services/KiloAdapter.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Context } from "effect"; - -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -export interface KiloAdapterShape extends Omit< - ProviderAdapterShape, - "provider" -> { - readonly provider: "kilo"; -} - -export class KiloAdapter extends Context.Service()( - "t3/provider/Services/KiloAdapter", -) {} 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 f153c2bf072..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, @@ -23,205 +23,13 @@ import type { import type { Effect } from "effect"; import type { Stream } from "effect"; -export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported"; -export type ProviderTransport = - | "app-server-json-rpc" - | "sdk-cli-server" - | "sdk-query" - | "acp-stdio" - | "http-sse" - | "cli-headless-json" - | "cli-persistent-json"; -export type ProviderModelDiscovery = - | "native" - | "acp-or-config" - | "config-or-static" - | "session-native" - | "unsupported"; -export type ProviderHarnessOperation = - | "startSession" - | "sendTurn" - | "interruptTurn" - | "respondToRequest" - | "respondToUserInput" - | "readThread" - | "rollbackThread" - | "stopSession" - | "streamEvents"; - -export const PROVIDER_HARNESS_OPERATIONS: ReadonlyArray = [ - "startSession", - "sendTurn", - "interruptTurn", - "respondToRequest", - "respondToUserInput", - "readThread", - "rollbackThread", - "stopSession", - "streamEvents", -] as const; +export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; export interface ProviderAdapterCapabilities { /** * Declares whether changing the model on an existing session is supported. */ readonly sessionModelSwitch: ProviderSessionModelSwitchMode; - /** - * Declares the provider transport family used by the adapter. - */ - readonly transport: ProviderTransport; - /** - * Describes how model discovery is sourced for this provider. - */ - readonly modelDiscovery: ProviderModelDiscovery; - /** - * Quick boolean check for whether model discovery is available at all. - */ - readonly supportsModelDiscovery: boolean; - /** - * Whether a stopped or missing runtime can be recovered from persisted resume - * state. - */ - readonly supportsResume: boolean; - /** - * Whether conversation rollback is supported by the underlying provider. - */ - readonly supportsRollback: boolean; - /** - * Whether the adapter accepts chat attachments. - */ - readonly supportsAttachments: boolean; - /** - * Whether the provider keeps a runtime/session alive across turns after - * `startSession`. - */ - readonly persistentRuntime: boolean; -} - -export const PROVIDER_CAPABILITIES_BY_PROVIDER: Readonly< - Record -> = { - codex: { - sessionModelSwitch: "in-session", - transport: "app-server-json-rpc", - modelDiscovery: "native", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: true, - supportsAttachments: true, - persistentRuntime: true, - }, - copilot: { - sessionModelSwitch: "in-session", - transport: "sdk-cli-server", - modelDiscovery: "native", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: false, - supportsAttachments: true, - persistentRuntime: true, - }, - claudeAgent: { - sessionModelSwitch: "in-session", - transport: "sdk-query", - modelDiscovery: "session-native", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: false, - supportsAttachments: true, - persistentRuntime: true, - }, - cursor: { - sessionModelSwitch: "unsupported", - transport: "acp-stdio", - modelDiscovery: "acp-or-config", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: false, - supportsAttachments: false, - persistentRuntime: true, - }, - opencode: { - sessionModelSwitch: "in-session", - transport: "http-sse", - modelDiscovery: "native", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: true, - supportsAttachments: false, - persistentRuntime: true, - }, - geminiCli: { - sessionModelSwitch: "restart-session", - transport: "cli-headless-json", - modelDiscovery: "config-or-static", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: false, - supportsAttachments: false, - persistentRuntime: false, - }, - amp: { - sessionModelSwitch: "restart-session", - transport: "cli-persistent-json", - modelDiscovery: "config-or-static", - supportsModelDiscovery: true, - supportsResume: false, - supportsRollback: false, - supportsAttachments: false, - persistentRuntime: true, - }, - kilo: { - sessionModelSwitch: "in-session", - transport: "http-sse", - modelDiscovery: "native", - supportsModelDiscovery: true, - supportsResume: true, - supportsRollback: true, - supportsAttachments: false, - persistentRuntime: true, - }, -} as const; - -export function getProviderCapabilities(provider: ProviderKind): ProviderAdapterCapabilities { - return PROVIDER_CAPABILITIES_BY_PROVIDER[provider]; -} - -export function validateProviderAdapterConformance( - adapter: ProviderAdapterShape, -): ReadonlyArray { - const issues: string[] = []; - const expected = getProviderCapabilities(adapter.provider); - - for (const operation of PROVIDER_HARNESS_OPERATIONS) { - if (operation === "streamEvents") { - if (adapter.streamEvents === undefined || adapter.streamEvents === null) { - issues.push(`missing operation '${operation}'`); - } - continue; - } - - if (typeof adapter[operation] !== "function") { - issues.push(`missing operation '${operation}'`); - } - } - - for (const [key, value] of Object.entries(expected) as Array< - [ - keyof ProviderAdapterCapabilities, - ProviderAdapterCapabilities[keyof ProviderAdapterCapabilities], - ] - >) { - if (adapter.capabilities[key] !== value) { - issues.push( - `capability mismatch for '${String(key)}': expected '${String(value)}', received '${String( - adapter.capabilities[key], - )}'`, - ); - } - } - - return issues; } export interface ProviderThreadTurnSnapshot { @@ -238,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.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index 94de569b2b2..30941acbfd5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -99,11 +99,11 @@ describe("applyCursorAcpModelSelection", () => { applyCursorAcpModelSelection({ runtime, model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]", - modelOptions: { - reasoning: "xhigh", - contextWindow: "1m", - fastMode: true, - }, + selections: [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ], mapError: ({ step, configId, cause }) => new Error( step === "set-config-option" diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 72b9af394b3..3e405dd7ff3 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,4 @@ -import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts"; +import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; import { Effect, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; @@ -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( @@ -76,7 +79,7 @@ interface CursorAcpModelSelectionRuntime { export function applyCursorAcpModelSelection(input: { readonly runtime: CursorAcpModelSelectionRuntime; readonly model: string | null | undefined; - readonly modelOptions: CursorModelOptions | null | undefined; + readonly selections: ReadonlyArray | null | undefined; readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E; }): Effect.Effect { return Effect.gen(function* () { @@ -91,7 +94,7 @@ export function applyCursorAcpModelSelection(input: { const configUpdates = resolveCursorAcpConfigUpdates( yield* input.runtime.getConfigOptions, - input.modelOptions, + input.selections, ); for (const update of configUpdates) { yield* input.runtime.setConfigOption(update.configId, update.value).pipe( diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts new file mode 100644 index 00000000000..c59ffa89335 --- /dev/null +++ b/apps/server/src/provider/builtInDrivers.ts @@ -0,0 +1,70 @@ +/** + * 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. + * + * Fork additions (Amp/Copilot/GeminiCli/Kilo) are currently registered as + * stubs that fail their `create()` with a clear message. This keeps + * configured instances of those drivers visible in the UI as "unavailable" + * shadows rather than disappearing on upgrade. See the TODO(sync) markers + * in each driver file for the re-port plan. + * + * @module provider/builtInDrivers + */ +import { AmpDriver, type AmpDriverEnv } from "./Drivers/AmpDriver.ts"; +import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; +import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CopilotDriver, type CopilotDriverEnv } from "./Drivers/CopilotDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { GeminiCliDriver, type GeminiCliDriverEnv } from "./Drivers/GeminiCliDriver.ts"; +import { KiloDriver, type KiloDriverEnv } from "./Drivers/KiloDriver.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 = + | AmpDriverEnv + | ClaudeDriverEnv + | CodexDriverEnv + | CopilotDriverEnv + | CursorDriverEnv + | GeminiCliDriverEnv + | KiloDriverEnv + | 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, + // Fork-only drivers — currently stubs returning ProviderDriverError. See + // the TODO(sync) markers in each driver for the re-port plan. + AmpDriver, + CopilotDriver, + GeminiCliDriver, + KiloDriver, +]; diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts new file mode 100644 index 00000000000..ee25b6d0184 --- /dev/null +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -0,0 +1,17 @@ +import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import type { Stream } from "effect"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; + +export type ProviderSnapshotSource = { + /** + * 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; +}; diff --git a/apps/server/src/provider/claude-agent-sdk.d.ts b/apps/server/src/provider/claude-agent-sdk.d.ts deleted file mode 100644 index dd98e6c595f..00000000000 --- a/apps/server/src/provider/claude-agent-sdk.d.ts +++ /dev/null @@ -1,186 +0,0 @@ -declare module "@anthropic-ai/claude-agent-sdk" { - export type PermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk"; - - export interface PermissionUpdate { - readonly [key: string]: unknown; - } - - export type PermissionResult = - | { - readonly behavior: "allow"; - readonly updatedInput?: unknown; - readonly message?: string; - } - | { - readonly behavior: "deny"; - readonly updatedInput?: unknown; - readonly message?: string; - }; - - export interface CanUseToolCallbackOptions { - readonly signal: AbortSignal; - readonly toolUseID?: string; - readonly suggestions?: ReadonlyArray; - readonly [key: string]: unknown; - } - - export type CanUseTool = ( - toolName: string, - toolInput: Record, - callbackOptions: CanUseToolCallbackOptions, - ) => Promise; - - export interface SDKUserMessage { - readonly [key: string]: unknown; - } - - export interface SDKResultMessage { - readonly subtype?: string; - readonly duration_ms?: number; - readonly durationMs?: number; - readonly is_error?: boolean; - readonly isError?: boolean; - readonly num_turns?: number; - readonly total_cost_usd?: number; - readonly stop_reason?: string | null; - readonly errors?: ReadonlyArray; - readonly usage?: { - readonly input_tokens?: number; - readonly output_tokens?: number; - readonly cache_creation_input_tokens?: number; - readonly cache_read_input_tokens?: number; - readonly server_tool_use?: { - readonly web_search_requests?: number; - }; - }; - readonly modelUsage?: { readonly [key: string]: unknown }; - readonly result?: string; - readonly session_id?: string; - readonly [key: string]: unknown; - } - - export interface SDKMessage { - readonly type?: string; - readonly subtype?: string; - readonly role?: string; - readonly message?: { - readonly id?: string; - readonly content?: ReadonlyArray; - readonly [key: string]: unknown; - }; - readonly content?: ReadonlyArray>; - readonly uuid?: string; - readonly session_id?: string; - readonly parent_tool_use_id?: string; - readonly tool_use_id?: string; - readonly tool_name?: string; - readonly input?: Record; - readonly result?: string; - readonly error?: string; - readonly errors?: ReadonlyArray; - readonly content_block?: Record; - readonly index?: number; - readonly preceding_tool_use_ids?: ReadonlyArray; - readonly is_error?: boolean; - readonly suggestions?: ReadonlyArray; - - // System message fields - readonly status?: string; - readonly hook_id?: string; - readonly hook_name?: string; - readonly hook_event?: string; - readonly output?: string; - readonly stdout?: string; - readonly stderr?: string; - readonly outcome?: "error" | "cancelled" | "success"; - readonly exit_code?: number; - - // Task fields - readonly task_id?: string; - readonly description?: string; - readonly task_type?: string; - readonly summary?: string; - readonly usage?: { readonly [key: string]: unknown }; - readonly last_tool_name?: string; - - // File persistence fields - readonly files?: ReadonlyArray<{ readonly filename: string; readonly file_id: string }>; - readonly failed?: ReadonlyArray<{ readonly filename: string; readonly error: string }>; - - // Tool progress fields - readonly elapsed_time_seconds?: number; - - // Auth status fields - readonly isAuthenticating?: boolean; - - // Stream event fields - readonly event?: Record; - - readonly [key: string]: unknown; - } - - export type ThinkingConfig = - | { readonly type: "adaptive" } - | { readonly type: "enabled"; readonly budgetTokens?: number } - | { readonly type: "disabled" }; - - export type EffortLevel = "low" | "medium" | "high" | "max"; - - export interface SpawnOptions { - readonly args: string[]; - readonly env?: Record; - readonly cwd?: string; - readonly [key: string]: unknown; - } - - export interface SpawnedProcess { - readonly stdin: NodeJS.WritableStream; - readonly stdout: NodeJS.ReadableStream; - killed: boolean; - exitCode: number | null; - kill(signal: NodeJS.Signals): boolean; - on(event: "exit" | "error", listener: (...args: unknown[]) => void): void; - once(event: "exit" | "error", listener: (...args: unknown[]) => void): void; - off(event: "exit" | "error", listener: (...args: unknown[]) => void): void; - } - - export type SettingSource = "user" | "project" | "local"; - - export interface Options { - readonly cwd?: string; - readonly model?: string; - readonly pathToClaudeCodeExecutable?: string; - readonly permissionMode?: PermissionMode; - readonly allowDangerouslySkipPermissions?: boolean; - /** @deprecated Use `thinking` instead. */ - readonly maxThinkingTokens?: number; - readonly thinking?: ThinkingConfig; - readonly effort?: EffortLevel; - readonly resume?: string; - readonly resumeSessionAt?: string; - readonly includePartialMessages?: boolean; - readonly persistSession?: boolean; - readonly sessionId?: string; - readonly settings?: Record; - readonly settingSources?: SettingSource[]; - readonly spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess; - readonly canUseTool?: CanUseTool; - readonly env?: Record; - readonly additionalDirectories?: ReadonlyArray; - readonly stderr?: (message: string) => void; - } - - export type Query = AsyncIterable & { - readonly interrupt?: () => Promise; - readonly setModel?: (model?: string) => Promise; - readonly setPermissionMode?: (mode: PermissionMode) => Promise; - readonly setMaxThinkingTokens?: (maxThinkingTokens: number | null) => Promise; - readonly close?: () => void; - readonly initializationResult?: () => Promise>; - }; - - export function query(input: { - readonly prompt: string | AsyncIterable; - readonly options?: Options; - }): Query; -} diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 31fe73a467e..ff664763804 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,15 +1,28 @@ 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"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const fastModeCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], +}); + interface TestSettings { readonly enabled: boolean; } const initialSnapshot: ServerProvider = { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), enabled: true, installed: true, version: null, @@ -23,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", @@ -43,13 +57,7 @@ const enrichedSnapshot: ServerProvider = { slug: "composer-2", name: "Composer 2", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: fastModeCapabilities, }, ], }; @@ -68,13 +76,7 @@ const enrichedSnapshotSecond: ServerProvider = { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], }; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 41ec5102c3c..c3e973e7e32 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( @@ -330,8 +333,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make(input.binaryPath, args, { + detached: process.platform !== "win32", + shell: process.platform === "win32", env: { - ...process.env, + ...(input.environment ?? process.env), OPENCODE_CONFIG_CONTENT: JSON.stringify({}), }, }), @@ -348,6 +353,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(); @@ -452,6 +476,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/providerKind.ts b/apps/server/src/provider/providerKind.ts index 14f54075553..9aa495044c4 100644 --- a/apps/server/src/provider/providerKind.ts +++ b/apps/server/src/provider/providerKind.ts @@ -1,4 +1,15 @@ -import type { ProviderKind } from "@t3tools/contracts"; +/** + * providerKind — fork-local helpers for normalizing legacy provider names + * read from disk against the set of driver kinds this build knows about. + * + * Upstream's PR #2277 split the historical "provider kind" into branded + * `ProviderDriverKind` (driver implementation) and `ProviderInstanceId` + * (user-defined routing key). The fork keeps this normalizer because some + * persisted projection rows still carry historical provider names that + * need to be coerced before they enter routing logic. Instance ids are + * resolved separately by the persistence boundary. + */ +import { ProviderDriverKind } from "@t3tools/contracts"; const PROVIDER_KINDS = [ "codex", @@ -9,19 +20,19 @@ const PROVIDER_KINDS = [ "geminiCli", "amp", "kilo", -] as const satisfies ReadonlyArray; +] as const; -const LEGACY_PROVIDER_KIND_ALIASES = { +const LEGACY_PROVIDER_KIND_ALIASES: Record = { claudeCode: "claudeAgent", gemini: "geminiCli", -} as const satisfies Record; +}; -const PROVIDER_KIND_SET = new Set(PROVIDER_KINDS); +const PROVIDER_KIND_SET = new Set(PROVIDER_KINDS); -export function normalizePersistedProviderKindName(providerName: string): ProviderKind | null { - const normalized = - LEGACY_PROVIDER_KIND_ALIASES[providerName as keyof typeof LEGACY_PROVIDER_KIND_ALIASES] ?? - providerName; - - return PROVIDER_KIND_SET.has(normalized as ProviderKind) ? (normalized as ProviderKind) : null; +export function normalizePersistedProviderKindName( + providerName: string, +): ProviderDriverKind | null { + const normalized = LEGACY_PROVIDER_KIND_ALIASES[providerName] ?? providerName; + if (!PROVIDER_KIND_SET.has(normalized)) return null; + return ProviderDriverKind.make(normalized); } diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 0a0d31ccb59..449dca8fc5a 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,23 +1,33 @@ 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"; -const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], - agentOptions: [{ value: "build", label: "Build", isDefault: true }], -}; +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [{ id: "medium", label: "Medium", isDefault: true }], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build", isDefault: true }], + currentValue: "build", + }, + ], +}); 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 82a3f418803..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, @@ -8,7 +9,9 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import { Effect, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { isWindowsCommandNotFound } from "../processRunner.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) @@ -34,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(); @@ -45,6 +50,26 @@ export function isCommandMissingCause(error: Error): boolean { return lower.includes("enoent") || lower.includes("notfound"); } +export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + const result: CommandResult = { stdout, stderr, code: exitCode }; + if (isWindowsCommandNotFound(exitCode, stderr)) { + return yield* Effect.fail(new Error(`spawn ${binaryPath} ENOENT`)); + } + return result; + }).pipe(Effect.scoped); + export function detailFromResult( result: CommandResult & { readonly timedOut?: boolean }, ): string | undefined { @@ -88,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 { @@ -157,20 +182,18 @@ export function buildBooleanOptionDescriptor(input: { } export function buildServerProvider(input: { - provider: ServerProvider["provider"]; - presentation?: ServerProviderPresentation; + presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; -}): ServerProvider { +}): ServerProviderDraft { return { - provider: input.provider, - ...(input.presentation?.displayName ? { displayName: input.presentation.displayName } : {}), - ...(input.presentation?.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), - ...(typeof input.presentation?.showInteractionModeToggle === "boolean" + displayName: input.presentation.displayName, + ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), + ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), enabled: input.enabled, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index b0cb5bc663c..8986ba48f29 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -1,20 +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", @@ -32,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({ @@ -74,20 +87,14 @@ 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: [ { slug: "gpt-5-mini", name: "GPT-5 Mini", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], message: "Cached message", @@ -100,19 +107,13 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { }, ], }); - const fallbackCodex = makeProvider("codex", { + const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [ { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], message: "Pending refresh", @@ -131,13 +132,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5-mini", name: "GPT-5 Mini", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], installed: cachedCodex.installed, @@ -153,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, @@ -174,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 6ba5a4d5bb2..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 as ReadonlyArray).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 7c62395598b..9113961167c 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 9a8885ffc0f..85f2d84ad28 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,26 +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 { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter.ts"; -import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts"; -import { makeGeminiCliAdapterLive } from "./provider/Layers/GeminiCliAdapter.ts"; -import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; -import { makeAmpAdapterLive } from "./provider/Layers/AmpAdapter.ts"; -import { makeKiloAdapterLive } from "./provider/Layers/KiloAdapter.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"; @@ -148,49 +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 copilotAdapterLayer = makeCopilotAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const cursorAdapterLayer = makeCursorAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const geminiCliAdapterLayer = makeGeminiCliAdapterLive(); - const openCodeAdapterLayer = makeOpenCodeAdapterLive(); - const ampAdapterLayer = makeAmpAdapterLive(); - const kiloAdapterLayer = makeKiloAdapterLive(); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(codexAdapterLayer), - Layer.provide(claudeAdapterLayer), - Layer.provide(copilotAdapterLayer), - Layer.provide(cursorAdapterLayer), - Layer.provide(geminiCliAdapterLayer), - Layer.provide(openCodeAdapterLayer), - Layer.provide(ampAdapterLayer), - Layer.provide(kiloAdapterLayer), - 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)); @@ -199,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( @@ -245,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 f9e0542f9a6..f11c5bf4519 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,5 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_SERVER_SETTINGS, 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"; import { ServerConfig } from "./config.ts"; @@ -24,44 +31,44 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(decodePatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), { providers: { codex: { binaryPath: "/tmp/codex" } }, }); - assert.deepEqual( - decodePatch({ - providers: { - copilot: { - binaryPath: "/tmp/copilot", - configDir: "/tmp/copilot-config", - }, - }, - }), - { - providers: { - copilot: { - binaryPath: "/tmp/copilot", - configDir: "/tmp/copilot-config", - }, - }, - }, - ); assert.deepEqual( decodePatch({ textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }), { textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }, ); }), ); + it.effect( + "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", + () => + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(ServerSettings); + + const decoded = decode({ + textGenerationModelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.4-mini", + options: { reasoningEffort: "low" }, + }, + }); + + assert.deepEqual(decoded.textGenerationModelSelection, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4-mini", + options: [{ id: "reasoningEffort", value: "low" }], + }); + }), + ); + it.effect("deep merges nested settings updates without dropping siblings", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -78,12 +85,16 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); @@ -94,9 +105,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }); @@ -104,22 +113,27 @@ 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, { - provider: "codex", - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: false, - }, - }); + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ], + ), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -130,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: { - effort: "high", - }, + options: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "high" }], + ).options!, }, }); @@ -142,21 +158,104 @@ 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: { - reasoningEffort: "high", + options: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]).options!, + }, + }); + + assert.deepEqual( + next.textGenerationModelSelection, + 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, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "high", + 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())), ); @@ -166,29 +265,70 @@ it.layer(NodeServices.layer)("server settings", (it) => { yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); 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; @@ -202,14 +342,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, - copilot: { - binaryPath: " /opt/homebrew/bin/copilot ", - configDir: " /Users/julius/.config/copilot ", - }, opencode: { binaryPath: " /opt/homebrew/bin/opencode ", - serverUrl: " http://localhost:1234 ", - serverPassword: " s3cret ", + serverUrl: " http://127.0.0.1:4096 ", + serverPassword: " secret-password ", }, }, }); @@ -218,25 +354,21 @@ 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: "", }); - assert.deepEqual(next.providers.copilot, { - enabled: true, - binaryPath: "/opt/homebrew/bin/copilot", - configDir: "/Users/julius/.config/copilot", - customModels: [], - }); assert.deepEqual(next.providers.opencode, { enabled: true, binaryPath: "/opt/homebrew/bin/opencode", - serverUrl: "http://localhost:1234", - serverPassword: "s3cret", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", customModels: [], }); }).pipe(Effect.provide(makeServerSettingsLayer())), @@ -298,7 +430,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", }, opencode: { - serverUrl: "http://localhost:1234", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", }, }, }); @@ -317,10 +450,74 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", }, opencode: { - serverUrl: "http://localhost:1234", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", }, }, }); }).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 acda7be294f..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,16 +152,13 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = [ - "codex", - "claudeAgent", - "copilot", - "cursor", - "opencode", - "geminiCli", - "amp", - "kilo", -]; +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. @@ -125,22 +168,36 @@ const PROVIDER_ORDER: readonly ProviderKind[] = [ */ 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, }; } @@ -185,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(); @@ -242,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) ?? {}; @@ -333,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({ @@ -353,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/index.html b/apps/web/index.html index 53e59c71bbc..53040ee5f88 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,10 @@ - + diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts deleted file mode 100644 index cf3548beeff..00000000000 --- a/apps/web/src/appSettings.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { Schema } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - AppSettingsSchema, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - DEFAULT_TIMESTAMP_FORMAT, - getProviderStartOptions, -} from "./appSettings"; -import { - getAppModelOptions, - getAppSettingsSnapshot, - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - getCustomModelsForProvider, - getDefaultCustomModelsForProvider, - MODEL_PROVIDER_SETTINGS, - normalizeCustomModelSlugs, - patchCustomModels, - patchGitTextGenerationModelOverrides, - resolveAppModelSelection, - resolveGitTextGenerationModelSelection, -} from "./appSettings"; - -/** Empty custom models for all providers — test helper */ -const EMPTY_CUSTOM_MODELS = { - codex: [] as readonly string[], - copilot: [] as readonly string[], - claudeAgent: [] as readonly string[], - cursor: [] as readonly string[], - opencode: [] as readonly string[], - geminiCli: [] as readonly string[], - amp: [] as readonly string[], - kilo: [] as readonly string[], -} as const; - -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; - -const originalWindow = globalThis.window; -const originalLocalStorage = globalThis.localStorage; - -function createLocalStorageMock(): Storage { - const store = new Map(); - return { - get length() { - return store.size; - }, - clear() { - store.clear(); - }, - getItem(key) { - return store.get(key) ?? null; - }, - key(index) { - return Array.from(store.keys())[index] ?? null; - }, - removeItem(key) { - store.delete(key); - }, - setItem(key, value) { - store.set(key, String(value)); - }, - }; -} - -beforeEach(() => { - const localStorage = createLocalStorageMock(); - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: localStorage, - }); - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - localStorage, - }, - }); -}); - -afterEach(() => { - Object.defineProperty(globalThis, "window", { - configurable: true, - value: originalWindow, - }); - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: originalLocalStorage, - }); -}); - -describe("normalizeCustomModelSlugs", () => { - it("normalizes aliases, removes built-ins, and deduplicates values", () => { - expect( - normalizeCustomModelSlugs([ - " custom/internal-model ", - "gpt-5.3-codex", - "5.3", - "custom/internal-model", - "", - null, - ]), - ).toEqual(["custom/internal-model"]); - }); - - it("normalizes provider-specific aliases for claude and cursor", () => { - expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); - expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ - "claude/custom-sonnet", - ]); - expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]); - expect(normalizeCustomModelSlugs(["cursor/custom-model"], "cursor")).toEqual([ - "cursor/custom-model", - ]); - }); -}); - -describe("getAppModelOptions", () => { - it("appends saved custom models after the built-in options", () => { - const options = getAppModelOptions("codex", ["custom/internal-model"]); - - expect(options.map((option) => option.slug)).toEqual([ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2-codex", - "gpt-5.2", - "custom/internal-model", - ]); - }); - - it("keeps the currently selected custom model available even if it is no longer saved", () => { - const options = getAppModelOptions("codex", [], "custom/selected-model"); - - expect(options.at(-1)).toEqual({ - slug: "custom/selected-model", - name: "custom/selected-model", - isCustom: true, - }); - }); - - it("keeps a saved custom provider model available as an exact slug option", () => { - const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); - - expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( - true, - ); - }); -}); - -describe("resolveAppModelSelection", () => { - it("preserves saved custom model slugs instead of falling back to the default", () => { - expect( - resolveAppModelSelection( - "codex", - { ...EMPTY_CUSTOM_MODELS, codex: ["galapagos-alpha"] }, - "galapagos-alpha", - ), - ).toBe("galapagos-alpha"); - }); - - it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", EMPTY_CUSTOM_MODELS, "")).toBe("gpt-5.4"); - }); - - it("resolves display names through the shared resolver", () => { - expect(resolveAppModelSelection("codex", EMPTY_CUSTOM_MODELS, "GPT-5.3 Codex")).toBe( - "gpt-5.3-codex", - ); - }); - - it("resolves aliases through the shared resolver", () => { - expect(resolveAppModelSelection("claudeAgent", EMPTY_CUSTOM_MODELS, "sonnet")).toBe( - "claude-sonnet-4-6", - ); - }); - - it("resolves transient selected custom models included in app model options", () => { - expect(resolveAppModelSelection("codex", EMPTY_CUSTOM_MODELS, "custom/selected-model")).toBe( - "custom/selected-model", - ); - }); -}); - -describe("resolveGitTextGenerationModelSelection", () => { - it("prefers a provider-specific override over the active thread model", () => { - const settings = { - ...getAppSettingsSnapshot(), - ...patchGitTextGenerationModelOverrides({}, "codex", "gpt-5.4-mini"), - }; - - expect(resolveGitTextGenerationModelSelection("codex", settings, "gpt-5.4")).toBe( - "gpt-5.4-mini", - ); - }); - - it("falls back to the active thread model when no override is configured", () => { - const settings = getAppSettingsSnapshot(); - - expect(resolveGitTextGenerationModelSelection("cursor", settings, "opus-4.6-thinking")).toBe( - "opus-4.6-thinking", - ); - }); - - it("uses the provider git default when neither an override nor thread model exists", () => { - const settings = getAppSettingsSnapshot(); - - expect(resolveGitTextGenerationModelSelection("codex", settings, null)).toBe("gpt-5.4-mini"); - }); -}); - -describe("timestamp format defaults", () => { - it("defaults timestamp format to locale", () => { - expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); - }); - - it("includes provider-specific custom slugs in non-codex model lists", () => { - const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); - const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]); - - expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); - expect(cursorOptions.some((option) => option.slug === "cursor/custom-model")).toBe(true); - }); -}); - -describe("getAppSettingsSnapshot", () => { - it("defaults provider logos to color", () => { - expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("original"); - }); - - it("hydrates a persisted provider logo appearance preference", () => { - const persistedSettings = { - ...getAppSettingsSnapshot(), - providerLogoAppearance: "accent", - }; - localStorage.setItem(APP_SETTINGS_STORAGE_KEY, JSON.stringify(persistedSettings)); - - expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("accent"); - }); - - it("migrates the legacy grayscale provider logo preference", () => { - localStorage.setItem( - APP_SETTINGS_STORAGE_KEY, - JSON.stringify({ - grayscaleProviderLogos: true, - }), - ); - - expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("grayscale"); - }); -}); - -describe("sidebar sort defaults", () => { - it("defaults project sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at"); - }); - - it("defaults thread sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at"); - }); -}); - -describe("provider-specific custom models", () => { - it("includes provider-specific custom slugs in non-codex model lists", () => { - const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); - - expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); - }); -}); - -describe("getProviderStartOptions", () => { - it("returns only populated provider overrides", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "/usr/local/bin/claude", - codexBinaryPath: "", - codexHomePath: "/Users/you/.codex", - }), - ).toEqual({ - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - }, - codex: { - homePath: "/Users/you/.codex", - }, - }); - }); - - it("returns undefined when no provider overrides are configured", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "", - codexBinaryPath: "", - codexHomePath: "", - }), - ).toBeUndefined(); - }); -}); - -describe("provider-indexed custom model settings", () => { - const settings = { - customCodexModels: ["custom/codex-model"], - customClaudeModels: ["claude/custom-opus"], - customCopilotModels: [], - customCursorModels: [], - customOpencodeModels: [], - customGeminiCliModels: [], - customAmpModels: [], - customKiloModels: [], - } as const; - - it("exports one provider config per provider", () => { - expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ - "codex", - "copilot", - "claudeAgent", - "cursor", - "opencode", - "geminiCli", - "amp", - "kilo", - ]); - }); - - it("reads custom models for each provider", () => { - expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); - expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); - }); - - it("reads default custom models for each provider", () => { - const defaults = { - customCodexModels: ["default/codex-model"], - customClaudeModels: ["claude/default-opus"], - customCopilotModels: [], - customCursorModels: [], - customOpencodeModels: [], - customGeminiCliModels: [], - customAmpModels: [], - customKiloModels: [], - } as const; - - expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); - expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ - "claude/default-opus", - ]); - }); - - it("patches custom models for codex", () => { - expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ - customCodexModels: ["custom/codex-model"], - }); - }); - - it("patches custom models for claude", () => { - expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ - customClaudeModels: ["claude/custom-opus"], - }); - }); - - it("builds a complete provider-indexed custom model record", () => { - expect(getCustomModelsByProvider(settings)).toEqual({ - codex: ["custom/codex-model"], - copilot: [], - claudeAgent: ["claude/custom-opus"], - cursor: [], - opencode: [], - geminiCli: [], - amp: [], - kilo: [], - }); - }); - - it("builds provider-indexed model options including custom models", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); - - expect( - modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), - ).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), - ).toBe(true); - }); - - it("normalizes and deduplicates custom model options per provider", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider({ - customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], - customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], - customCopilotModels: [], - customCursorModels: [], - customOpencodeModels: [], - customGeminiCliModels: [], - customAmpModels: [], - customKiloModels: [], - }); - - expect( - modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), - ).toHaveLength(1); - expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), - ).toHaveLength(1); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), - ).toBe(true); - }); -}); - -describe("AppSettingsSchema", () => { - it("fills decoding defaults for persisted settings that predate newer keys", () => { - const decode = Schema.decodeUnknownSync(Schema.fromJsonString(AppSettingsSchema)); - - expect( - decode( - JSON.stringify({ - codexBinaryPath: "/usr/local/bin/codex", - confirmThreadDelete: false, - }), - ), - ).toMatchObject({ - claudeBinaryPath: "", - codexBinaryPath: "/usr/local/bin/codex", - codexHomePath: "", - defaultThreadEnvMode: "local", - confirmThreadDelete: false, - enableAssistantStreaming: false, - sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - timestampFormat: DEFAULT_TIMESTAMP_FORMAT, - customCodexModels: [], - customClaudeModels: [], - }); - }); -}); - -// Note: upstream's resolveAppModelSelectionState tests removed — the fork -// uses resolveGitTextGenerationModelSelection with per-provider overrides -// instead of a single textGenerationModelSelection field. Equivalent -// coverage lives in the resolveGitTextGenerationModelSelection tests above. diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 1c6784e4920..cc45987fac7 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,477 +1,84 @@ -import { useCallback, useMemo } from "react"; -import { Effect, Schema } from "effect"; -import { - DEFAULT_SERVER_SETTINGS, - type ProviderStartOptions, - type ProviderKind, -} from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; -import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; -import { useLocalStorage } from "./hooks/useLocalStorage"; -import { useSettings, useUpdateSettings } from "./hooks/useSettings"; - -// Domain modules -import { - AppProviderLogoAppearanceSchema, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - DEFAULT_TIMESTAMP_FORMAT, - SidebarProjectSortOrder, - SidebarThreadSortOrder, -} from "./appearance"; -import { normalizeCustomModelSlugs } from "./customModels"; -import { normalizeGitTextGenerationModelByProvider } from "./gitTextGeneration"; - -// Re-export everything from domain modules for backwards compatibility -export { - APP_PROVIDER_LOGO_APPEARANCE_OPTIONS, - type AppProviderLogoAppearance, - AppProviderLogoAppearanceSchema, - TIMESTAMP_FORMAT_OPTIONS, - type TimestampFormat, - DEFAULT_TIMESTAMP_FORMAT, - SidebarProjectSortOrder, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - SidebarThreadSortOrder, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "./appearance"; - -export { - MAX_CUSTOM_MODEL_LENGTH, - type CustomModelSettingsKey, - type ProviderCustomModelConfig, - type ProviderCustomModelSettings, - MODEL_PROVIDER_SETTINGS, - type AppModelOption, - normalizeCustomModelSlugs, - getCustomModelsForProvider, - patchCustomModels, - getDefaultCustomModelsForProvider, - getCustomModelsByProvider, - getAppModelOptions, - resolveAppModelSelection, - getCustomModelOptionsByProvider, - getSlashModelOptions, -} from "./customModels"; - -export { - getGitTextGenerationModelOverride, - patchGitTextGenerationModelOverrides, - resolveGitTextGenerationModelSelection, -} from "./gitTextGeneration"; - -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; -const APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS = { - codex: "customCodexModels", - copilot: "customCopilotModels", - claudeAgent: "customClaudeModels", - cursor: "customCursorModels", - opencode: "customOpencodeModels", - geminiCli: "customGeminiCliModels", - amp: "customAmpModels", - kilo: "customKiloModels", -} as const satisfies Record; -const MIRRORED_CLIENT_KEYS = new Set([ - "confirmThreadDelete", - "diffWordWrap", - "sidebarProjectSortOrder", - "sidebarThreadSortOrder", - "timestampFormat", -]); -const MIRRORED_SERVER_KEYS = new Set([ - "claudeBinaryPath", - "codexBinaryPath", - "codexHomePath", - "copilotCliPath", - "copilotConfigDir", - "defaultThreadEnvMode", - "enableAssistantStreaming", - "customCodexModels", - "customCopilotModels", - "customClaudeModels", - "customCursorModels", - "customOpencodeModels", - "customGeminiCliModels", - "customAmpModels", - "customKiloModels", -]); - -const withDefaults = - < - S extends Schema.Top & Schema.WithoutConstructorDefault, - D extends S["~type.make.in"] & S["Encoded"], - >( - fallback: () => D, - ) => - (schema: S) => - schema.pipe( - Schema.withConstructorDefault(Effect.succeed(fallback())), - Schema.withDecodingDefault(Effect.succeed(fallback())), - ); - -export const AppSettingsSchema = Schema.Struct({ - claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - copilotCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( - withDefaults(() => "local" as const), - ), - confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), - diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), - enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), - showCommandOutput: Schema.Boolean.pipe(withDefaults(() => true)), - showFileChangeDiffs: Schema.Boolean.pipe(withDefaults(() => true)), - sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), - ), - sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), - ), - timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( - withDefaults(() => DEFAULT_TIMESTAMP_FORMAT), - ), - customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customCopilotModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customCursorModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customOpencodeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customGeminiCliModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customAmpModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customKiloModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - gitTextGenerationModelByProvider: Schema.Record(Schema.String, Schema.String).pipe( - withDefaults(() => ({}) as Record), - ), - providerLogoAppearance: AppProviderLogoAppearanceSchema.pipe( - withDefaults(() => "original" as const), - ), - grayscaleProviderLogos: Schema.Boolean.pipe(withDefaults(() => false)), - accentColor: Schema.String.check(Schema.isMaxLength(16)).pipe( - withDefaults(() => DEFAULT_ACCENT_COLOR), - ), - providerAccentColors: Schema.Record(Schema.String, Schema.String).pipe( - withDefaults(() => ({}) as Record), - ), - customAccentPresets: Schema.Array( - Schema.Struct({ - label: Schema.String.check(Schema.isMaxLength(64)), - value: Schema.String.check(Schema.isMaxLength(16)), - }), - ).pipe(withDefaults(() => [] as ReadonlyArray<{ label: string; value: string }>)), - backgroundColorOverride: Schema.String.check(Schema.isMaxLength(16)).pipe(withDefaults(() => "")), - foregroundColorOverride: Schema.String.check(Schema.isMaxLength(16)).pipe(withDefaults(() => "")), - uiFont: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), - codeFont: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), - uiFontSize: Schema.Number.pipe(withDefaults(() => 0)), - codeFontSize: Schema.Number.pipe(withDefaults(() => 0)), - contrast: Schema.Number.pipe(withDefaults(() => 0)), - translucency: Schema.Boolean.pipe(withDefaults(() => false)), -}); -export type AppSettings = typeof AppSettingsSchema.Type; - -const DEFAULT_APP_SETTINGS = AppSettingsSchema.make({}); - -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - customCopilotModels: normalizeCustomModelSlugs(settings.customCopilotModels, "copilot"), - customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), - customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), - customOpencodeModels: normalizeCustomModelSlugs(settings.customOpencodeModels, "opencode"), - customGeminiCliModels: normalizeCustomModelSlugs(settings.customGeminiCliModels, "geminiCli"), - customAmpModels: normalizeCustomModelSlugs(settings.customAmpModels, "amp"), - customKiloModels: normalizeCustomModelSlugs(settings.customKiloModels, "kilo"), - gitTextGenerationModelByProvider: normalizeGitTextGenerationModelByProvider( - settings.gitTextGenerationModelByProvider, - ), - accentColor: normalizeAccentColor(settings.accentColor), - providerAccentColors: Object.fromEntries( - Object.entries(settings.providerAccentColors) - .filter(([, v]) => isValidAccentColor(v)) - .map(([k, v]) => [k, normalizeAccentColor(v)]), - ), - }; +/** + * appSettings — fork-local app settings shim. + * + * The fork historically extended ServerSettings with many client-side + * preferences (custom model lists per provider, theme controls, etc.). + * Upstream's PR #2277 reshuffled the contracts package enough that the + * old fork-flavored AppSettings no longer compiles. This shim exposes a + * minimal AppSettings type covering the fields that surviving consumers + * still read, backed by `useSettings` for the parts that are now + * server-authoritative. + * + * TODO(sync): rebuild the rich custom-model + theme controls UI on top + * of the new ProviderInstance settings model. + */ +import { useMemo } from "react"; + +import { useSettings } from "./hooks/useSettings"; + +export type AppProviderLogoAppearance = "color" | "grayscale"; + +export interface AppSettings { + // ── Mirrored from server settings ── + readonly diffWordWrap: boolean; + readonly timestampFormat: "locale" | "12-hour" | "24-hour"; + // ── Theme controls (local-only, defaults retained) ── + readonly accentColor: string; + readonly providerLogoAppearance: AppProviderLogoAppearance; + readonly grayscaleProviderLogos: boolean; + readonly uiFont: string; + readonly codeFont: string; + readonly uiFontSize: number; + readonly codeFontSize: number; + readonly backgroundColorOverride: string; + readonly foregroundColorOverride: string; + readonly contrast: number; + readonly translucency: boolean; + // ── Misc UI behaviors ── + readonly showCommandOutput: boolean; + readonly showFileChangeDiffs: boolean; } -export function getProviderStartOptions( - settings: Pick, -): ProviderStartOptions | undefined { - const providerOptions: ProviderStartOptions = { - ...(settings.codexBinaryPath || settings.codexHomePath - ? { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, - } - : {}), - ...(settings.claudeBinaryPath - ? { - claudeAgent: { - binaryPath: settings.claudeBinaryPath, - }, - } - : {}), - }; +export const DEFAULT_APP_SETTINGS: AppSettings = { + diffWordWrap: false, + timestampFormat: "locale", + accentColor: "", + providerLogoAppearance: "color", + grayscaleProviderLogos: false, + uiFont: "", + codeFont: "", + uiFontSize: 0, + codeFontSize: 0, + backgroundColorOverride: "", + foregroundColorOverride: "", + contrast: 0, + translucency: false, + showCommandOutput: true, + showFileChangeDiffs: true, +}; - return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; -} - -let cachedRawSettings: string | null = null; let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; -function migratePersistedAppSettings(value: unknown): unknown { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return value; - } - - const settings = { ...(value as Record) }; - if (settings.providerLogoAppearance === undefined && settings.grayscaleProviderLogos === true) { - settings.providerLogoAppearance = "grayscale"; - } - - // Migrate legacy "claudeCode" key to "claudeAgent" in record-typed settings - for (const key of ["gitTextGenerationModelByProvider", "providerAccentColors"] as const) { - const record = settings[key]; - if (record && typeof record === "object" && !Array.isArray(record)) { - const obj = record as Record; - if ("claudeCode" in obj && !("claudeAgent" in obj)) { - const { claudeCode, ...rest } = obj; - settings[key] = { ...rest, claudeAgent: claudeCode }; - } - } - } - - return settings; -} - -function parsePersistedSettings(value: string | null): AppSettings { - if (!value) { - return DEFAULT_APP_SETTINGS; - } - - try { - const parsed = JSON.parse(value) as unknown; - return normalizeAppSettings( - AppSettingsSchema.make(migratePersistedAppSettings(parsed) as Record), - ); - } catch { - return DEFAULT_APP_SETTINGS; - } -} - -function withUnifiedCompatSettings( - localSettings: AppSettings, - unifiedSettings: Pick< - UnifiedSettings, - | "confirmThreadDelete" - | "defaultThreadEnvMode" - | "diffWordWrap" - | "enableAssistantStreaming" - | "providers" - | "sidebarProjectSortOrder" - | "sidebarThreadSortOrder" - | "timestampFormat" - >, -): AppSettings { - return normalizeAppSettings({ - ...localSettings, - claudeBinaryPath: unifiedSettings.providers.claudeAgent.binaryPath, - codexBinaryPath: unifiedSettings.providers.codex.binaryPath, - codexHomePath: unifiedSettings.providers.codex.homePath, - copilotCliPath: unifiedSettings.providers.copilot.binaryPath, - copilotConfigDir: unifiedSettings.providers.copilot.configDir, - defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, - confirmThreadDelete: unifiedSettings.confirmThreadDelete, - diffWordWrap: unifiedSettings.diffWordWrap, - enableAssistantStreaming: unifiedSettings.enableAssistantStreaming, - sidebarProjectSortOrder: unifiedSettings.sidebarProjectSortOrder, - sidebarThreadSortOrder: unifiedSettings.sidebarThreadSortOrder, - timestampFormat: unifiedSettings.timestampFormat, - customCodexModels: [...unifiedSettings.providers.codex.customModels], - customCopilotModels: [...unifiedSettings.providers.copilot.customModels], - customClaudeModels: [...unifiedSettings.providers.claudeAgent.customModels], - customCursorModels: [...unifiedSettings.providers.cursor.customModels], - customOpencodeModels: [...unifiedSettings.providers.opencode.customModels], - customGeminiCliModels: [...unifiedSettings.providers.geminiCli.customModels], - customAmpModels: [...unifiedSettings.providers.amp.customModels], - customKiloModels: [...unifiedSettings.providers.kilo.customModels], - }); -} - -function toUnifiedPatch(patch: Partial): Partial { - const providersPatch: Partial< - Record< - ProviderKind, - { - binaryPath?: string; - homePath?: string; - configDir?: string; - customModels?: ReadonlyArray; - } - > - > = {}; - if (patch.codexBinaryPath !== undefined || patch.codexHomePath !== undefined) { - providersPatch.codex = { - ...(patch.codexBinaryPath !== undefined ? { binaryPath: patch.codexBinaryPath } : {}), - ...(patch.codexHomePath !== undefined ? { homePath: patch.codexHomePath } : {}), - }; - } - if (patch.claudeBinaryPath !== undefined) { - providersPatch.claudeAgent = { - binaryPath: patch.claudeBinaryPath, - }; - } - if (patch.copilotCliPath !== undefined || patch.copilotConfigDir !== undefined) { - providersPatch.copilot = { - ...(patch.copilotCliPath !== undefined ? { binaryPath: patch.copilotCliPath } : {}), - ...(patch.copilotConfigDir !== undefined ? { configDir: patch.copilotConfigDir } : {}), - }; - } - const providerModelEntries = Object.entries(APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS) as Array< - [ProviderKind, (typeof APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS)[ProviderKind]] - >; - for (const [provider, settingsKey] of providerModelEntries) { - const models = patch[settingsKey]; - if (!Array.isArray(models)) { - continue; - } - providersPatch[provider] = { - ...(providersPatch[provider] ?? {}), - customModels: normalizeCustomModelSlugs(models, provider), - }; - } - return { - ...(patch.confirmThreadDelete !== undefined - ? { confirmThreadDelete: patch.confirmThreadDelete } - : {}), - ...(patch.diffWordWrap !== undefined ? { diffWordWrap: patch.diffWordWrap } : {}), - ...(patch.sidebarProjectSortOrder !== undefined - ? { sidebarProjectSortOrder: patch.sidebarProjectSortOrder } - : {}), - ...(patch.sidebarThreadSortOrder !== undefined - ? { sidebarThreadSortOrder: patch.sidebarThreadSortOrder } - : {}), - ...(patch.timestampFormat !== undefined ? { timestampFormat: patch.timestampFormat } : {}), - ...(patch.defaultThreadEnvMode !== undefined - ? { defaultThreadEnvMode: patch.defaultThreadEnvMode } - : {}), - ...(patch.enableAssistantStreaming !== undefined - ? { enableAssistantStreaming: patch.enableAssistantStreaming } - : {}), - ...(Object.keys(providersPatch).length > 0 - ? { providers: providersPatch as Partial } - : {}), - } as Partial; -} - -function stripMirroredKeys(patch: Partial): Partial { - const nextPatch = { ...patch }; - for (const key of [...MIRRORED_CLIENT_KEYS, ...MIRRORED_SERVER_KEYS]) { - delete nextPatch[key]; - } - return nextPatch; -} - export function getAppSettingsSnapshot(): AppSettings { - if (typeof window === "undefined") { - return DEFAULT_APP_SETTINGS; - } - - const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); - if (raw === cachedRawSettings) { - return cachedSnapshot; - } - - cachedRawSettings = raw; - cachedSnapshot = parsePersistedSettings(raw); return cachedSnapshot; } -export function useAppSettings() { - const [localSettings, setLocalSettings] = useLocalStorage( - APP_SETTINGS_STORAGE_KEY, - DEFAULT_APP_SETTINGS, - AppSettingsSchema, - ); - const unifiedSettings = useSettings(); - const compatUnifiedSettings = useMemo( - () => ({ - confirmThreadDelete: unifiedSettings.confirmThreadDelete, - defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, - diffWordWrap: unifiedSettings.diffWordWrap, - enableAssistantStreaming: unifiedSettings.enableAssistantStreaming, - providers: unifiedSettings.providers, - sidebarProjectSortOrder: unifiedSettings.sidebarProjectSortOrder, - sidebarThreadSortOrder: unifiedSettings.sidebarThreadSortOrder, - timestampFormat: unifiedSettings.timestampFormat, - }), - [unifiedSettings], - ); - const { updateSettings: updateUnifiedSettings, resetSettings: resetUnifiedSettings } = - useUpdateSettings(); - const settings = useMemo( - () => withUnifiedCompatSettings(localSettings, compatUnifiedSettings), - [compatUnifiedSettings, localSettings], - ); - const defaults = useMemo( - () => - withUnifiedCompatSettings(DEFAULT_APP_SETTINGS, { - ...DEFAULT_SERVER_SETTINGS, - ...DEFAULT_CLIENT_SETTINGS, - }), - [], - ); - - // Apply legacy key migration that the schema decode path doesn't handle - // Migrate legacy "claudeCode" keys to "claudeAgent" in record-typed settings - // (e.g. gitTextGenerationModelByProvider.claudeCode, providerAccentColors.claudeCode). - const migratedSettings = useMemo(() => { - let patched = settings; - for (const key of ["gitTextGenerationModelByProvider", "providerAccentColors"] as const) { - const val = patched[key]; - if (val && typeof val === "object" && "claudeCode" in val) { - const record = { ...val } as Record; - if (typeof record.claudeAgent !== "string" && typeof record.claudeCode === "string") { - record.claudeAgent = record.claudeCode; - } - delete record.claudeCode; - patched = { ...patched, [key]: record }; - } - } - return patched; - }, [settings]); - - const updateSettings = useCallback( - (patch: Partial) => { - const unifiedPatch = toUnifiedPatch(patch); - if (Object.keys(unifiedPatch).length > 0) { - updateUnifiedSettings(unifiedPatch); - } - - const localPatch = stripMirroredKeys(patch); - if (Object.keys(localPatch).length === 0) { - return; - } - - setLocalSettings((prev: AppSettings) => - normalizeAppSettings(AppSettingsSchema.make(stripMirroredKeys({ ...prev, ...localPatch }))), - ); - }, - [setLocalSettings, updateUnifiedSettings], - ); - - const resetSettings = useCallback(() => { - resetUnifiedSettings(); - setLocalSettings(AppSettingsSchema.make(stripMirroredKeys(DEFAULT_APP_SETTINGS))); - }, [resetUnifiedSettings, setLocalSettings]); +export function useAppSettings(): { settings: AppSettings } { + const serverSettings = useSettings(); + + const settings = useMemo(() => { + const next: AppSettings = { + ...DEFAULT_APP_SETTINGS, + diffWordWrap: + (serverSettings as { diffWordWrap?: boolean } | undefined)?.diffWordWrap ?? + DEFAULT_APP_SETTINGS.diffWordWrap, + timestampFormat: + (serverSettings as { timestampFormat?: AppSettings["timestampFormat"] } | undefined) + ?.timestampFormat ?? DEFAULT_APP_SETTINGS.timestampFormat, + }; + cachedSnapshot = next; + return next; + }, [serverSettings]); - return { - settings: migratedSettings, - updateSettings, - resetSettings, - defaults, - } as const; + return { settings }; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index b1ce57235a8..d98f30a1e5c 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -54,7 +54,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + void; } +interface MobileRunContextSelectorProps { + envLocked: boolean; + envModeLocked: boolean; + environmentId: EnvironmentId; + availableEnvironments: readonly EnvironmentOption[] | undefined; + showEnvironmentPicker: boolean; + onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + onEnvModeChange: (mode: EnvMode) => void; +} + +const MobileRunContextSelector = memo(function MobileRunContextSelector({ + envLocked, + envModeLocked, + environmentId, + availableEnvironments, + showEnvironmentPicker, + onEnvironmentChange, + effectiveEnvMode, + activeWorktreePath, + onEnvModeChange, +}: MobileRunContextSelectorProps) { + const activeEnvironment = useMemo( + () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, + [availableEnvironments, environmentId], + ); + const environmentLabel = activeEnvironment?.label ?? "Run on"; + const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; + const WorkspaceIcon = + effectiveEnvMode === "worktree" + ? FolderGit2Icon + : activeWorktreePath + ? FolderGitIcon + : FolderIcon; + const workspaceLabel = envModeLocked + ? resolveLockedWorkspaceLabel(activeWorktreePath) + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); + + return ( + + } + className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + > + {showEnvironmentPicker ? ( + <> + + {environmentLabel} + + ) : ( + <> + + {workspaceLabel} + + )} + + + + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( + <> + + Run on + onEnvironmentChange(value as EnvironmentId)} + > + {availableEnvironments.map((env) => { + const Icon = env.isPrimary ? MonitorIcon : CloudIcon; + return ( + + + + {env.label} + + + ); + })} + + + + + ) : null} + + Workspace + onEnvModeChange(value as EnvMode)} + > + + + {activeWorktreePath ? ( + + ) : ( + + )} + + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + + + + + + + {resolveEnvModeLabel("worktree")} + + + + + + + ); +}); + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, @@ -74,34 +217,51 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); - const showEnvironmentPicker = - availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; + const showEnvironmentPicker = Boolean( + availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, + ); + const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; return ( -
-
- {showEnvironmentPicker && ( - <> - - - - )} - + {isMobile ? ( + -
+ ) : ( +
+ {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + <> + + + + )} + +
+ )} } - className="text-muted-foreground/70 hover:text-foreground/80" + className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} > - {triggerLabel} - + {triggerLabel} +
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6e1c80f5573..6d06882662f 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -58,6 +58,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe return ( onEnvironmentChange(value as EnvironmentId)} items={environmentItems} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2469acfb821..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, @@ -18,6 +20,7 @@ import { DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -166,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", @@ -322,7 +326,7 @@ function createSnapshotForTargetUser(options: { title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, scripts: [], @@ -337,7 +341,7 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: THREAD_TITLE, modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -402,7 +406,7 @@ function addThreadToSnapshot( projectId: PROJECT_ID, title: "New thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -739,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", @@ -771,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", @@ -806,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, @@ -883,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({ @@ -891,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 = @@ -1922,7 +1926,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await waitForServerConfigToApply(); const menuButton = await waitForElement( - () => document.querySelector('button[aria-label="Select editor"]'), + () => document.querySelector('button[aria-label="Copy options"]'), "Unable to find Open picker button.", ); (menuButton as HTMLButtonElement).click(); @@ -1971,7 +1975,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await waitForServerConfigToApply(); const menuButton = await waitForElement( - () => document.querySelector('button[aria-label="Select editor"]'), + () => document.querySelector('button[aria-label="Copy options"]'), "Unable to find Open picker button.", ); (menuButton as HTMLButtonElement).click(); @@ -2437,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({ @@ -3777,16 +3901,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: 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({ @@ -3810,14 +3934,16 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const newDraftId = draftIdFromPath(newThreadPath); + // `toMatchObject` matches objects loosely (extras ignored) but compares + // arrays strictly, so wrap `options` in `arrayContaining` to keep the + // assertion focused on sticky `fastMode` carrying over without asserting + // on exactly which other options are preserved. expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { codex: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", - options: { - fastMode: true, - }, + options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, }, activeProvider: "codex", @@ -3830,16 +3956,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: 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({ @@ -3865,14 +3991,14 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, + claudeAgent: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), }, activeProvider: "claudeAgent", }); @@ -3912,16 +4038,16 @@ describe("ChatView timeline estimator parity (full app)", () => { it("prefers draft state over sticky composer settings and defaults", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: 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({ @@ -3945,27 +4071,27 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const draftId = draftIdFromPath(threadPath); + // See the note on the sibling sticky-codex test: arrays match strictly + // under `toMatchObject`, so use `arrayContaining` to keep the assertion + // scoped to the sticky trait (`fastMode`) that must carry over. expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", - options: { - fastMode: true, - }, + options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, }, activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(draftId, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, - }); + useComposerDraftStore.getState().setModelSelection( + draftId, + createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ]), + ); await newThreadButton.click(); @@ -3976,14 +4102,10 @@ describe("ChatView timeline estimator parity (full app)", () => { ); expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, - }, + codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ]), }, activeProvider: "codex", }); @@ -5452,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", }), @@ -5482,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", }), @@ -5606,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, ), @@ -5664,43 +5795,36 @@ describe("ChatView timeline estimator parity (full app)", () => { providers: [ { ...nextFixture.serverConfig.providers[0]!, - provider: "codex", models: [ { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max", isCustom: false, - capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", isCustom: false, - capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), }, { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), }, ], }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 08266e22a38..fecaeddafa2 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 1c72f9a5a35..417313ef2c1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,16 +1,15 @@ import { type EnvironmentId, + isProviderDriverKind, ProjectId, type ModelSelection, - type OrchestrationThreadActivity, - type ProviderKind, + type ProviderDriverKind, type ScopedThreadRef, type ThreadId, type TurnId, } from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; -import { deriveWorkLogEntries, type WorkLogEntry } from "../session-logic"; import { Schema } from "effect"; import { selectThreadByRef, useStore } from "../store"; import { @@ -181,30 +180,6 @@ export function cloneComposerImageForRetry( } } -/** - * Resolve which provider the health banner should reflect before a session - * starts. Once a session is active its provider takes precedence; otherwise - * fall back to the user's currently selected draft provider. - */ -export function resolveProviderHealthBannerProvider(opts: { - sessionProvider: ProviderKind | null; - selectedProvider: ProviderKind; -}): ProviderKind { - return opts.sessionProvider ?? opts.selectedProvider; -} - -/** - * Derive work-log entries that keep completed tool calls from previous turns - * visible while the user is composing a new message. Passing `undefined` for - * the turn-id filter causes `deriveWorkLogEntries` to include all activities - * rather than scoping to only the latest turn. - */ -export function deriveVisibleThreadWorkLogEntries( - activities: ReadonlyArray, -): WorkLogEntry[] { - return deriveWorkLogEntries(activities, undefined); -} - export function deriveComposerSendState(options: { prompt: string; imageCount: number; @@ -252,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 535c0d9fcae..40cd1b42105 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,14 +1,14 @@ import { type ApprovalRequestId, - DEFAULT_MODEL_BY_PROVIDER, - type ClaudeAgentEffort, + DEFAULT_MODEL, + defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, - type ProviderKind, type ProjectId, type ProviderApprovalDecision, + ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, @@ -17,6 +17,7 @@ import { type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, + ProviderDriverKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -26,7 +27,11 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + createModelSelection, + resolvePromptInjectedEffort, +} from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; @@ -101,7 +106,7 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -112,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 { @@ -299,17 +304,15 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog } function formatOutgoingPrompt(params: { - provider: ProviderKind; + provider: ProviderDriverKind; model: string | null; models: ReadonlyArray; effort: string | null; text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeAgentEffort | null); - } - return params.text; + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -615,6 +618,7 @@ export default function ChatView(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; + const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -775,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, ) @@ -1017,7 +1021,10 @@ export default function ChatView(props: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + activeLatestTurn.completedAt, + ); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, @@ -1029,7 +1036,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, @@ -1049,9 +1058,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( @@ -1423,10 +1432,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; @@ -1855,11 +1878,13 @@ export default function ChatView(props: ChatViewProps) { title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } catch (error) { - toastManager.add({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not delete action", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); } }, [activeProject, persistProjectScripts], @@ -1941,7 +1966,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)) ) { @@ -2007,6 +2032,7 @@ export default function ChatView(props: ChatViewProps) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); } else { + planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; @@ -2015,6 +2041,7 @@ export default function ChatView(props: ChatViewProps) { // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { + if (!autoOpenPlanSidebar) return; if (!activePlan) return; if (planSidebarOpen) return; const latestTurnId = activeLatestTurn?.turnId ?? null; @@ -2022,7 +2049,13 @@ export default function ChatView(props: ChatViewProps) { const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; setPlanSidebarOpen(true); - }, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]); + }, [ + activePlan, + activeLatestTurn?.turnId, + autoOpenPlanSidebar, + planSidebarOpen, + sidebarProposedPlan?.turnId, + ]); useEffect(() => { setIsRevertingCheckpoint(false); @@ -2426,11 +2459,13 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "empty", ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); } return; } @@ -2512,11 +2547,13 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "omitted", ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -2543,10 +2580,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, ); @@ -2943,7 +2978,7 @@ export default function ChatView(props: ChatViewProps) { // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { + if (nextInteractionMode === "default" && autoOpenPlanSidebar) { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } @@ -2972,6 +3007,7 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, setComposerDraftInteractionMode, setThreadError, + autoOpenPlanSidebar, environmentId, ], ); @@ -3064,8 +3100,8 @@ export default function ChatView(props: ChatViewProps) { return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { - // Signal that the plan sidebar should open on the new thread. - planSidebarOpenOnNextThreadRef.current = true; + // Signal that the plan sidebar should open on the new thread when enabled. + planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; return navigate({ to: "/$environmentId/$threadId", params: { @@ -3082,12 +3118,16 @@ export default function ChatView(props: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); - toastManager.add({ - type: "error", - title: "Could not start implementation thread", - description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start implementation thread", + description: + err instanceof Error + ? err.message + : "An error occurred while creating the new thread.", + }), + ); }) .then(finish, finish); }, [ @@ -3102,25 +3142,51 @@ export default function ChatView(props: ChatViewProps) { navigate, resetLocalDispatch, runtimeMode, + autoOpenPlanSidebar, environmentId, ]); 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( @@ -3215,14 +3281,14 @@ export default function ChatView(props: ChatViewProps) { {/* Top bar */}
{/* Input bar */} -
+
+ {isGitRepo && ( + + )}
- {isGitRepo && ( - - )} {pullRequestDialogState ? ( = {}): 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 a9f9f8007cf..b852fa04ead 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"; @@ -788,8 +789,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(), }); @@ -995,6 +996,9 @@ function OpenCommandPaletteDialog() { composerHandleRef?.current?.focusAtEnd(); return false; }} + onBackdropPointerDown={() => { + setOpen(false); + }} > - + {props.path} @@ -275,7 +272,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s return ( - + {props.skillDescription} @@ -1493,7 +1490,7 @@ function ComposerPromptEditorInner({ const rootElement = editor.getRootElement(); if (!rootElement) return; const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); - rootElement.focus(); + rootElement.focus({ preventScroll: true }); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); @@ -1624,7 +1621,7 @@ function ComposerPromptEditorInner({ contentEditable={ 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 31342d8406b..3f39b129acc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,7 +4,6 @@ import type { GitRunStackedActionResult, GitStackedAction, GitStatusResult, - ProviderKind, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -23,7 +22,6 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -import { resolveGitTextGenerationModelSelection, useAppSettings } from "~/appSettings"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -60,8 +58,6 @@ import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; - provider?: ProviderKind; - model?: string; draftId?: DraftId; } @@ -217,11 +213,8 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { export default function GitActionsControl({ gitCwd, activeThreadRef, - provider, - model, draftId, }: GitActionsControlProps) { - const { settings } = useAppSettings(); const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), @@ -248,13 +241,6 @@ export default function GitActionsControl({ const [isEditingFiles, setIsEditingFiles] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); - const gitProvider = provider ?? activeServerThread?.modelSelection.provider ?? "codex"; - const gitModel = model ?? activeServerThread?.modelSelection.model ?? ""; - const gitTextGenerationModel = resolveGitTextGenerationModelSelection( - gitProvider, - settings, - gitModel, - ); const activeGitActionProgressRef = useRef(null); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; @@ -1039,7 +1025,6 @@ export default function GitActionsControl({
{isEditingFiles && allFiles.length > 0 && ( { @@ -1081,7 +1066,6 @@ export default function GitActionsControl({ > {isEditingFiles && ( { setExcludedFiles((prev) => { @@ -1139,7 +1123,6 @@ export default function GitActionsControl({

Commit message (optional)