| author | |
| committer | |
| log | 08e6d4cfbfe55af83fe5fb6b9e75364f42f1f22f |
| tree | a135a659f540ba6c145af50331f63bc238f7deab |
| parent | 35822884d19ba64c6529d7736fb0182b361f3e4c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: codex <codex@users.noreply.github.com>205 files changed, 17851 insertions(+), 6517 deletions(-)
apps/desktop/src/clientPersistence.test.ts+1| ... | @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = { | ... | @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = { |
| 54 | confirmThreadDelete: false, | 54 | confirmThreadDelete: false, |
| 55 | diffWordWrap: true, | 55 | diffWordWrap: true, |
| 56 | favorites: [], | 56 | favorites: [], |
| 57 | providerModelPreferences: {}, | ||
| 57 | sidebarProjectGroupingMode: "repository_path", | 58 | sidebarProjectGroupingMode: "repository_path", |
| 58 | sidebarProjectGroupingOverrides: { | 59 | sidebarProjectGroupingOverrides: { |
| 59 | "environment-1:/tmp/project-a": "separate", | 60 | "environment-1:/tmp/project-a": "separate", |
apps/server/integration/OrchestrationEngineHarness.integration.ts+22-22| ... | @@ -3,7 +3,8 @@ import { execFileSync } from "node:child_process"; | ... | @@ -3,7 +3,8 @@ import { execFileSync } from "node:child_process"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { | 4 | import { |
| 5 | ApprovalRequestId, | 5 | ApprovalRequestId, |
| 6 | ProviderKind, | 6 | CodexSettings, |
| 7 | ProviderDriverKind, | ||
| 7 | type OrchestrationEvent, | 8 | type OrchestrationEvent, |
| 8 | type OrchestrationThread, | 9 | type OrchestrationThread, |
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| ... | @@ -36,13 +37,16 @@ import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ | ... | @@ -36,13 +37,16 @@ import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ |
| 36 | import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; | 37 | import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; |
| 37 | import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; | 38 | import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; |
| 38 | import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; | 39 | import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; |
| 39 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | 40 | import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; |
| 40 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 41 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 41 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 42 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 42 | import { ServerSettingsService } from "../src/serverSettings.ts"; | 43 | import { ServerSettingsService } from "../src/serverSettings.ts"; |
| 43 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | 44 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| 44 | import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; | 45 | import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; |
| 45 | import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; | 46 | import { |
| 47 | NoOpProviderEventLoggers, | ||
| 48 | ProviderEventLoggers, | ||
| 49 | } from "../src/provider/Layers/ProviderEventLoggers.ts"; | ||
| 46 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | 50 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 47 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; | 51 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 48 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | 52 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| ... | @@ -214,7 +218,7 @@ export interface OrchestrationIntegrationHarness { | ... | @@ -214,7 +218,7 @@ export interface OrchestrationIntegrationHarness { |
| 214 | } | 218 | } |
| 215 | 219 | ||
| 216 | interface MakeOrchestrationIntegrationHarnessOptions { | 220 | interface MakeOrchestrationIntegrationHarnessOptions { |
| 217 | readonly provider?: ProviderKind; | 221 | readonly provider?: ProviderDriverKind; |
| 218 | readonly realCodex?: boolean; | 222 | readonly realCodex?: boolean; |
| 219 | } | 223 | } |
| 220 | 224 | ||
| ... | @@ -225,7 +229,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -225,7 +229,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 225 | const path = yield* Path.Path; | 229 | const path = yield* Path.Path; |
| 226 | const fileSystem = yield* FileSystem.FileSystem; | 230 | const fileSystem = yield* FileSystem.FileSystem; |
| 227 | 231 | ||
| 228 | const provider = options?.provider ?? "codex"; | 232 | const provider = options?.provider ?? ProviderDriverKind.make("codex"); |
| 229 | const useRealCodex = options?.realCodex === true; | 233 | const useRealCodex = options?.realCodex === true; |
| 230 | const adapterHarness = useRealCodex | 234 | const adapterHarness = useRealCodex |
| 231 | ? null | 235 | ? null |
| ... | @@ -233,13 +237,10 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -233,13 +237,10 @@ export const makeOrchestrationIntegrationHarness = ( |
| 233 | provider, | 237 | provider, |
| 234 | }); | 238 | }); |
| 235 | const fakeRegistry = adapterHarness | 239 | const fakeRegistry = adapterHarness |
| 236 | ? Layer.succeed(ProviderAdapterRegistry, { | 240 | ? Layer.succeed( |
| 237 | getByProvider: (resolvedProvider) => | 241 | ProviderAdapterRegistry, |
| 238 | resolvedProvider === adapterHarness.provider | 242 | makeAdapterRegistryMock({ [adapterHarness.provider]: adapterHarness.adapter }), |
| 239 | ? Effect.succeed(adapterHarness.adapter) | 243 | ) |
| 240 | : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), | ||
| 241 | listProviders: () => Effect.succeed([adapterHarness.provider]), | ||
| 242 | } as typeof ProviderAdapterRegistry.Service) | ||
| 243 | : null; | 244 | : null; |
| 244 | const rootDir = yield* fileSystem.makeTempDirectoryScoped({ | 245 | const rootDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 245 | prefix: "t3-orchestration-integration-", | 246 | prefix: "t3-orchestration-integration-", |
| ... | @@ -264,31 +265,30 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -264,31 +265,30 @@ export const makeOrchestrationIntegrationHarness = ( |
| 264 | const realCodexRegistry = Layer.effect( | 265 | const realCodexRegistry = Layer.effect( |
| 265 | ProviderAdapterRegistry, | 266 | ProviderAdapterRegistry, |
| 266 | Effect.gen(function* () { | 267 | Effect.gen(function* () { |
| 267 | const codexAdapter = yield* CodexAdapter; | 268 | const codexSettings = Schema.decodeSync(CodexSettings)({}); |
| 268 | return { | 269 | const codexAdapter = yield* makeCodexAdapter(codexSettings); |
| 269 | getByProvider: (resolvedProvider) => | 270 | return makeAdapterRegistryMock({ |
| 270 | resolvedProvider === "codex" | 271 | [ProviderDriverKind.make("codex")]: codexAdapter, |
| 271 | ? Effect.succeed(codexAdapter) | 272 | }); |
| 272 | : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), | ||
| 273 | listProviders: () => Effect.succeed(["codex"] as const), | ||
| 274 | } as typeof ProviderAdapterRegistry.Service; | ||
| 275 | }), | 273 | }), |
| 276 | ).pipe( | 274 | ).pipe( |
| 277 | Layer.provide(makeCodexAdapterLive()), | ||
| 278 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), | 275 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 279 | Layer.provideMerge(NodeServices.layer), | 276 | Layer.provideMerge(NodeServices.layer), |
| 280 | Layer.provideMerge(providerSessionDirectoryLayer), | 277 | Layer.provideMerge(providerSessionDirectoryLayer), |
| 281 | ); | 278 | ); |
| 279 | const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); | ||
| 282 | const providerLayer = useRealCodex | 280 | const providerLayer = useRealCodex |
| 283 | ? makeProviderServiceLive().pipe( | 281 | ? makeProviderServiceLive().pipe( |
| 284 | Layer.provide(providerSessionDirectoryLayer), | 282 | Layer.provide(providerSessionDirectoryLayer), |
| 285 | Layer.provide(realCodexRegistry), | 283 | Layer.provide(realCodexRegistry), |
| 286 | Layer.provide(AnalyticsService.layerTest), | 284 | Layer.provide(AnalyticsService.layerTest), |
| 285 | Layer.provide(providerEventLoggersLayer), | ||
| 287 | ) | 286 | ) |
| 288 | : makeProviderServiceLive().pipe( | 287 | : makeProviderServiceLive().pipe( |
| 289 | Layer.provide(providerSessionDirectoryLayer), | 288 | Layer.provide(providerSessionDirectoryLayer), |
| 290 | Layer.provide(fakeRegistry!), | 289 | Layer.provide(fakeRegistry!), |
| 291 | Layer.provide(AnalyticsService.layerTest), | 290 | Layer.provide(AnalyticsService.layerTest), |
| 291 | Layer.provide(providerEventLoggersLayer), | ||
| 292 | ); | 292 | ); |
| 293 | 293 | ||
| 294 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); | 294 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); |
apps/server/integration/TestProviderAdapter.integration.ts+10-7| ... | @@ -10,7 +10,7 @@ import { | ... | @@ -10,7 +10,7 @@ import { |
| 10 | ProviderTurnStartResult, | 10 | ProviderTurnStartResult, |
| 11 | ThreadId, | 11 | ThreadId, |
| 12 | TurnId, | 12 | TurnId, |
| 13 | ProviderKind, | 13 | ProviderDriverKind, |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { Effect, Queue, Stream } from "effect"; | 15 | import { Effect, Queue, Stream } from "effect"; |
| 16 | 16 | ||
| ... | @@ -36,7 +36,7 @@ export interface TestTurnResponse { | ... | @@ -36,7 +36,7 @@ export interface TestTurnResponse { |
| 36 | export type FixtureProviderRuntimeEvent = { | 36 | export type FixtureProviderRuntimeEvent = { |
| 37 | readonly type: string; | 37 | readonly type: string; |
| 38 | readonly eventId: EventId; | 38 | readonly eventId: EventId; |
| 39 | readonly provider: ProviderKind; | 39 | readonly provider: ProviderDriverKind; |
| 40 | readonly createdAt: string; | 40 | readonly createdAt: string; |
| 41 | readonly threadId: string; | 41 | readonly threadId: string; |
| 42 | readonly turnId?: string | undefined; | 42 | readonly turnId?: string | undefined; |
| ... | @@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti | ... | @@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti |
| 178 | 178 | ||
| 179 | export interface TestProviderAdapterHarness { | 179 | export interface TestProviderAdapterHarness { |
| 180 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; | 180 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; |
| 181 | readonly provider: ProviderKind; | 181 | readonly provider: ProviderDriverKind; |
| 182 | readonly queueTurnResponse: ( | 182 | readonly queueTurnResponse: ( |
| 183 | threadId: ThreadId, | 183 | threadId: ThreadId, |
| 184 | response: TestTurnResponse, | 184 | response: TestTurnResponse, |
| ... | @@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness { | ... | @@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness { |
| 198 | } | 198 | } |
| 199 | 199 | ||
| 200 | interface MakeTestProviderAdapterHarnessOptions { | 200 | interface MakeTestProviderAdapterHarnessOptions { |
| 201 | readonly provider?: ProviderKind; | 201 | readonly provider?: ProviderDriverKind; |
| 202 | } | 202 | } |
| 203 | 203 | ||
| 204 | function nowIso(): string { | 204 | function nowIso(): string { |
| ... | @@ -206,7 +206,7 @@ function nowIso(): string { | ... | @@ -206,7 +206,7 @@ function nowIso(): string { |
| 206 | } | 206 | } |
| 207 | 207 | ||
| 208 | function sessionNotFound( | 208 | function sessionNotFound( |
| 209 | provider: ProviderKind, | 209 | provider: ProviderDriverKind, |
| 210 | threadId: ThreadId, | 210 | threadId: ThreadId, |
| 211 | ): ProviderAdapterSessionNotFoundError { | 211 | ): ProviderAdapterSessionNotFoundError { |
| 212 | return new ProviderAdapterSessionNotFoundError({ | 212 | return new ProviderAdapterSessionNotFoundError({ |
| ... | @@ -216,7 +216,7 @@ function sessionNotFound( | ... | @@ -216,7 +216,7 @@ function sessionNotFound( |
| 216 | } | 216 | } |
| 217 | 217 | ||
| 218 | function missingSessionEffect( | 218 | function missingSessionEffect( |
| 219 | provider: ProviderKind, | 219 | provider: ProviderDriverKind, |
| 220 | threadId: ThreadId, | 220 | threadId: ThreadId, |
| 221 | ): Effect.Effect<never, ProviderAdapterError> { | 221 | ): Effect.Effect<never, ProviderAdapterError> { |
| 222 | return Effect.fail(sessionNotFound(provider, threadId)); | 222 | return Effect.fail(sessionNotFound(provider, threadId)); |
| ... | @@ -224,7 +224,7 @@ function missingSessionEffect( | ... | @@ -224,7 +224,7 @@ function missingSessionEffect( |
| 224 | 224 | ||
| 225 | export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => | 225 | export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => |
| 226 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 227 | const provider = options?.provider ?? "codex"; | 227 | const provider = options?.provider ?? ProviderDriverKind.make("codex"); |
| 228 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 228 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 229 | let sessionCount = 0; | 229 | let sessionCount = 0; |
| 230 | const sessions = new Map<ThreadId, SessionState>(); | 230 | const sessions = new Map<ThreadId, SessionState>(); |
| ... | @@ -257,6 +257,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter | ... | @@ -257,6 +257,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 257 | 257 | ||
| 258 | const session: ProviderSession = { | 258 | const session: ProviderSession = { |
| 259 | provider, | 259 | provider, |
| 260 | ...(input.providerInstanceId !== undefined | ||
| 261 | ? { providerInstanceId: input.providerInstanceId } | ||
| 262 | : {}), | ||
| 260 | status: "ready", | 263 | status: "ready", |
| 261 | runtimeMode: input.runtimeMode, | 264 | runtimeMode: input.runtimeMode, |
| 262 | threadId, | 265 | threadId, |
apps/server/integration/fixtures/providerRuntime.ts+2-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { EventId, RuntimeRequestId } from "@t3tools/contracts"; | 1 | import { EventId, ProviderDriverKind, RuntimeRequestId } from "@t3tools/contracts"; |
| 2 | import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts"; | 2 | import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts"; |
| 3 | 3 | ||
| 4 | const PROVIDER = "codex" as const; | 4 | const PROVIDER = ProviderDriverKind.make("codex"); |
| 5 | const SESSION_ID = "fixture-session"; | 5 | const SESSION_ID = "fixture-session"; |
| 6 | const THREAD_ID = "fixture-thread"; | 6 | const THREAD_ID = "fixture-thread"; |
| 7 | const TURN_ID = "fixture-turn"; | 7 | const TURN_ID = "fixture-turn"; |
apps/server/integration/orchestrationEngine.integration.test.ts+136-42| ... | @@ -4,14 +4,17 @@ import path from "node:path"; | ... | @@ -4,14 +4,17 @@ import path from "node:path"; |
| 4 | import { | 4 | import { |
| 5 | ApprovalRequestId, | 5 | ApprovalRequestId, |
| 6 | CommandId, | 6 | CommandId, |
| 7 | defaultInstanceIdForDriver, | ||
| 7 | DEFAULT_PROVIDER_INTERACTION_MODE, | 8 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 9 | DEFAULT_MODEL, | ||
| 8 | DEFAULT_MODEL_BY_PROVIDER, | 10 | DEFAULT_MODEL_BY_PROVIDER, |
| 9 | EventId, | 11 | EventId, |
| 10 | MessageId, | 12 | MessageId, |
| 11 | ProjectId, | 13 | ProjectId, |
| 12 | ProviderKind, | 14 | ProviderDriverKind, |
| 13 | ThreadId, | 15 | ThreadId, |
| 14 | ModelSelection, | 16 | ModelSelection, |
| 17 | ProviderInstanceId, | ||
| 15 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 16 | import { assert, it } from "@effect/vitest"; | 19 | import { assert, it } from "@effect/vitest"; |
| 17 | import { Effect, Option, Schema } from "effect"; | 20 | import { Effect, Option, Schema } from "effect"; |
| ... | @@ -39,7 +42,9 @@ const PROJECT_ID = asProjectId("project-1"); | ... | @@ -39,7 +42,9 @@ const PROJECT_ID = asProjectId("project-1"); |
| 39 | const THREAD_ID = ThreadId.make("thread-1"); | 42 | const THREAD_ID = ThreadId.make("thread-1"); |
| 40 | const FIXTURE_TURN_ID = "fixture-turn"; | 43 | const FIXTURE_TURN_ID = "fixture-turn"; |
| 41 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); | 44 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); |
| 42 | type IntegrationProvider = ProviderKind; | 45 | type IntegrationProvider = ProviderDriverKind; |
| 46 | const CODEX_PROVIDER = ProviderDriverKind.make("codex"); | ||
| 47 | const CLAUDE_AGENT_PROVIDER = ProviderDriverKind.make("claudeAgent"); | ||
| 43 | 48 | ||
| 44 | function nowIso() { | 49 | function nowIso() { |
| 45 | return new Date().toISOString(); | 50 | return new Date().toISOString(); |
| ... | @@ -74,7 +79,11 @@ function waitForSync<A>( | ... | @@ -74,7 +79,11 @@ function waitForSync<A>( |
| 74 | }); | 79 | }); |
| 75 | } | 80 | } |
| 76 | 81 | ||
| 77 | function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") { | 82 | function runtimeBase( |
| 83 | eventId: string, | ||
| 84 | createdAt: string, | ||
| 85 | provider: IntegrationProvider = CODEX_PROVIDER, | ||
| 86 | ) { | ||
| 78 | return { | 87 | return { |
| 79 | eventId: asEventId(eventId), | 88 | eventId: asEventId(eventId), |
| 80 | provider, | 89 | provider, |
| ... | @@ -84,7 +93,7 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr | ... | @@ -84,7 +93,7 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr |
| 84 | 93 | ||
| 85 | function withHarness<A, E>( | 94 | function withHarness<A, E>( |
| 86 | use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>, | 95 | use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>, |
| 87 | provider: IntegrationProvider = "codex", | 96 | provider: IntegrationProvider = CODEX_PROVIDER, |
| 88 | ) { | 97 | ) { |
| 89 | return Effect.acquireUseRelease( | 98 | return Effect.acquireUseRelease( |
| 90 | makeOrchestrationIntegrationHarness({ provider }), | 99 | makeOrchestrationIntegrationHarness({ provider }), |
| ... | @@ -97,7 +106,7 @@ function withRealCodexHarness<A, E>( | ... | @@ -97,7 +106,7 @@ function withRealCodexHarness<A, E>( |
| 97 | use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>, | 106 | use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>, |
| 98 | ) { | 107 | ) { |
| 99 | return Effect.acquireUseRelease( | 108 | return Effect.acquireUseRelease( |
| 100 | makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), | 109 | makeOrchestrationIntegrationHarness({ provider: CODEX_PROVIDER, realCodex: true }), |
| 101 | use, | 110 | use, |
| 102 | (harness) => harness.dispose, | 111 | (harness) => harness.dispose, |
| 103 | ).pipe(Effect.provide(NodeServices.layer)); | 112 | ).pipe(Effect.provide(NodeServices.layer)); |
| ... | @@ -106,8 +115,9 @@ function withRealCodexHarness<A, E>( | ... | @@ -106,8 +115,9 @@ function withRealCodexHarness<A, E>( |
| 106 | const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | 115 | const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 107 | Effect.gen(function* () { | 116 | Effect.gen(function* () { |
| 108 | const createdAt = nowIso(); | 117 | const createdAt = nowIso(); |
| 109 | const provider = harness.adapterHarness?.provider ?? "codex"; | 118 | const provider = harness.adapterHarness?.provider ?? CODEX_PROVIDER; |
| 110 | const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; | 119 | const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; |
| 120 | const instanceId = defaultInstanceIdForDriver(provider); | ||
| 111 | 121 | ||
| 112 | yield* harness.engine.dispatch({ | 122 | yield* harness.engine.dispatch({ |
| 113 | type: "project.create", | 123 | type: "project.create", |
| ... | @@ -116,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | ... | @@ -116,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 116 | title: "Integration Project", | 126 | title: "Integration Project", |
| 117 | workspaceRoot: harness.workspaceDir, | 127 | workspaceRoot: harness.workspaceDir, |
| 118 | defaultModelSelection: { | 128 | defaultModelSelection: { |
| 119 | provider, | 129 | instanceId, |
| 120 | model: defaultModel, | 130 | model: defaultModel, |
| 121 | }, | 131 | }, |
| 122 | createdAt, | 132 | createdAt, |
| ... | @@ -129,7 +139,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | ... | @@ -129,7 +139,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 129 | projectId: PROJECT_ID, | 139 | projectId: PROJECT_ID, |
| 130 | title: "Integration Thread", | 140 | title: "Integration Thread", |
| 131 | modelSelection: { | 141 | modelSelection: { |
| 132 | provider, | 142 | instanceId, |
| 133 | model: defaultModel, | 143 | model: defaultModel, |
| 134 | }, | 144 | }, |
| 135 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 145 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -265,7 +275,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( | ... | @@ -265,7 +275,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 265 | title: "Integration Project", | 275 | title: "Integration Project", |
| 266 | workspaceRoot: harness.workspaceDir, | 276 | workspaceRoot: harness.workspaceDir, |
| 267 | defaultModelSelection: { | 277 | defaultModelSelection: { |
| 268 | provider: "codex", | 278 | instanceId: ProviderInstanceId.make("codex"), |
| 269 | model: "gpt-5.3-codex", | 279 | model: "gpt-5.3-codex", |
| 270 | }, | 280 | }, |
| 271 | createdAt, | 281 | createdAt, |
| ... | @@ -278,7 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( | ... | @@ -278,7 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 278 | projectId: PROJECT_ID, | 288 | projectId: PROJECT_ID, |
| 279 | title: "Integration Thread", | 289 | title: "Integration Thread", |
| 280 | modelSelection: { | 290 | modelSelection: { |
| 281 | provider: "codex", | 291 | instanceId: ProviderInstanceId.make("codex"), |
| 282 | model: "gpt-5.3-codex", | 292 | model: "gpt-5.3-codex", |
| 283 | }, | 293 | }, |
| 284 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 294 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -912,20 +922,32 @@ it.live("starts a claudeAgent session on first turn when provider is requested", | ... | @@ -912,20 +922,32 @@ it.live("starts a claudeAgent session on first turn when provider is requested", |
| 912 | events: [ | 922 | events: [ |
| 913 | { | 923 | { |
| 914 | type: "turn.started", | 924 | type: "turn.started", |
| 915 | ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeAgent"), | 925 | ...runtimeBase( |
| 926 | "evt-claude-start-1", | ||
| 927 | "2026-02-24T10:10:00.000Z", | ||
| 928 | CLAUDE_AGENT_PROVIDER, | ||
| 929 | ), | ||
| 916 | threadId: THREAD_ID, | 930 | threadId: THREAD_ID, |
| 917 | turnId: FIXTURE_TURN_ID, | 931 | turnId: FIXTURE_TURN_ID, |
| 918 | }, | 932 | }, |
| 919 | { | 933 | { |
| 920 | type: "message.delta", | 934 | type: "message.delta", |
| 921 | ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"), | 935 | ...runtimeBase( |
| 936 | "evt-claude-start-2", | ||
| 937 | "2026-02-24T10:10:00.050Z", | ||
| 938 | CLAUDE_AGENT_PROVIDER, | ||
| 939 | ), | ||
| 922 | threadId: THREAD_ID, | 940 | threadId: THREAD_ID, |
| 923 | turnId: FIXTURE_TURN_ID, | 941 | turnId: FIXTURE_TURN_ID, |
| 924 | delta: "Claude first turn.\n", | 942 | delta: "Claude first turn.\n", |
| 925 | }, | 943 | }, |
| 926 | { | 944 | { |
| 927 | type: "turn.completed", | 945 | type: "turn.completed", |
| 928 | ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"), | 946 | ...runtimeBase( |
| 947 | "evt-claude-start-3", | ||
| 948 | "2026-02-24T10:10:00.100Z", | ||
| 949 | CLAUDE_AGENT_PROVIDER, | ||
| 950 | ), | ||
| 929 | threadId: THREAD_ID, | 951 | threadId: THREAD_ID, |
| 930 | turnId: FIXTURE_TURN_ID, | 952 | turnId: FIXTURE_TURN_ID, |
| 931 | status: "completed", | 953 | status: "completed", |
| ... | @@ -939,7 +961,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", | ... | @@ -939,7 +961,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", |
| 939 | messageId: "msg-user-claude-initial", | 961 | messageId: "msg-user-claude-initial", |
| 940 | text: "Use Claude", | 962 | text: "Use Claude", |
| 941 | modelSelection: { | 963 | modelSelection: { |
| 942 | provider: "claudeAgent", | 964 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 943 | model: "claude-sonnet-4-6", | 965 | model: "claude-sonnet-4-6", |
| 944 | }, | 966 | }, |
| 945 | }); | 967 | }); |
| ... | @@ -955,7 +977,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", | ... | @@ -955,7 +977,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", |
| 955 | ); | 977 | ); |
| 956 | assert.equal(thread.session?.providerName, "claudeAgent"); | 978 | assert.equal(thread.session?.providerName, "claudeAgent"); |
| 957 | }), | 979 | }), |
| 958 | "claudeAgent", | 980 | CLAUDE_AGENT_PROVIDER, |
| 959 | ), | 981 | ), |
| 960 | ); | 982 | ); |
| 961 | 983 | ||
| ... | @@ -969,20 +991,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re | ... | @@ -969,20 +991,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re |
| 969 | events: [ | 991 | events: [ |
| 970 | { | 992 | { |
| 971 | type: "turn.started", | 993 | type: "turn.started", |
| 972 | ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"), | 994 | ...runtimeBase( |
| 995 | "evt-claude-recover-1", | ||
| 996 | "2026-02-24T10:11:00.000Z", | ||
| 997 | CLAUDE_AGENT_PROVIDER, | ||
| 998 | ), | ||
| 973 | threadId: THREAD_ID, | 999 | threadId: THREAD_ID, |
| 974 | turnId: FIXTURE_TURN_ID, | 1000 | turnId: FIXTURE_TURN_ID, |
| 975 | }, | 1001 | }, |
| 976 | { | 1002 | { |
| 977 | type: "message.delta", | 1003 | type: "message.delta", |
| 978 | ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), | 1004 | ...runtimeBase( |
| 1005 | "evt-claude-recover-2", | ||
| 1006 | "2026-02-24T10:11:00.050Z", | ||
| 1007 | CLAUDE_AGENT_PROVIDER, | ||
| 1008 | ), | ||
| 979 | threadId: THREAD_ID, | 1009 | threadId: THREAD_ID, |
| 980 | turnId: FIXTURE_TURN_ID, | 1010 | turnId: FIXTURE_TURN_ID, |
| 981 | delta: "Turn before restart.\n", | 1011 | delta: "Turn before restart.\n", |
| 982 | }, | 1012 | }, |
| 983 | { | 1013 | { |
| 984 | type: "turn.completed", | 1014 | type: "turn.completed", |
| 985 | ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"), | 1015 | ...runtimeBase( |
| 1016 | "evt-claude-recover-3", | ||
| 1017 | "2026-02-24T10:11:00.100Z", | ||
| 1018 | CLAUDE_AGENT_PROVIDER, | ||
| 1019 | ), | ||
| 986 | threadId: THREAD_ID, | 1020 | threadId: THREAD_ID, |
| 987 | turnId: FIXTURE_TURN_ID, | 1021 | turnId: FIXTURE_TURN_ID, |
| 988 | status: "completed", | 1022 | status: "completed", |
| ... | @@ -996,7 +1030,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re | ... | @@ -996,7 +1030,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re |
| 996 | messageId: "msg-user-claude-recover-1", | 1030 | messageId: "msg-user-claude-recover-1", |
| 997 | text: "Before restart", | 1031 | text: "Before restart", |
| 998 | modelSelection: { | 1032 | modelSelection: { |
| 999 | provider: "claudeAgent", | 1033 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 1000 | model: "claude-sonnet-4-6", | 1034 | model: "claude-sonnet-4-6", |
| 1001 | }, | 1035 | }, |
| 1002 | }); | 1036 | }); |
| ... | @@ -1018,20 +1052,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re | ... | @@ -1018,20 +1052,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re |
| 1018 | events: [ | 1052 | events: [ |
| 1019 | { | 1053 | { |
| 1020 | type: "turn.started", | 1054 | type: "turn.started", |
| 1021 | ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), | 1055 | ...runtimeBase( |
| 1056 | "evt-claude-recover-4", | ||
| 1057 | "2026-02-24T10:11:01.000Z", | ||
| 1058 | CLAUDE_AGENT_PROVIDER, | ||
| 1059 | ), | ||
| 1022 | threadId: THREAD_ID, | 1060 | threadId: THREAD_ID, |
| 1023 | turnId: FIXTURE_TURN_ID, | 1061 | turnId: FIXTURE_TURN_ID, |
| 1024 | }, | 1062 | }, |
| 1025 | { | 1063 | { |
| 1026 | type: "message.delta", | 1064 | type: "message.delta", |
| 1027 | ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), | 1065 | ...runtimeBase( |
| 1066 | "evt-claude-recover-5", | ||
| 1067 | "2026-02-24T10:11:01.050Z", | ||
| 1068 | CLAUDE_AGENT_PROVIDER, | ||
| 1069 | ), | ||
| 1028 | threadId: THREAD_ID, | 1070 | threadId: THREAD_ID, |
| 1029 | turnId: FIXTURE_TURN_ID, | 1071 | turnId: FIXTURE_TURN_ID, |
| 1030 | delta: "Turn after restart.\n", | 1072 | delta: "Turn after restart.\n", |
| 1031 | }, | 1073 | }, |
| 1032 | { | 1074 | { |
| 1033 | type: "turn.completed", | 1075 | type: "turn.completed", |
| 1034 | ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"), | 1076 | ...runtimeBase( |
| 1077 | "evt-claude-recover-6", | ||
| 1078 | "2026-02-24T10:11:01.100Z", | ||
| 1079 | CLAUDE_AGENT_PROVIDER, | ||
| 1080 | ), | ||
| 1035 | threadId: THREAD_ID, | 1081 | threadId: THREAD_ID, |
| 1036 | turnId: FIXTURE_TURN_ID, | 1082 | turnId: FIXTURE_TURN_ID, |
| 1037 | status: "completed", | 1083 | status: "completed", |
| ... | @@ -1063,7 +1109,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re | ... | @@ -1063,7 +1109,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re |
| 1063 | assert.equal(recoveredThread.session?.providerName, "claudeAgent"); | 1109 | assert.equal(recoveredThread.session?.providerName, "claudeAgent"); |
| 1064 | assert.equal(recoveredThread.session?.threadId, "thread-1"); | 1110 | assert.equal(recoveredThread.session?.threadId, "thread-1"); |
| 1065 | }), | 1111 | }), |
| 1066 | "claudeAgent", | 1112 | CLAUDE_AGENT_PROVIDER, |
| 1067 | ), | 1113 | ), |
| 1068 | ); | 1114 | ); |
| 1069 | 1115 | ||
| ... | @@ -1077,13 +1123,21 @@ it.live("forwards claudeAgent approval responses to the provider session", () => | ... | @@ -1077,13 +1123,21 @@ it.live("forwards claudeAgent approval responses to the provider session", () => |
| 1077 | events: [ | 1123 | events: [ |
| 1078 | { | 1124 | { |
| 1079 | type: "turn.started", | 1125 | type: "turn.started", |
| 1080 | ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeAgent"), | 1126 | ...runtimeBase( |
| 1127 | "evt-claude-approval-1", | ||
| 1128 | "2026-02-24T10:12:00.000Z", | ||
| 1129 | CLAUDE_AGENT_PROVIDER, | ||
| 1130 | ), | ||
| 1081 | threadId: THREAD_ID, | 1131 | threadId: THREAD_ID, |
| 1082 | turnId: FIXTURE_TURN_ID, | 1132 | turnId: FIXTURE_TURN_ID, |
| 1083 | }, | 1133 | }, |
| 1084 | { | 1134 | { |
| 1085 | type: "approval.requested", | 1135 | type: "approval.requested", |
| 1086 | ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), | 1136 | ...runtimeBase( |
| 1137 | "evt-claude-approval-2", | ||
| 1138 | "2026-02-24T10:12:00.050Z", | ||
| 1139 | CLAUDE_AGENT_PROVIDER, | ||
| 1140 | ), | ||
| 1087 | threadId: THREAD_ID, | 1141 | threadId: THREAD_ID, |
| 1088 | turnId: FIXTURE_TURN_ID, | 1142 | turnId: FIXTURE_TURN_ID, |
| 1089 | requestId: APPROVAL_REQUEST_ID, | 1143 | requestId: APPROVAL_REQUEST_ID, |
| ... | @@ -1092,7 +1146,11 @@ it.live("forwards claudeAgent approval responses to the provider session", () => | ... | @@ -1092,7 +1146,11 @@ it.live("forwards claudeAgent approval responses to the provider session", () => |
| 1092 | }, | 1146 | }, |
| 1093 | { | 1147 | { |
| 1094 | type: "turn.completed", | 1148 | type: "turn.completed", |
| 1095 | ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"), | 1149 | ...runtimeBase( |
| 1150 | "evt-claude-approval-3", | ||
| 1151 | "2026-02-24T10:12:00.100Z", | ||
| 1152 | CLAUDE_AGENT_PROVIDER, | ||
| 1153 | ), | ||
| 1096 | threadId: THREAD_ID, | 1154 | threadId: THREAD_ID, |
| 1097 | turnId: FIXTURE_TURN_ID, | 1155 | turnId: FIXTURE_TURN_ID, |
| 1098 | status: "completed", | 1156 | status: "completed", |
| ... | @@ -1106,7 +1164,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => | ... | @@ -1106,7 +1164,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => |
| 1106 | messageId: "msg-user-claude-approval", | 1164 | messageId: "msg-user-claude-approval", |
| 1107 | text: "Need approval", | 1165 | text: "Need approval", |
| 1108 | modelSelection: { | 1166 | modelSelection: { |
| 1109 | provider: "claudeAgent", | 1167 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 1110 | model: "claude-sonnet-4-6", | 1168 | model: "claude-sonnet-4-6", |
| 1111 | }, | 1169 | }, |
| 1112 | }); | 1170 | }); |
| ... | @@ -1137,7 +1195,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => | ... | @@ -1137,7 +1195,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => |
| 1137 | ); | 1195 | ); |
| 1138 | assert.equal(approvalResponses[0]?.decision, "accept"); | 1196 | assert.equal(approvalResponses[0]?.decision, "accept"); |
| 1139 | }), | 1197 | }), |
| 1140 | "claudeAgent", | 1198 | CLAUDE_AGENT_PROVIDER, |
| 1141 | ), | 1199 | ), |
| 1142 | ); | 1200 | ); |
| 1143 | 1201 | ||
| ... | @@ -1151,20 +1209,32 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => | ... | @@ -1151,20 +1209,32 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => |
| 1151 | events: [ | 1209 | events: [ |
| 1152 | { | 1210 | { |
| 1153 | type: "turn.started", | 1211 | type: "turn.started", |
| 1154 | ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeAgent"), | 1212 | ...runtimeBase( |
| 1213 | "evt-claude-interrupt-1", | ||
| 1214 | "2026-02-24T10:13:00.000Z", | ||
| 1215 | CLAUDE_AGENT_PROVIDER, | ||
| 1216 | ), | ||
| 1155 | threadId: THREAD_ID, | 1217 | threadId: THREAD_ID, |
| 1156 | turnId: FIXTURE_TURN_ID, | 1218 | turnId: FIXTURE_TURN_ID, |
| 1157 | }, | 1219 | }, |
| 1158 | { | 1220 | { |
| 1159 | type: "message.delta", | 1221 | type: "message.delta", |
| 1160 | ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"), | 1222 | ...runtimeBase( |
| 1223 | "evt-claude-interrupt-2", | ||
| 1224 | "2026-02-24T10:13:00.050Z", | ||
| 1225 | CLAUDE_AGENT_PROVIDER, | ||
| 1226 | ), | ||
| 1161 | threadId: THREAD_ID, | 1227 | threadId: THREAD_ID, |
| 1162 | turnId: FIXTURE_TURN_ID, | 1228 | turnId: FIXTURE_TURN_ID, |
| 1163 | delta: "Long running output.\n", | 1229 | delta: "Long running output.\n", |
| 1164 | }, | 1230 | }, |
| 1165 | { | 1231 | { |
| 1166 | type: "turn.completed", | 1232 | type: "turn.completed", |
| 1167 | ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"), | 1233 | ...runtimeBase( |
| 1234 | "evt-claude-interrupt-3", | ||
| 1235 | "2026-02-24T10:13:00.100Z", | ||
| 1236 | CLAUDE_AGENT_PROVIDER, | ||
| 1237 | ), | ||
| 1168 | threadId: THREAD_ID, | 1238 | threadId: THREAD_ID, |
| 1169 | turnId: FIXTURE_TURN_ID, | 1239 | turnId: FIXTURE_TURN_ID, |
| 1170 | status: "completed", | 1240 | status: "completed", |
| ... | @@ -1178,7 +1248,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => | ... | @@ -1178,7 +1248,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => |
| 1178 | messageId: "msg-user-claude-interrupt", | 1248 | messageId: "msg-user-claude-interrupt", |
| 1179 | text: "Start long turn", | 1249 | text: "Start long turn", |
| 1180 | modelSelection: { | 1250 | modelSelection: { |
| 1181 | provider: "claudeAgent", | 1251 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 1182 | model: "claude-sonnet-4-6", | 1252 | model: "claude-sonnet-4-6", |
| 1183 | }, | 1253 | }, |
| 1184 | }); | 1254 | }); |
| ... | @@ -1206,7 +1276,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => | ... | @@ -1206,7 +1276,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => |
| 1206 | ); | 1276 | ); |
| 1207 | assert.equal(interruptCalls.length, 1); | 1277 | assert.equal(interruptCalls.length, 1); |
| 1208 | }), | 1278 | }), |
| 1209 | "claudeAgent", | 1279 | CLAUDE_AGENT_PROVIDER, |
| 1210 | ), | 1280 | ), |
| 1211 | ); | 1281 | ); |
| 1212 | 1282 | ||
| ... | @@ -1220,20 +1290,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", | ... | @@ -1220,20 +1290,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", |
| 1220 | events: [ | 1290 | events: [ |
| 1221 | { | 1291 | { |
| 1222 | type: "turn.started", | 1292 | type: "turn.started", |
| 1223 | ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), | 1293 | ...runtimeBase( |
| 1294 | "evt-claude-revert-1", | ||
| 1295 | "2026-02-24T10:14:00.000Z", | ||
| 1296 | CLAUDE_AGENT_PROVIDER, | ||
| 1297 | ), | ||
| 1224 | threadId: THREAD_ID, | 1298 | threadId: THREAD_ID, |
| 1225 | turnId: FIXTURE_TURN_ID, | 1299 | turnId: FIXTURE_TURN_ID, |
| 1226 | }, | 1300 | }, |
| 1227 | { | 1301 | { |
| 1228 | type: "message.delta", | 1302 | type: "message.delta", |
| 1229 | ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), | 1303 | ...runtimeBase( |
| 1304 | "evt-claude-revert-2", | ||
| 1305 | "2026-02-24T10:14:00.050Z", | ||
| 1306 | CLAUDE_AGENT_PROVIDER, | ||
| 1307 | ), | ||
| 1230 | threadId: THREAD_ID, | 1308 | threadId: THREAD_ID, |
| 1231 | turnId: FIXTURE_TURN_ID, | 1309 | turnId: FIXTURE_TURN_ID, |
| 1232 | delta: "README -> v2\n", | 1310 | delta: "README -> v2\n", |
| 1233 | }, | 1311 | }, |
| 1234 | { | 1312 | { |
| 1235 | type: "turn.completed", | 1313 | type: "turn.completed", |
| 1236 | ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), | 1314 | ...runtimeBase( |
| 1315 | "evt-claude-revert-3", | ||
| 1316 | "2026-02-24T10:14:00.100Z", | ||
| 1317 | CLAUDE_AGENT_PROVIDER, | ||
| 1318 | ), | ||
| 1237 | threadId: THREAD_ID, | 1319 | threadId: THREAD_ID, |
| 1238 | turnId: FIXTURE_TURN_ID, | 1320 | turnId: FIXTURE_TURN_ID, |
| 1239 | status: "completed", | 1321 | status: "completed", |
| ... | @@ -1251,7 +1333,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", | ... | @@ -1251,7 +1333,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", |
| 1251 | messageId: "msg-user-claude-revert-1", | 1333 | messageId: "msg-user-claude-revert-1", |
| 1252 | text: "First Claude edit", | 1334 | text: "First Claude edit", |
| 1253 | modelSelection: { | 1335 | modelSelection: { |
| 1254 | provider: "claudeAgent", | 1336 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 1255 | model: "claude-sonnet-4-6", | 1337 | model: "claude-sonnet-4-6", |
| 1256 | }, | 1338 | }, |
| 1257 | }); | 1339 | }); |
| ... | @@ -1266,20 +1348,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", | ... | @@ -1266,20 +1348,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", |
| 1266 | events: [ | 1348 | events: [ |
| 1267 | { | 1349 | { |
| 1268 | type: "turn.started", | 1350 | type: "turn.started", |
| 1269 | ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), | 1351 | ...runtimeBase( |
| 1352 | "evt-claude-revert-4", | ||
| 1353 | "2026-02-24T10:14:01.000Z", | ||
| 1354 | CLAUDE_AGENT_PROVIDER, | ||
| 1355 | ), | ||
| 1270 | threadId: THREAD_ID, | 1356 | threadId: THREAD_ID, |
| 1271 | turnId: FIXTURE_TURN_ID, | 1357 | turnId: FIXTURE_TURN_ID, |
| 1272 | }, | 1358 | }, |
| 1273 | { | 1359 | { |
| 1274 | type: "message.delta", | 1360 | type: "message.delta", |
| 1275 | ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), | 1361 | ...runtimeBase( |
| 1362 | "evt-claude-revert-5", | ||
| 1363 | "2026-02-24T10:14:01.050Z", | ||
| 1364 | CLAUDE_AGENT_PROVIDER, | ||
| 1365 | ), | ||
| 1276 | threadId: THREAD_ID, | 1366 | threadId: THREAD_ID, |
| 1277 | turnId: FIXTURE_TURN_ID, | 1367 | turnId: FIXTURE_TURN_ID, |
| 1278 | delta: "README -> v3\n", | 1368 | delta: "README -> v3\n", |
| 1279 | }, | 1369 | }, |
| 1280 | { | 1370 | { |
| 1281 | type: "turn.completed", | 1371 | type: "turn.completed", |
| 1282 | ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), | 1372 | ...runtimeBase( |
| 1373 | "evt-claude-revert-6", | ||
| 1374 | "2026-02-24T10:14:01.100Z", | ||
| 1375 | CLAUDE_AGENT_PROVIDER, | ||
| 1376 | ), | ||
| 1283 | threadId: THREAD_ID, | 1377 | threadId: THREAD_ID, |
| 1284 | turnId: FIXTURE_TURN_ID, | 1378 | turnId: FIXTURE_TURN_ID, |
| 1285 | status: "completed", | 1379 | status: "completed", |
| ... | @@ -1330,6 +1424,6 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", | ... | @@ -1330,6 +1424,6 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", |
| 1330 | ); | 1424 | ); |
| 1331 | assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); | 1425 | assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); |
| 1332 | }), | 1426 | }), |
| 1333 | "claudeAgent", | 1427 | CLAUDE_AGENT_PROVIDER, |
| 1334 | ), | 1428 | ), |
| 1335 | ); | 1429 | ); |
apps/server/integration/providerService.integration.test.ts+24-13| ... | @@ -1,13 +1,17 @@ | ... | @@ -1,13 +1,17 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; | 3 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { it, assert } from "@effect/vitest"; | 5 | import { it, assert } from "@effect/vitest"; |
| 6 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; | 6 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; |
| 7 | 7 | ||
| 8 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | ||
| 9 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 8 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 9 | import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; | ||
| 10 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 10 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 11 | import { | ||
| 12 | NoOpProviderEventLoggers, | ||
| 13 | ProviderEventLoggers, | ||
| 14 | } from "../src/provider/Layers/ProviderEventLoggers.ts"; | ||
| 11 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | 15 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| 12 | import { | 16 | import { |
| 13 | ProviderService, | 17 | ProviderService, |
| ... | @@ -29,6 +33,8 @@ import { | ... | @@ -29,6 +33,8 @@ import { |
| 29 | codexTurnTextFixture, | 33 | codexTurnTextFixture, |
| 30 | } from "./fixtures/providerRuntime.ts"; | 34 | } from "./fixtures/providerRuntime.ts"; |
| 31 | 35 | ||
| 36 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 37 | |||
| 32 | const makeWorkspaceDirectory = Effect.gen(function* () { | 38 | const makeWorkspaceDirectory = Effect.gen(function* () { |
| 33 | const fs = yield* FileSystem.FileSystem; | 39 | const fs = yield* FileSystem.FileSystem; |
| 34 | const pathService = yield* Path.Path; | 40 | const pathService = yield* Path.Path; |
| ... | @@ -47,13 +53,9 @@ const makeIntegrationFixture = Effect.gen(function* () { | ... | @@ -47,13 +53,9 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 47 | const cwd = yield* makeWorkspaceDirectory; | 53 | const cwd = yield* makeWorkspaceDirectory; |
| 48 | const harness = yield* makeTestProviderAdapterHarness(); | 54 | const harness = yield* makeTestProviderAdapterHarness(); |
| 49 | 55 | ||
| 50 | const registry: typeof ProviderAdapterRegistry.Service = { | 56 | const registry = makeAdapterRegistryMock({ |
| 51 | getByProvider: (provider) => | 57 | [ProviderDriverKind.make("codex")]: harness.adapter, |
| 52 | provider === "codex" | 58 | }); |
| 53 | ? Effect.succeed(harness.adapter) | ||
| 54 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 55 | listProviders: () => Effect.succeed(["codex"]), | ||
| 56 | }; | ||
| 57 | 59 | ||
| 58 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | 60 | const directoryLayer = ProviderSessionDirectoryLive.pipe( |
| 59 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | 61 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| ... | @@ -64,6 +66,7 @@ const makeIntegrationFixture = Effect.gen(function* () { | ... | @@ -64,6 +66,7 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 64 | Layer.succeed(ProviderAdapterRegistry, registry), | 66 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 65 | ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), | 67 | ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), |
| 66 | AnalyticsService.layerTest, | 68 | AnalyticsService.layerTest, |
| 69 | Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), | ||
| 67 | ).pipe(Layer.provide(SqlitePersistenceMemory)); | 70 | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 68 | 71 | ||
| 69 | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); | 72 | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); |
| ... | @@ -124,7 +127,8 @@ it.live("replays typed runtime fixture events", () => | ... | @@ -124,7 +127,8 @@ it.live("replays typed runtime fixture events", () => |
| 124 | const provider = yield* ProviderService; | 127 | const provider = yield* ProviderService; |
| 125 | const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { | 128 | const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { |
| 126 | threadId: ThreadId.make("thread-integration-typed"), | 129 | threadId: ThreadId.make("thread-integration-typed"), |
| 127 | provider: "codex", | 130 | provider: ProviderDriverKind.make("codex"), |
| 131 | providerInstanceId: codexInstanceId, | ||
| 128 | cwd: fixture.cwd, | 132 | cwd: fixture.cwd, |
| 129 | runtimeMode: "full-access", | 133 | runtimeMode: "full-access", |
| 130 | }); | 134 | }); |
| ... | @@ -142,6 +146,10 @@ it.live("replays typed runtime fixture events", () => | ... | @@ -142,6 +146,10 @@ it.live("replays typed runtime fixture events", () => |
| 142 | observedEvents.map((event) => event.type), | 146 | observedEvents.map((event) => event.type), |
| 143 | codexTurnTextFixture.map((event) => event.type), | 147 | codexTurnTextFixture.map((event) => event.type), |
| 144 | ); | 148 | ); |
| 149 | assert.deepEqual( | ||
| 150 | observedEvents.map((event) => event.providerInstanceId), | ||
| 151 | codexTurnTextFixture.map(() => codexInstanceId), | ||
| 152 | ); | ||
| 145 | }).pipe(Effect.provide(fixture.layer)); | 153 | }).pipe(Effect.provide(fixture.layer)); |
| 146 | }).pipe(Effect.provide(NodeServices.layer)), | 154 | }).pipe(Effect.provide(NodeServices.layer)), |
| 147 | ); | 155 | ); |
| ... | @@ -156,7 +164,8 @@ it.live("replays file-changing fixture turn events", () => | ... | @@ -156,7 +164,8 @@ it.live("replays file-changing fixture turn events", () => |
| 156 | const provider = yield* ProviderService; | 164 | const provider = yield* ProviderService; |
| 157 | const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { | 165 | const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { |
| 158 | threadId: ThreadId.make("thread-integration-tools"), | 166 | threadId: ThreadId.make("thread-integration-tools"), |
| 159 | provider: "codex", | 167 | provider: ProviderDriverKind.make("codex"), |
| 168 | providerInstanceId: codexInstanceId, | ||
| 160 | cwd: fixture.cwd, | 169 | cwd: fixture.cwd, |
| 161 | runtimeMode: "full-access", | 170 | runtimeMode: "full-access", |
| 162 | }); | 171 | }); |
| ... | @@ -192,7 +201,8 @@ it.live("runs multi-turn tool/approval flow", () => | ... | @@ -192,7 +201,8 @@ it.live("runs multi-turn tool/approval flow", () => |
| 192 | const provider = yield* ProviderService; | 201 | const provider = yield* ProviderService; |
| 193 | const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { | 202 | const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { |
| 194 | threadId: ThreadId.make("thread-integration-multi"), | 203 | threadId: ThreadId.make("thread-integration-multi"), |
| 195 | provider: "codex", | 204 | provider: ProviderDriverKind.make("codex"), |
| 205 | providerInstanceId: codexInstanceId, | ||
| 196 | cwd: fixture.cwd, | 206 | cwd: fixture.cwd, |
| 197 | runtimeMode: "full-access", | 207 | runtimeMode: "full-access", |
| 198 | }); | 208 | }); |
| ... | @@ -243,7 +253,8 @@ it.live("rolls back provider conversation state only", () => | ... | @@ -243,7 +253,8 @@ it.live("rolls back provider conversation state only", () => |
| 243 | const provider = yield* ProviderService; | 253 | const provider = yield* ProviderService; |
| 244 | const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { | 254 | const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { |
| 245 | threadId: ThreadId.make("thread-integration-rollback"), | 255 | threadId: ThreadId.make("thread-integration-rollback"), |
| 246 | provider: "codex", | 256 | provider: ProviderDriverKind.make("codex"), |
| 257 | providerInstanceId: codexInstanceId, | ||
| 247 | cwd: fixture.cwd, | 258 | cwd: fixture.cwd, |
| 248 | runtimeMode: "full-access", | 259 | runtimeMode: "full-access", |
| 249 | }); | 260 | }); |
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+172-139| ... | @@ -1,24 +1,18 @@ | ... | @@ -1,24 +1,18 @@ |
| 1 | import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it } from "@effect/vitest"; | 3 | import { it } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 4 | import { Effect, FileSystem, Layer, Path, Schema } from "effect"; |
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | 5 | import { createModelSelection } from "@t3tools/shared/model"; |
| 5 | import { expect } from "vitest"; | 6 | import { expect } from "vitest"; |
| 6 | 7 | ||
| 7 | import { ServerConfig } from "../../config.ts"; | 8 | import { ServerConfig } from "../../config.ts"; |
| 8 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 9 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; |
| 9 | import { sanitizeThreadTitle } from "../Utils.ts"; | 10 | import { sanitizeThreadTitle } from "../Utils.ts"; |
| 10 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; | 11 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; |
| 11 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 12 | 12 | ||
| 13 | const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( | 13 | const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { |
| 14 | Layer.provideMerge(ServerSettingsService.layerTest()), | 14 | prefix: "t3code-claude-text-generation-test-", |
| 15 | Layer.provideMerge( | 15 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 16 | ServerConfig.layerTest(process.cwd(), { | ||
| 17 | prefix: "t3code-claude-text-generation-test-", | ||
| 18 | }), | ||
| 19 | ), | ||
| 20 | Layer.provideMerge(NodeServices.layer), | ||
| 21 | ); | ||
| 22 | 16 | ||
| 23 | function makeFakeClaudeBinary(dir: string) { | 17 | function makeFakeClaudeBinary(dir: string) { |
| 24 | return Effect.gen(function* () { | 18 | return Effect.gen(function* () { |
| ... | @@ -52,6 +46,10 @@ function makeFakeClaudeBinary(dir: string) { | ... | @@ -52,6 +46,10 @@ function makeFakeClaudeBinary(dir: string) { |
| 52 | " exit 4", | 46 | " exit 4", |
| 53 | " }", | 47 | " }", |
| 54 | "fi", | 48 | "fi", |
| 49 | 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', | ||
| 50 | ' printf "%s\\n" "HOME was $HOME" >&2', | ||
| 51 | " exit 5", | ||
| 52 | "fi", | ||
| 55 | 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', | 53 | 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', |
| 56 | ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', | 54 | ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', |
| 57 | "fi", | 55 | "fi", |
| ... | @@ -73,23 +71,26 @@ function withFakeClaudeEnv<A, E, R>( | ... | @@ -73,23 +71,26 @@ function withFakeClaudeEnv<A, E, R>( |
| 73 | argsMustContain?: string; | 71 | argsMustContain?: string; |
| 74 | argsMustNotContain?: string; | 72 | argsMustNotContain?: string; |
| 75 | stdinMustContain?: string; | 73 | stdinMustContain?: string; |
| 74 | homeMustBe?: string; | ||
| 75 | claudeConfig?: Partial<ClaudeSettings>; | ||
| 76 | }, | 76 | }, |
| 77 | effect: Effect.Effect<A, E, R>, | 77 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, |
| 78 | ) { | 78 | ) { |
| 79 | return Effect.acquireUseRelease( | 79 | return Effect.gen(function* () { |
| 80 | Effect.gen(function* () { | 80 | const fs = yield* FileSystem.FileSystem; |
| 81 | const fs = yield* FileSystem.FileSystem; | 81 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); |
| 82 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); | 82 | const binDir = yield* makeFakeClaudeBinary(tempDir); |
| 83 | const binDir = yield* makeFakeClaudeBinary(tempDir); | 83 | const previousPath = process.env.PATH; |
| 84 | const previousPath = process.env.PATH; | 84 | const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; |
| 85 | const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; | 85 | const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; |
| 86 | const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; | 86 | const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; |
| 87 | const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; | 87 | const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; |
| 88 | const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | 88 | const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; |
| 89 | const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | 89 | const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; |
| 90 | const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | 90 | const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; |
| 91 | 91 | ||
| 92 | yield* Effect.sync(() => { | 92 | yield* Effect.acquireRelease( |
| 93 | Effect.sync(() => { | ||
| 93 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | 94 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; |
| 94 | process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; | 95 | process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; |
| 95 | 96 | ||
| ... | @@ -122,60 +123,65 @@ function withFakeClaudeEnv<A, E, R>( | ... | @@ -122,60 +123,65 @@ function withFakeClaudeEnv<A, E, R>( |
| 122 | } else { | 123 | } else { |
| 123 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | 124 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; |
| 124 | } | 125 | } |
| 125 | }); | ||
| 126 | |||
| 127 | return { | ||
| 128 | previousPath, | ||
| 129 | previousOutput, | ||
| 130 | previousExitCode, | ||
| 131 | previousStderr, | ||
| 132 | previousArgsMustContain, | ||
| 133 | previousArgsMustNotContain, | ||
| 134 | previousStdinMustContain, | ||
| 135 | }; | ||
| 136 | }), | ||
| 137 | () => effect, | ||
| 138 | (previous) => | ||
| 139 | Effect.sync(() => { | ||
| 140 | process.env.PATH = previous.previousPath; | ||
| 141 | 126 | ||
| 142 | if (previous.previousOutput === undefined) { | 127 | if (input.homeMustBe !== undefined) { |
| 143 | delete process.env.T3_FAKE_CLAUDE_OUTPUT; | 128 | process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; |
| 144 | } else { | 129 | } else { |
| 145 | process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput; | 130 | delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; |
| 146 | } | 131 | } |
| 132 | }), | ||
| 133 | () => | ||
| 134 | Effect.sync(() => { | ||
| 135 | process.env.PATH = previousPath; | ||
| 147 | 136 | ||
| 148 | if (previous.previousExitCode === undefined) { | 137 | if (previousOutput === undefined) { |
| 149 | delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; | 138 | delete process.env.T3_FAKE_CLAUDE_OUTPUT; |
| 150 | } else { | 139 | } else { |
| 151 | process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode; | 140 | process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; |
| 152 | } | 141 | } |
| 153 | 142 | ||
| 154 | if (previous.previousStderr === undefined) { | 143 | if (previousExitCode === undefined) { |
| 155 | delete process.env.T3_FAKE_CLAUDE_STDERR; | 144 | delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; |
| 156 | } else { | 145 | } else { |
| 157 | process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr; | 146 | process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; |
| 158 | } | 147 | } |
| 159 | 148 | ||
| 160 | if (previous.previousArgsMustContain === undefined) { | 149 | if (previousStderr === undefined) { |
| 161 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | 150 | delete process.env.T3_FAKE_CLAUDE_STDERR; |
| 162 | } else { | 151 | } else { |
| 163 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain; | 152 | process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; |
| 164 | } | 153 | } |
| 165 | 154 | ||
| 166 | if (previous.previousArgsMustNotContain === undefined) { | 155 | if (previousArgsMustContain === undefined) { |
| 167 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | 156 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; |
| 168 | } else { | 157 | } else { |
| 169 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain; | 158 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; |
| 170 | } | 159 | } |
| 171 | 160 | ||
| 172 | if (previous.previousStdinMustContain === undefined) { | 161 | if (previousArgsMustNotContain === undefined) { |
| 173 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | 162 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; |
| 174 | } else { | 163 | } else { |
| 175 | process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; | 164 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; |
| 176 | } | 165 | } |
| 177 | }), | 166 | |
| 178 | ); | 167 | if (previousStdinMustContain === undefined) { |
| 168 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 169 | } else { | ||
| 170 | process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; | ||
| 171 | } | ||
| 172 | |||
| 173 | if (previousHomeMustBe === undefined) { | ||
| 174 | delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 175 | } else { | ||
| 176 | process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; | ||
| 177 | } | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | |||
| 181 | const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {}); | ||
| 182 | const textGeneration = yield* makeClaudeTextGeneration(config); | ||
| 183 | return yield* effectFn(textGeneration); | ||
| 184 | }).pipe(Effect.scoped); | ||
| 179 | } | 185 | } |
| 180 | 186 | ||
| 181 | it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { | 187 | it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| ... | @@ -191,24 +197,23 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { | ... | @@ -191,24 +197,23 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| 191 | argsMustContain: '--settings {"alwaysThinkingEnabled":false}', | 197 | argsMustContain: '--settings {"alwaysThinkingEnabled":false}', |
| 192 | argsMustNotContain: "--effort", | 198 | argsMustNotContain: "--effort", |
| 193 | }, | 199 | }, |
| 194 | Effect.gen(function* () { | 200 | (textGeneration) => |
| 195 | const textGeneration = yield* TextGeneration; | 201 | Effect.gen(function* () { |
| 196 | 202 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 197 | const generated = yield* textGeneration.generateCommitMessage({ | 203 | cwd: process.cwd(), |
| 198 | cwd: process.cwd(), | 204 | branch: "feature/claude-effect", |
| 199 | branch: "feature/claude-effect", | 205 | stagedSummary: "M README.md", |
| 200 | stagedSummary: "M README.md", | 206 | stagedPatch: "diff --git a/README.md b/README.md", |
| 201 | stagedPatch: "diff --git a/README.md b/README.md", | 207 | modelSelection: { |
| 202 | modelSelection: { | 208 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ |
| 203 | ...createModelSelection("claudeAgent", "claude-haiku-4-5", [ | 209 | { id: "thinking", value: false }, |
| 204 | { id: "thinking", value: false }, | 210 | { id: "effort", value: "high" }, |
| 205 | { id: "effort", value: "high" }, | 211 | ]), |
| 206 | ]), | 212 | }, |
| 207 | }, | 213 | }); |
| 208 | }); | ||
| 209 | 214 | ||
| 210 | expect(generated.subject).toBe("Add important change"); | 215 | expect(generated.subject).toBe("Add important change"); |
| 211 | }), | 216 | }), |
| 212 | ), | 217 | ), |
| 213 | ); | 218 | ); |
| 214 | 219 | ||
| ... | @@ -223,26 +228,25 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { | ... | @@ -223,26 +228,25 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| 223 | }), | 228 | }), |
| 224 | argsMustContain: '--effort max --settings {"fastMode":true}', | 229 | argsMustContain: '--effort max --settings {"fastMode":true}', |
| 225 | }, | 230 | }, |
| 226 | Effect.gen(function* () { | 231 | (textGeneration) => |
| 227 | const textGeneration = yield* TextGeneration; | 232 | Effect.gen(function* () { |
| 228 | 233 | const generated = yield* textGeneration.generatePrContent({ | |
| 229 | const generated = yield* textGeneration.generatePrContent({ | 234 | cwd: process.cwd(), |
| 230 | cwd: process.cwd(), | 235 | baseBranch: "main", |
| 231 | baseBranch: "main", | 236 | headBranch: "feature/claude-effect", |
| 232 | headBranch: "feature/claude-effect", | 237 | commitSummary: "Improve orchestration", |
| 233 | commitSummary: "Improve orchestration", | 238 | diffSummary: "1 file changed", |
| 234 | diffSummary: "1 file changed", | 239 | diffPatch: "diff --git a/README.md b/README.md", |
| 235 | diffPatch: "diff --git a/README.md b/README.md", | 240 | modelSelection: { |
| 236 | modelSelection: { | 241 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ |
| 237 | ...createModelSelection("claudeAgent", "claude-opus-4-6", [ | 242 | { id: "effort", value: "max" }, |
| 238 | { id: "effort", value: "max" }, | 243 | { id: "fastMode", value: true }, |
| 239 | { id: "fastMode", value: true }, | 244 | ]), |
| 240 | ]), | 245 | }, |
| 241 | }, | 246 | }); |
| 242 | }); | ||
| 243 | 247 | ||
| 244 | expect(generated.title).toBe("Improve orchestration flow"); | 248 | expect(generated.title).toBe("Improve orchestration flow"); |
| 245 | }), | 249 | }), |
| 246 | ), | 250 | ), |
| 247 | ); | 251 | ); |
| 248 | 252 | ||
| ... | @@ -257,27 +261,57 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { | ... | @@ -257,27 +261,57 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| 257 | }), | 261 | }), |
| 258 | stdinMustContain: "You write concise thread titles for coding conversations.", | 262 | stdinMustContain: "You write concise thread titles for coding conversations.", |
| 259 | }, | 263 | }, |
| 260 | Effect.gen(function* () { | 264 | (textGeneration) => |
| 261 | const textGeneration = yield* TextGeneration; | 265 | Effect.gen(function* () { |
| 266 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 267 | cwd: process.cwd(), | ||
| 268 | message: "Please investigate reconnect failures after restarting the session.", | ||
| 269 | modelSelection: { | ||
| 270 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 271 | model: "claude-sonnet-4-6", | ||
| 272 | }, | ||
| 273 | }); | ||
| 262 | 274 | ||
| 263 | const generated = yield* textGeneration.generateThreadTitle({ | 275 | expect(generated.title).toBe( |
| 264 | cwd: process.cwd(), | 276 | sanitizeThreadTitle( |
| 265 | message: "Please investigate reconnect failures after restarting the session.", | 277 | '"Reconnect failures after restart because the session state does not recover"', |
| 266 | modelSelection: { | 278 | ), |
| 267 | provider: "claudeAgent", | 279 | ); |
| 268 | model: "claude-sonnet-4-6", | 280 | }), |
| 269 | }, | ||
| 270 | }); | ||
| 271 | |||
| 272 | expect(generated.title).toBe( | ||
| 273 | sanitizeThreadTitle( | ||
| 274 | '"Reconnect failures after restart because the session state does not recover"', | ||
| 275 | ), | ||
| 276 | ); | ||
| 277 | }), | ||
| 278 | ), | 281 | ), |
| 279 | ); | 282 | ); |
| 280 | 283 | ||
| 284 | it.effect("runs Claude text generation with the configured Claude HOME", () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const path = yield* Path.Path; | ||
| 287 | const claudeHome = path.join(process.cwd(), ".claude-work-test"); | ||
| 288 | return yield* withFakeClaudeEnv( | ||
| 289 | { | ||
| 290 | output: JSON.stringify({ | ||
| 291 | structured_output: { | ||
| 292 | title: "Use Claude home", | ||
| 293 | }, | ||
| 294 | }), | ||
| 295 | homeMustBe: claudeHome, | ||
| 296 | claudeConfig: { homePath: claudeHome }, | ||
| 297 | }, | ||
| 298 | (textGeneration) => | ||
| 299 | Effect.gen(function* () { | ||
| 300 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 301 | cwd: process.cwd(), | ||
| 302 | message: "thread title", | ||
| 303 | modelSelection: { | ||
| 304 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 305 | model: "claude-sonnet-4-6", | ||
| 306 | }, | ||
| 307 | }); | ||
| 308 | |||
| 309 | expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | }), | ||
| 313 | ); | ||
| 314 | |||
| 281 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => | 315 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => |
| 282 | withFakeClaudeEnv( | 316 | withFakeClaudeEnv( |
| 283 | { | 317 | { |
| ... | @@ -287,20 +321,19 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { | ... | @@ -287,20 +321,19 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| 287 | }, | 321 | }, |
| 288 | }), | 322 | }), |
| 289 | }, | 323 | }, |
| 290 | Effect.gen(function* () { | 324 | (textGeneration) => |
| 291 | const textGeneration = yield* TextGeneration; | 325 | Effect.gen(function* () { |
| 292 | 326 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 293 | const generated = yield* textGeneration.generateThreadTitle({ | 327 | cwd: process.cwd(), |
| 294 | cwd: process.cwd(), | 328 | message: "Name this thread.", |
| 295 | message: "Name this thread.", | 329 | modelSelection: { |
| 296 | modelSelection: { | 330 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 297 | provider: "claudeAgent", | 331 | model: "claude-sonnet-4-6", |
| 298 | model: "claude-sonnet-4-6", | 332 | }, |
| 299 | }, | 333 | }); |
| 300 | }); | ||
| 301 | 334 | ||
| 302 | expect(generated.title).toBe("New thread"); | 335 | expect(generated.title).toBe("New thread"); |
| 303 | }), | 336 | }), |
| 304 | ), | 337 | ), |
| 305 | ); | 338 | ); |
| 306 | }); | 339 | }); |
apps/server/src/git/Layers/ClaudeTextGeneration.ts+12-43| ... | @@ -7,14 +7,14 @@ | ... | @@ -7,14 +7,14 @@ |
| 7 | * | 7 | * |
| 8 | * @module ClaudeTextGeneration | 8 | * @module ClaudeTextGeneration |
| 9 | */ | 9 | */ |
| 10 | import { Effect, Layer, Option, Schema, Stream } from "effect"; | 10 | import { Effect, Option, Schema, Stream } from "effect"; |
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 12 | ||
| 13 | import { ClaudeModelSelection } from "@t3tools/contracts"; | 13 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; |
| 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 15 | 15 | ||
| 16 | import { TextGenerationError } from "@t3tools/contracts"; | 16 | import { TextGenerationError } from "@t3tools/contracts"; |
| 17 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | 17 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; |
| 18 | import { | 18 | import { |
| 19 | buildBranchNamePrompt, | 19 | buildBranchNamePrompt, |
| 20 | buildCommitMessagePrompt, | 20 | buildCommitMessagePrompt, |
| ... | @@ -38,7 +38,7 @@ import { | ... | @@ -38,7 +38,7 @@ import { |
| 38 | resolveClaudeApiModelId, | 38 | resolveClaudeApiModelId, |
| 39 | resolveClaudeEffort, | 39 | resolveClaudeEffort, |
| 40 | } from "../../provider/Layers/ClaudeProvider.ts"; | 40 | } from "../../provider/Layers/ClaudeProvider.ts"; |
| 41 | import { ServerSettingsService } from "../../serverSettings.ts"; | 41 | import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; |
| 42 | 42 | ||
| 43 | const CLAUDE_TIMEOUT_MS = 180_000; | 43 | const CLAUDE_TIMEOUT_MS = 180_000; |
| 44 | 44 | ||
| ... | @@ -50,9 +50,12 @@ const ClaudeOutputEnvelope = Schema.Struct({ | ... | @@ -50,9 +50,12 @@ const ClaudeOutputEnvelope = Schema.Struct({ |
| 50 | structured_output: Schema.Unknown, | 50 | structured_output: Schema.Unknown, |
| 51 | }); | 51 | }); |
| 52 | 52 | ||
| 53 | const makeClaudeTextGeneration = Effect.gen(function* () { | 53 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( |
| 54 | claudeSettings: ClaudeSettings, | ||
| 55 | environment: NodeJS.ProcessEnv = process.env, | ||
| 56 | ) { | ||
| 54 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 57 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 55 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | 58 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 56 | 59 | ||
| 57 | const readStreamAsString = <E>( | 60 | const readStreamAsString = <E>( |
| 58 | operation: string, | 61 | operation: string, |
| ... | @@ -88,7 +91,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -88,7 +91,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 88 | cwd: string; | 91 | cwd: string; |
| 89 | prompt: string; | 92 | prompt: string; |
| 90 | outputSchemaJson: S; | 93 | outputSchemaJson: S; |
| 91 | modelSelection: ClaudeModelSelection; | 94 | modelSelection: ModelSelection; |
| 92 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | 95 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { |
| 93 | const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); | 96 | const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); |
| 94 | const caps = getClaudeModelCapabilities(modelSelection.model); | 97 | const caps = getClaudeModelCapabilities(modelSelection.model); |
| ... | @@ -111,14 +114,9 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -111,14 +114,9 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 111 | ...(fastMode ? { fastMode: true } : {}), | 114 | ...(fastMode ? { fastMode: true } : {}), |
| 112 | }; | 115 | }; |
| 113 | 116 | ||
| 114 | const claudeSettings = yield* Effect.map( | ||
| 115 | serverSettingsService.getSettings, | ||
| 116 | (settings) => settings.providers.claudeAgent, | ||
| 117 | ).pipe(Effect.catch(() => Effect.undefined)); | ||
| 118 | |||
| 119 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { | 117 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { |
| 120 | const command = ChildProcess.make( | 118 | const command = ChildProcess.make( |
| 121 | claudeSettings?.binaryPath || "claude", | 119 | claudeSettings.binaryPath || "claude", |
| 122 | [ | 120 | [ |
| 123 | "-p", | 121 | "-p", |
| 124 | "--output-format", | 122 | "--output-format", |
| ... | @@ -132,6 +130,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -132,6 +130,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 132 | "--dangerously-skip-permissions", | 130 | "--dangerously-skip-permissions", |
| 133 | ], | 131 | ], |
| 134 | { | 132 | { |
| 133 | env: claudeEnvironment, | ||
| 135 | cwd, | 134 | cwd, |
| 136 | shell: process.platform === "win32", | 135 | shell: process.platform === "win32", |
| 137 | stdin: { | 136 | stdin: { |
| ... | @@ -232,13 +231,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -232,13 +231,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 232 | includeBranch: input.includeBranch === true, | 231 | includeBranch: input.includeBranch === true, |
| 233 | }); | 232 | }); |
| 234 | 233 | ||
| 235 | if (input.modelSelection.provider !== "claudeAgent") { | ||
| 236 | return yield* new TextGenerationError({ | ||
| 237 | operation: "generateCommitMessage", | ||
| 238 | detail: "Invalid model selection.", | ||
| 239 | }); | ||
| 240 | } | ||
| 241 | |||
| 242 | const generated = yield* runClaudeJson({ | 234 | const generated = yield* runClaudeJson({ |
| 243 | operation: "generateCommitMessage", | 235 | operation: "generateCommitMessage", |
| 244 | cwd: input.cwd, | 236 | cwd: input.cwd, |
| ... | @@ -267,13 +259,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -267,13 +259,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 267 | diffPatch: input.diffPatch, | 259 | diffPatch: input.diffPatch, |
| 268 | }); | 260 | }); |
| 269 | 261 | ||
| 270 | if (input.modelSelection.provider !== "claudeAgent") { | ||
| 271 | return yield* new TextGenerationError({ | ||
| 272 | operation: "generatePrContent", | ||
| 273 | detail: "Invalid model selection.", | ||
| 274 | }); | ||
| 275 | } | ||
| 276 | |||
| 277 | const generated = yield* runClaudeJson({ | 262 | const generated = yield* runClaudeJson({ |
| 278 | operation: "generatePrContent", | 263 | operation: "generatePrContent", |
| 279 | cwd: input.cwd, | 264 | cwd: input.cwd, |
| ... | @@ -296,13 +281,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -296,13 +281,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 296 | attachments: input.attachments, | 281 | attachments: input.attachments, |
| 297 | }); | 282 | }); |
| 298 | 283 | ||
| 299 | if (input.modelSelection.provider !== "claudeAgent") { | ||
| 300 | return yield* new TextGenerationError({ | ||
| 301 | operation: "generateBranchName", | ||
| 302 | detail: "Invalid model selection.", | ||
| 303 | }); | ||
| 304 | } | ||
| 305 | |||
| 306 | const generated = yield* runClaudeJson({ | 284 | const generated = yield* runClaudeJson({ |
| 307 | operation: "generateBranchName", | 285 | operation: "generateBranchName", |
| 308 | cwd: input.cwd, | 286 | cwd: input.cwd, |
| ... | @@ -324,13 +302,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -324,13 +302,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 324 | attachments: input.attachments, | 302 | attachments: input.attachments, |
| 325 | }); | 303 | }); |
| 326 | 304 | ||
| 327 | if (input.modelSelection.provider !== "claudeAgent") { | ||
| 328 | return yield* new TextGenerationError({ | ||
| 329 | operation: "generateThreadTitle", | ||
| 330 | detail: "Invalid model selection.", | ||
| 331 | }); | ||
| 332 | } | ||
| 333 | |||
| 334 | const generated = yield* runClaudeJson({ | 305 | const generated = yield* runClaudeJson({ |
| 335 | operation: "generateThreadTitle", | 306 | operation: "generateThreadTitle", |
| 336 | cwd: input.cwd, | 307 | cwd: input.cwd, |
| ... | @@ -351,5 +322,3 @@ const makeClaudeTextGeneration = Effect.gen(function* () { | ... | @@ -351,5 +322,3 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 351 | generateThreadTitle, | 322 | generateThreadTitle, |
| 352 | } satisfies TextGenerationShape; | 323 | } satisfies TextGenerationShape; |
| 353 | }); | 324 | }); |
| 354 | |||
| 355 | export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration); |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+243-285| ... | @@ -1,30 +1,24 @@ | ... | @@ -1,30 +1,24 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it } from "@effect/vitest"; | 2 | import { it } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Layer, Path, Result } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect"; |
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | 4 | import { createModelSelection } from "@t3tools/shared/model"; |
| 5 | import { expect } from "vitest"; | 5 | import { expect } from "vitest"; |
| 6 | 6 | ||
| 7 | import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | ||
| 8 | |||
| 7 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 8 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | 10 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; |
| 9 | import { TextGenerationError } from "@t3tools/contracts"; | 11 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; |
| 10 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 12 | |
| 11 | import { ServerSettingsService } from "../../serverSettings.ts"; | 13 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( |
| 12 | 14 | ProviderInstanceId.make("codex"), | |
| 13 | const DEFAULT_TEST_MODEL_SELECTION = { | 15 | "gpt-5.4-mini", |
| 14 | provider: "codex" as const, | ||
| 15 | model: "gpt-5.4-mini", | ||
| 16 | }; | ||
| 17 | |||
| 18 | const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( | ||
| 19 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 20 | Layer.provideMerge( | ||
| 21 | ServerConfig.layerTest(process.cwd(), { | ||
| 22 | prefix: "t3code-codex-text-generation-test-", | ||
| 23 | }), | ||
| 24 | ), | ||
| 25 | Layer.provideMerge(NodeServices.layer), | ||
| 26 | ); | 16 | ); |
| 27 | 17 | ||
| 18 | const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 19 | prefix: "t3code-codex-text-generation-test-", | ||
| 20 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 21 | |||
| 28 | function makeFakeCodexBinary( | 22 | function makeFakeCodexBinary( |
| 29 | dir: string, | 23 | dir: string, |
| 30 | input: { | 24 | input: { |
| ... | @@ -162,36 +156,16 @@ function withFakeCodexEnv<A, E, R>( | ... | @@ -162,36 +156,16 @@ function withFakeCodexEnv<A, E, R>( |
| 162 | stdinMustContain?: string; | 156 | stdinMustContain?: string; |
| 163 | stdinMustNotContain?: string; | 157 | stdinMustNotContain?: string; |
| 164 | }, | 158 | }, |
| 165 | effect: Effect.Effect<A, E, R>, | 159 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, |
| 166 | ) { | 160 | ) { |
| 167 | return Effect.acquireUseRelease( | 161 | return Effect.gen(function* () { |
| 168 | Effect.gen(function* () { | 162 | const fs = yield* FileSystem.FileSystem; |
| 169 | const fs = yield* FileSystem.FileSystem; | 163 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); |
| 170 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); | 164 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); |
| 171 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); | 165 | const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath }); |
| 172 | const serverSettings = yield* ServerSettingsService; | 166 | const textGeneration = yield* makeCodexTextGeneration(config); |
| 173 | const previousSettings = yield* serverSettings.getSettings; | 167 | return yield* effectFn(textGeneration); |
| 174 | yield* serverSettings.updateSettings({ | 168 | }).pipe(Effect.scoped); |
| 175 | providers: { | ||
| 176 | codex: { | ||
| 177 | binaryPath: codexPath, | ||
| 178 | }, | ||
| 179 | }, | ||
| 180 | }); | ||
| 181 | return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; | ||
| 182 | }), | ||
| 183 | () => effect, | ||
| 184 | ({ serverSettings, previousBinaryPath }) => | ||
| 185 | serverSettings | ||
| 186 | .updateSettings({ | ||
| 187 | providers: { | ||
| 188 | codex: { | ||
| 189 | binaryPath: previousBinaryPath, | ||
| 190 | }, | ||
| 191 | }, | ||
| 192 | }) | ||
| 193 | .pipe(Effect.asVoid), | ||
| 194 | ); | ||
| 195 | } | 169 | } |
| 196 | 170 | ||
| 197 | it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | 171 | it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| ... | @@ -205,22 +179,21 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -205,22 +179,21 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 205 | }), | 179 | }), |
| 206 | stdinMustNotContain: "branch must be a short semantic git branch fragment", | 180 | stdinMustNotContain: "branch must be a short semantic git branch fragment", |
| 207 | }, | 181 | }, |
| 208 | Effect.gen(function* () { | 182 | (textGeneration) => |
| 209 | const textGeneration = yield* TextGeneration; | 183 | Effect.gen(function* () { |
| 210 | 184 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 211 | const generated = yield* textGeneration.generateCommitMessage({ | 185 | cwd: process.cwd(), |
| 212 | cwd: process.cwd(), | 186 | branch: "feature/codex-effect", |
| 213 | branch: "feature/codex-effect", | 187 | stagedSummary: "M README.md", |
| 214 | stagedSummary: "M README.md", | 188 | stagedPatch: "diff --git a/README.md b/README.md", |
| 215 | stagedPatch: "diff --git a/README.md b/README.md", | 189 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 216 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 190 | }); |
| 217 | }); | ||
| 218 | 191 | ||
| 219 | expect(generated.subject.length).toBeLessThanOrEqual(72); | 192 | expect(generated.subject.length).toBeLessThanOrEqual(72); |
| 220 | expect(generated.subject.endsWith(".")).toBe(false); | 193 | expect(generated.subject.endsWith(".")).toBe(false); |
| 221 | expect(generated.body).toBe("- added migration\n- updated tests"); | 194 | expect(generated.body).toBe("- added migration\n- updated tests"); |
| 222 | expect(generated.branch).toBeUndefined(); | 195 | expect(generated.branch).toBeUndefined(); |
| 223 | }), | 196 | }), |
| 224 | ), | 197 | ), |
| 225 | ); | 198 | ); |
| 226 | 199 | ||
| ... | @@ -237,20 +210,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -237,20 +210,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 237 | requireReasoningEffort: "xhigh", | 210 | requireReasoningEffort: "xhigh", |
| 238 | stdinMustNotContain: "branch must be a short semantic git branch fragment", | 211 | stdinMustNotContain: "branch must be a short semantic git branch fragment", |
| 239 | }, | 212 | }, |
| 240 | Effect.gen(function* () { | 213 | (textGeneration) => |
| 241 | const textGeneration = yield* TextGeneration; | 214 | textGeneration.generateCommitMessage({ |
| 242 | |||
| 243 | yield* textGeneration.generateCommitMessage({ | ||
| 244 | cwd: process.cwd(), | 215 | cwd: process.cwd(), |
| 245 | branch: "feature/codex-effect", | 216 | branch: "feature/codex-effect", |
| 246 | stagedSummary: "M README.md", | 217 | stagedSummary: "M README.md", |
| 247 | stagedPatch: "diff --git a/README.md b/README.md", | 218 | stagedPatch: "diff --git a/README.md b/README.md", |
| 248 | modelSelection: createModelSelection("codex", "gpt-5.4", [ | 219 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 249 | { id: "reasoningEffort", value: "xhigh" }, | 220 | { id: "reasoningEffort", value: "xhigh" }, |
| 250 | { id: "fastMode", value: true }, | 221 | { id: "fastMode", value: true }, |
| 251 | ]), | 222 | ]), |
| 252 | }); | 223 | }), |
| 253 | }), | ||
| 254 | ), | 224 | ), |
| 255 | ); | 225 | ); |
| 256 | 226 | ||
| ... | @@ -263,17 +233,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -263,17 +233,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 263 | }), | 233 | }), |
| 264 | requireReasoningEffort: "low", | 234 | requireReasoningEffort: "low", |
| 265 | }, | 235 | }, |
| 266 | Effect.gen(function* () { | 236 | (textGeneration) => |
| 267 | const textGeneration = yield* TextGeneration; | 237 | textGeneration.generateCommitMessage({ |
| 268 | |||
| 269 | yield* textGeneration.generateCommitMessage({ | ||
| 270 | cwd: process.cwd(), | 238 | cwd: process.cwd(), |
| 271 | branch: "feature/codex-effect", | 239 | branch: "feature/codex-effect", |
| 272 | stagedSummary: "M README.md", | 240 | stagedSummary: "M README.md", |
| 273 | stagedPatch: "diff --git a/README.md b/README.md", | 241 | stagedPatch: "diff --git a/README.md b/README.md", |
| 274 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 242 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 275 | }); | 243 | }), |
| 276 | }), | ||
| 277 | ), | 244 | ), |
| 278 | ); | 245 | ); |
| 279 | 246 | ||
| ... | @@ -287,21 +254,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -287,21 +254,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 287 | }), | 254 | }), |
| 288 | stdinMustContain: "branch must be a short semantic git branch fragment", | 255 | stdinMustContain: "branch must be a short semantic git branch fragment", |
| 289 | }, | 256 | }, |
| 290 | Effect.gen(function* () { | 257 | (textGeneration) => |
| 291 | const textGeneration = yield* TextGeneration; | 258 | Effect.gen(function* () { |
| 292 | 259 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 293 | const generated = yield* textGeneration.generateCommitMessage({ | 260 | cwd: process.cwd(), |
| 294 | cwd: process.cwd(), | 261 | branch: "feature/codex-effect", |
| 295 | branch: "feature/codex-effect", | 262 | stagedSummary: "M README.md", |
| 296 | stagedSummary: "M README.md", | 263 | stagedPatch: "diff --git a/README.md b/README.md", |
| 297 | stagedPatch: "diff --git a/README.md b/README.md", | 264 | includeBranch: true, |
| 298 | includeBranch: true, | 265 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 299 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 266 | }); |
| 300 | }); | ||
| 301 | 267 | ||
| 302 | expect(generated.subject).toBe("Add important change"); | 268 | expect(generated.subject).toBe("Add important change"); |
| 303 | expect(generated.branch).toBe("feature/fix/important-system-change"); | 269 | expect(generated.branch).toBe("feature/fix/important-system-change"); |
| 304 | }), | 270 | }), |
| 305 | ), | 271 | ), |
| 306 | ); | 272 | ); |
| 307 | 273 | ||
| ... | @@ -313,23 +279,22 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -313,23 +279,22 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 313 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", | 279 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", |
| 314 | }), | 280 | }), |
| 315 | }, | 281 | }, |
| 316 | Effect.gen(function* () { | 282 | (textGeneration) => |
| 317 | const textGeneration = yield* TextGeneration; | 283 | Effect.gen(function* () { |
| 318 | 284 | const generated = yield* textGeneration.generatePrContent({ | |
| 319 | const generated = yield* textGeneration.generatePrContent({ | 285 | cwd: process.cwd(), |
| 320 | cwd: process.cwd(), | 286 | baseBranch: "main", |
| 321 | baseBranch: "main", | 287 | headBranch: "feature/codex-effect", |
| 322 | headBranch: "feature/codex-effect", | 288 | commitSummary: "feat: improve orchestration flow", |
| 323 | commitSummary: "feat: improve orchestration flow", | 289 | diffSummary: "2 files changed", |
| 324 | diffSummary: "2 files changed", | 290 | diffPatch: "diff --git a/a.ts b/a.ts", |
| 325 | diffPatch: "diff --git a/a.ts b/a.ts", | 291 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 326 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 292 | }); |
| 327 | }); | ||
| 328 | 293 | ||
| 329 | expect(generated.title).toBe("Improve orchestration flow"); | 294 | expect(generated.title).toBe("Improve orchestration flow"); |
| 330 | expect(generated.body.startsWith("## Summary")).toBe(true); | 295 | expect(generated.body.startsWith("## Summary")).toBe(true); |
| 331 | expect(generated.body.endsWith("\n\n")).toBe(false); | 296 | expect(generated.body.endsWith("\n\n")).toBe(false); |
| 332 | }), | 297 | }), |
| 333 | ), | 298 | ), |
| 334 | ); | 299 | ); |
| 335 | 300 | ||
| ... | @@ -341,17 +306,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -341,17 +306,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 341 | }), | 306 | }), |
| 342 | stdinMustNotContain: "Image attachments supplied to the model", | 307 | stdinMustNotContain: "Image attachments supplied to the model", |
| 343 | }, | 308 | }, |
| 344 | Effect.gen(function* () { | 309 | (textGeneration) => |
| 345 | const textGeneration = yield* TextGeneration; | 310 | Effect.gen(function* () { |
| 346 | 311 | const generated = yield* textGeneration.generateBranchName({ | |
| 347 | const generated = yield* textGeneration.generateBranchName({ | 312 | cwd: process.cwd(), |
| 348 | cwd: process.cwd(), | 313 | message: "Please update session handling.", |
| 349 | message: "Please update session handling.", | 314 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 350 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 315 | }); |
| 351 | }); | ||
| 352 | 316 | ||
| 353 | expect(generated.branch).toBe("feat/session"); | 317 | expect(generated.branch).toBe("feat/session"); |
| 354 | }), | 318 | }), |
| 355 | ), | 319 | ), |
| 356 | ); | 320 | ); |
| 357 | 321 | ||
| ... | @@ -363,17 +327,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -363,17 +327,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 363 | ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', | 327 | ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', |
| 364 | }), | 328 | }), |
| 365 | }, | 329 | }, |
| 366 | Effect.gen(function* () { | 330 | (textGeneration) => |
| 367 | const textGeneration = yield* TextGeneration; | 331 | Effect.gen(function* () { |
| 368 | 332 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 369 | const generated = yield* textGeneration.generateThreadTitle({ | 333 | cwd: process.cwd(), |
| 370 | cwd: process.cwd(), | 334 | message: "Please investigate websocket reconnect regressions after a worktree restore.", |
| 371 | message: "Please investigate websocket reconnect regressions after a worktree restore.", | 335 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 372 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 336 | }); |
| 373 | }); | ||
| 374 | 337 | ||
| 375 | expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); | 338 | expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); |
| 376 | }), | 339 | }), |
| 377 | ), | 340 | ), |
| 378 | ); | 341 | ); |
| 379 | 342 | ||
| ... | @@ -384,17 +347,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -384,17 +347,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 384 | title: ' """ """ ', | 347 | title: ' """ """ ', |
| 385 | }), | 348 | }), |
| 386 | }, | 349 | }, |
| 387 | Effect.gen(function* () { | 350 | (textGeneration) => |
| 388 | const textGeneration = yield* TextGeneration; | 351 | Effect.gen(function* () { |
| 389 | 352 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 390 | const generated = yield* textGeneration.generateThreadTitle({ | 353 | cwd: process.cwd(), |
| 391 | cwd: process.cwd(), | 354 | message: "Name this thread.", |
| 392 | message: "Name this thread.", | 355 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 393 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 356 | }); |
| 394 | }); | ||
| 395 | 357 | ||
| 396 | expect(generated.title).toBe("New thread"); | 358 | expect(generated.title).toBe("New thread"); |
| 397 | }), | 359 | }), |
| 398 | ), | 360 | ), |
| 399 | ); | 361 | ); |
| 400 | 362 | ||
| ... | @@ -405,17 +367,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -405,17 +367,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 405 | title: ` "' hello world '" `, | 367 | title: ` "' hello world '" `, |
| 406 | }), | 368 | }), |
| 407 | }, | 369 | }, |
| 408 | Effect.gen(function* () { | 370 | (textGeneration) => |
| 409 | const textGeneration = yield* TextGeneration; | 371 | Effect.gen(function* () { |
| 410 | 372 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 411 | const generated = yield* textGeneration.generateThreadTitle({ | 373 | cwd: process.cwd(), |
| 412 | cwd: process.cwd(), | 374 | message: "Name this thread.", |
| 413 | message: "Name this thread.", | 375 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 414 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 376 | }); |
| 415 | }); | ||
| 416 | 377 | ||
| 417 | expect(generated.title).toBe("hello world"); | 378 | expect(generated.title).toBe("hello world"); |
| 418 | }), | 379 | }), |
| 419 | ), | 380 | ), |
| 420 | ); | 381 | ); |
| 421 | 382 | ||
| ... | @@ -427,17 +388,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -427,17 +388,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 427 | }), | 388 | }), |
| 428 | stdinMustNotContain: "Attachment metadata:", | 389 | stdinMustNotContain: "Attachment metadata:", |
| 429 | }, | 390 | }, |
| 430 | Effect.gen(function* () { | 391 | (textGeneration) => |
| 431 | const textGeneration = yield* TextGeneration; | 392 | Effect.gen(function* () { |
| 432 | 393 | const generated = yield* textGeneration.generateBranchName({ | |
| 433 | const generated = yield* textGeneration.generateBranchName({ | 394 | cwd: process.cwd(), |
| 434 | cwd: process.cwd(), | 395 | message: "Fix timeout behavior.", |
| 435 | message: "Fix timeout behavior.", | 396 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 436 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 397 | }); |
| 437 | }); | ||
| 438 | 398 | ||
| 439 | expect(generated.branch).toBe("fix/session-timeout"); | 399 | expect(generated.branch).toBe("fix/session-timeout"); |
| 440 | }), | 400 | }), |
| 441 | ), | 401 | ), |
| 442 | ); | 402 | ); |
| 443 | 403 | ||
| ... | @@ -450,56 +410,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -450,56 +410,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 450 | requireImage: true, | 410 | requireImage: true, |
| 451 | stdinMustContain: "Attachment metadata:", | 411 | stdinMustContain: "Attachment metadata:", |
| 452 | }, | 412 | }, |
| 453 | Effect.gen(function* () { | 413 | (textGeneration) => |
| 454 | const fs = yield* FileSystem.FileSystem; | 414 | Effect.gen(function* () { |
| 455 | const path = yield* Path.Path; | 415 | const fs = yield* FileSystem.FileSystem; |
| 456 | const { attachmentsDir } = yield* ServerConfig; | 416 | const path = yield* Path.Path; |
| 457 | const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | 417 | const { attachmentsDir } = yield* ServerConfig; |
| 458 | const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); | 418 | const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
| 459 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | 419 | const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); |
| 460 | yield* fs.writeFile(attachmentPath, Buffer.from("hello")); | 420 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); |
| 461 | 421 | yield* fs.writeFile(attachmentPath, Buffer.from("hello")); | |
| 462 | const textGeneration = yield* TextGeneration; | 422 | |
| 463 | const generated = yield* textGeneration.generateBranchName({ | 423 | const generated = yield* textGeneration.generateBranchName({ |
| 464 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 465 | cwd: process.cwd(), | ||
| 466 | message: "Fix layout bug from screenshot.", | ||
| 467 | attachments: [ | ||
| 468 | { | ||
| 469 | type: "image", | ||
| 470 | id: attachmentId, | ||
| 471 | name: "bug.png", | ||
| 472 | mimeType: "image/png", | ||
| 473 | sizeBytes: 5, | ||
| 474 | }, | ||
| 475 | ], | ||
| 476 | }); | ||
| 477 | |||
| 478 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 479 | }), | ||
| 480 | ), | ||
| 481 | ); | ||
| 482 | |||
| 483 | it.effect("resolves persisted attachment ids to files for codex image inputs", () => | ||
| 484 | withFakeCodexEnv( | ||
| 485 | { | ||
| 486 | output: JSON.stringify({ | ||
| 487 | branch: "fix/ui-regression", | ||
| 488 | }), | ||
| 489 | requireImage: true, | ||
| 490 | }, | ||
| 491 | Effect.gen(function* () { | ||
| 492 | const fs = yield* FileSystem.FileSystem; | ||
| 493 | const path = yield* Path.Path; | ||
| 494 | const { attachmentsDir } = yield* ServerConfig; | ||
| 495 | const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 496 | const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); | ||
| 497 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | ||
| 498 | yield* fs.writeFile(imagePath, Buffer.from("hello")); | ||
| 499 | |||
| 500 | const textGeneration = yield* TextGeneration; | ||
| 501 | const generated = yield* textGeneration | ||
| 502 | .generateBranchName({ | ||
| 503 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 424 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 504 | cwd: process.cwd(), | 425 | cwd: process.cwd(), |
| 505 | message: "Fix layout bug from screenshot.", | 426 | message: "Fix layout bug from screenshot.", |
| ... | @@ -512,24 +433,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -512,24 +433,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 512 | sizeBytes: 5, | 433 | sizeBytes: 5, |
| 513 | }, | 434 | }, |
| 514 | ], | 435 | ], |
| 515 | }) | 436 | }); |
| 516 | .pipe( | ||
| 517 | Effect.tap(() => | ||
| 518 | fs.stat(imagePath).pipe( | ||
| 519 | Effect.map((fileInfo) => { | ||
| 520 | expect(fileInfo.type).toBe("File"); | ||
| 521 | }), | ||
| 522 | ), | ||
| 523 | ), | ||
| 524 | Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), | ||
| 525 | ); | ||
| 526 | 437 | ||
| 527 | expect(generated.branch).toBe("fix/ui-regression"); | 438 | expect(generated.branch).toBe("fix/ui-regression"); |
| 528 | }), | 439 | }), |
| 529 | ), | 440 | ), |
| 530 | ); | 441 | ); |
| 531 | 442 | ||
| 532 | it.effect("ignores missing attachment ids for codex image inputs", () => | 443 | it.effect("resolves persisted attachment ids to files for codex image inputs", () => |
| 533 | withFakeCodexEnv( | 444 | withFakeCodexEnv( |
| 534 | { | 445 | { |
| 535 | output: JSON.stringify({ | 446 | output: JSON.stringify({ |
| ... | @@ -537,67 +448,115 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -537,67 +448,115 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 537 | }), | 448 | }), |
| 538 | requireImage: true, | 449 | requireImage: true, |
| 539 | }, | 450 | }, |
| 540 | Effect.gen(function* () { | 451 | (textGeneration) => |
| 541 | const fs = yield* FileSystem.FileSystem; | 452 | Effect.gen(function* () { |
| 542 | const path = yield* Path.Path; | 453 | const fs = yield* FileSystem.FileSystem; |
| 543 | const { attachmentsDir } = yield* ServerConfig; | 454 | const path = yield* Path.Path; |
| 544 | const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | 455 | const { attachmentsDir } = yield* ServerConfig; |
| 545 | const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); | 456 | const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
| 546 | yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); | 457 | const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); |
| 547 | 458 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | |
| 548 | const textGeneration = yield* TextGeneration; | 459 | yield* fs.writeFile(imagePath, Buffer.from("hello")); |
| 549 | const result = yield* textGeneration | 460 | |
| 550 | .generateBranchName({ | 461 | const generated = yield* textGeneration |
| 551 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 462 | .generateBranchName({ |
| 552 | cwd: process.cwd(), | 463 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 553 | message: "Fix layout bug from screenshot.", | 464 | cwd: process.cwd(), |
| 554 | attachments: [ | 465 | message: "Fix layout bug from screenshot.", |
| 555 | { | 466 | attachments: [ |
| 556 | type: "image", | 467 | { |
| 557 | id: missingAttachmentId, | 468 | type: "image", |
| 558 | name: "outside.png", | 469 | id: attachmentId, |
| 559 | mimeType: "image/png", | 470 | name: "bug.png", |
| 560 | sizeBytes: 5, | 471 | mimeType: "image/png", |
| 561 | }, | 472 | sizeBytes: 5, |
| 562 | ], | 473 | }, |
| 563 | }) | 474 | ], |
| 564 | .pipe(Effect.result); | 475 | }) |
| 565 | 476 | .pipe( | |
| 566 | expect(Result.isFailure(result)).toBe(true); | 477 | Effect.tap(() => |
| 567 | if (Result.isFailure(result)) { | 478 | fs.stat(imagePath).pipe( |
| 568 | expect(result.failure).toBeInstanceOf(TextGenerationError); | 479 | Effect.map((fileInfo) => { |
| 569 | expect(result.failure.message).toContain("missing --image input"); | 480 | expect(fileInfo.type).toBe("File"); |
| 570 | } | 481 | }), |
| 571 | }), | 482 | ), |
| 483 | ), | ||
| 484 | Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), | ||
| 485 | ); | ||
| 486 | |||
| 487 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 488 | }), | ||
| 572 | ), | 489 | ), |
| 573 | ); | 490 | ); |
| 574 | 491 | ||
| 575 | it.effect( | 492 | it.effect("ignores missing attachment ids for codex image inputs", () => |
| 576 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", | 493 | withFakeCodexEnv( |
| 577 | () => | 494 | { |
| 578 | withFakeCodexEnv( | 495 | output: JSON.stringify({ |
| 579 | { | 496 | branch: "fix/ui-regression", |
| 580 | output: JSON.stringify({ | 497 | }), |
| 581 | title: "This is not a branch payload", | 498 | requireImage: true, |
| 582 | }), | 499 | }, |
| 583 | }, | 500 | (textGeneration) => |
| 584 | Effect.gen(function* () { | 501 | Effect.gen(function* () { |
| 585 | const textGeneration = yield* TextGeneration; | 502 | const fs = yield* FileSystem.FileSystem; |
| 503 | const path = yield* Path.Path; | ||
| 504 | const { attachmentsDir } = yield* ServerConfig; | ||
| 505 | const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 506 | const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); | ||
| 507 | yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); | ||
| 586 | 508 | ||
| 587 | const result = yield* textGeneration | 509 | const result = yield* textGeneration |
| 588 | .generateBranchName({ | 510 | .generateBranchName({ |
| 589 | cwd: process.cwd(), | ||
| 590 | message: "Fix websocket reconnect flake", | ||
| 591 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 511 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 512 | cwd: process.cwd(), | ||
| 513 | message: "Fix layout bug from screenshot.", | ||
| 514 | attachments: [ | ||
| 515 | { | ||
| 516 | type: "image", | ||
| 517 | id: missingAttachmentId, | ||
| 518 | name: "outside.png", | ||
| 519 | mimeType: "image/png", | ||
| 520 | sizeBytes: 5, | ||
| 521 | }, | ||
| 522 | ], | ||
| 592 | }) | 523 | }) |
| 593 | .pipe(Effect.result); | 524 | .pipe(Effect.result); |
| 594 | 525 | ||
| 595 | expect(Result.isFailure(result)).toBe(true); | 526 | expect(Result.isFailure(result)).toBe(true); |
| 596 | if (Result.isFailure(result)) { | 527 | if (Result.isFailure(result)) { |
| 597 | expect(result.failure).toBeInstanceOf(TextGenerationError); | 528 | expect(result.failure).toBeInstanceOf(TextGenerationError); |
| 598 | expect(result.failure.message).toContain("Codex returned invalid structured output"); | 529 | expect(result.failure.message).toContain("missing --image input"); |
| 599 | } | 530 | } |
| 600 | }), | 531 | }), |
| 532 | ), | ||
| 533 | ); | ||
| 534 | |||
| 535 | it.effect( | ||
| 536 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", | ||
| 537 | () => | ||
| 538 | withFakeCodexEnv( | ||
| 539 | { | ||
| 540 | output: JSON.stringify({ | ||
| 541 | title: "This is not a branch payload", | ||
| 542 | }), | ||
| 543 | }, | ||
| 544 | (textGeneration) => | ||
| 545 | Effect.gen(function* () { | ||
| 546 | const result = yield* textGeneration | ||
| 547 | .generateBranchName({ | ||
| 548 | cwd: process.cwd(), | ||
| 549 | message: "Fix websocket reconnect flake", | ||
| 550 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 551 | }) | ||
| 552 | .pipe(Effect.result); | ||
| 553 | |||
| 554 | expect(Result.isFailure(result)).toBe(true); | ||
| 555 | if (Result.isFailure(result)) { | ||
| 556 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 557 | expect(result.failure.message).toContain("Codex returned invalid structured output"); | ||
| 558 | } | ||
| 559 | }), | ||
| 601 | ), | 560 | ), |
| 602 | ); | 561 | ); |
| 603 | 562 | ||
| ... | @@ -608,27 +567,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -608,27 +567,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 608 | exitCode: 1, | 567 | exitCode: 1, |
| 609 | stderr: "codex execution failed", | 568 | stderr: "codex execution failed", |
| 610 | }, | 569 | }, |
| 611 | Effect.gen(function* () { | 570 | (textGeneration) => |
| 612 | const textGeneration = yield* TextGeneration; | 571 | Effect.gen(function* () { |
| 572 | const result = yield* textGeneration | ||
| 573 | .generateCommitMessage({ | ||
| 574 | cwd: process.cwd(), | ||
| 575 | branch: "feature/codex-error", | ||
| 576 | stagedSummary: "M README.md", | ||
| 577 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 578 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 579 | }) | ||
| 580 | .pipe(Effect.result); | ||
| 613 | 581 | ||
| 614 | const result = yield* textGeneration | 582 | expect(Result.isFailure(result)).toBe(true); |
| 615 | .generateCommitMessage({ | 583 | if (Result.isFailure(result)) { |
| 616 | cwd: process.cwd(), | 584 | expect(result.failure).toBeInstanceOf(TextGenerationError); |
| 617 | branch: "feature/codex-error", | 585 | expect(result.failure.message).toContain( |
| 618 | stagedSummary: "M README.md", | 586 | "Codex CLI command failed: codex execution failed", |
| 619 | stagedPatch: "diff --git a/README.md b/README.md", | 587 | ); |
| 620 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 588 | } |
| 621 | }) | 589 | }), |
| 622 | .pipe(Effect.result); | ||
| 623 | |||
| 624 | expect(Result.isFailure(result)).toBe(true); | ||
| 625 | if (Result.isFailure(result)) { | ||
| 626 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 627 | expect(result.failure.message).toContain( | ||
| 628 | "Codex CLI command failed: codex execution failed", | ||
| 629 | ); | ||
| 630 | } | ||
| 631 | }), | ||
| 632 | ), | 590 | ), |
| 633 | ); | 591 | ); |
| 634 | }); | 592 | }); |
apps/server/src/git/Layers/CodexTextGeneration.ts+34-63| ... | @@ -1,9 +1,7 @@ | ... | @@ -1,9 +1,7 @@ |
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect"; |
| 2 | |||
| 3 | import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; | ||
| 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 2 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 3 | ||
| 6 | import { CodexModelSelection } from "@t3tools/contracts"; | 4 | import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; |
| 7 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 8 | 6 | ||
| 9 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 7 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| ... | @@ -14,7 +12,6 @@ import { | ... | @@ -14,7 +12,6 @@ import { |
| 14 | type BranchNameGenerationInput, | 12 | type BranchNameGenerationInput, |
| 15 | type ThreadTitleGenerationResult, | 13 | type ThreadTitleGenerationResult, |
| 16 | type TextGenerationShape, | 14 | type TextGenerationShape, |
| 17 | TextGeneration, | ||
| 18 | } from "../Services/TextGeneration.ts"; | 15 | } from "../Services/TextGeneration.ts"; |
| 19 | import { | 16 | import { |
| 20 | buildBranchNamePrompt, | 17 | buildBranchNamePrompt, |
| ... | @@ -29,7 +26,6 @@ import { | ... | @@ -29,7 +26,6 @@ import { |
| 29 | sanitizeThreadTitle, | 26 | sanitizeThreadTitle, |
| 30 | toJsonSchemaObject, | 27 | toJsonSchemaObject, |
| 31 | } from "../Utils.ts"; | 28 | } from "../Utils.ts"; |
| 32 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 33 | import { | 29 | import { |
| 34 | getModelSelectionBooleanOptionValue, | 30 | getModelSelectionBooleanOptionValue, |
| 35 | getModelSelectionStringOptionValue, | 31 | getModelSelectionStringOptionValue, |
| ... | @@ -37,12 +33,18 @@ import { | ... | @@ -37,12 +33,18 @@ import { |
| 37 | 33 | ||
| 38 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; | 34 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; |
| 39 | const CODEX_TIMEOUT_MS = 180_000; | 35 | const CODEX_TIMEOUT_MS = 180_000; |
| 40 | const makeCodexTextGeneration = Effect.gen(function* () { | 36 | /** |
| 37 | * Build a Codex text-generation closure bound to a specific `CodexSettings` | ||
| 38 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | ||
| 39 | */ | ||
| 40 | export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( | ||
| 41 | codexConfig: CodexSettings, | ||
| 42 | environment: NodeJS.ProcessEnv = process.env, | ||
| 43 | ) { | ||
| 41 | const fileSystem = yield* FileSystem.FileSystem; | 44 | const fileSystem = yield* FileSystem.FileSystem; |
| 42 | const path = yield* Path.Path; | 45 | const path = yield* Path.Path; |
| 43 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 46 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 44 | const serverConfig = yield* Effect.service(ServerConfig); | 47 | const serverConfig = yield* Effect.service(ServerConfig); |
| 45 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | ||
| 46 | 48 | ||
| 47 | type MaterializedImageAttachments = { | 49 | type MaterializedImageAttachments = { |
| 48 | readonly imagePaths: ReadonlyArray<string>; | 50 | readonly imagePaths: ReadonlyArray<string>; |
| ... | @@ -68,21 +70,23 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -68,21 +70,23 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 68 | prefix: string, | 70 | prefix: string, |
| 69 | content: string, | 71 | content: string, |
| 70 | ): Effect.Effect<string, TextGenerationError, Scope.Scope> => { | 72 | ): Effect.Effect<string, TextGenerationError, Scope.Scope> => { |
| 71 | return fileSystem | 73 | return Effect.gen(function* () { |
| 72 | .makeTempFileScoped({ | 74 | const tempFileId = yield* Random.nextUUIDv4; |
| 73 | prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, | 75 | return yield* fileSystem |
| 74 | }) | 76 | .makeTempFileScoped({ |
| 75 | .pipe( | 77 | prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, |
| 76 | Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)), | 78 | }) |
| 77 | Effect.mapError( | 79 | .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); |
| 78 | (cause) => | 80 | }).pipe( |
| 79 | new TextGenerationError({ | 81 | Effect.mapError( |
| 80 | operation, | 82 | (cause) => |
| 81 | detail: `Failed to write temp file`, | 83 | new TextGenerationError({ |
| 82 | cause, | 84 | operation, |
| 83 | }), | 85 | detail: `Failed to write temp file`, |
| 84 | ), | 86 | cause, |
| 85 | ); | 87 | }), |
| 88 | ), | ||
| 89 | ); | ||
| 86 | }; | 90 | }; |
| 87 | 91 | ||
| 88 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | 92 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => |
| ... | @@ -143,7 +147,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -143,7 +147,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 143 | outputSchemaJson: S; | 147 | outputSchemaJson: S; |
| 144 | imagePaths?: ReadonlyArray<string>; | 148 | imagePaths?: ReadonlyArray<string>; |
| 145 | cleanupPaths?: ReadonlyArray<string>; | 149 | cleanupPaths?: ReadonlyArray<string>; |
| 146 | modelSelection: CodexModelSelection; | 150 | modelSelection: ModelSelection; |
| 147 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | 151 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { |
| 148 | const schemaPath = yield* writeTempFile( | 152 | const schemaPath = yield* writeTempFile( |
| 149 | operation, | 153 | operation, |
| ... | @@ -152,17 +156,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -152,17 +156,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 152 | ); | 156 | ); |
| 153 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | 157 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); |
| 154 | 158 | ||
| 155 | const codexSettings = yield* Effect.map( | ||
| 156 | serverSettingsService.getSettings, | ||
| 157 | (settings) => settings.providers.codex, | ||
| 158 | ).pipe(Effect.catch(() => Effect.undefined)); | ||
| 159 | |||
| 160 | const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { | 159 | const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { |
| 161 | const reasoningEffort = | 160 | const reasoningEffort = |
| 162 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? | 161 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? |
| 163 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | 162 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 164 | const command = ChildProcess.make( | 163 | const command = ChildProcess.make( |
| 165 | codexSettings?.binaryPath || "codex", | 164 | codexConfig.binaryPath || "codex", |
| 166 | [ | 165 | [ |
| 167 | "exec", | 166 | "exec", |
| 168 | "--ephemeral", | 167 | "--ephemeral", |
| ... | @@ -185,10 +184,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -185,10 +184,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 185 | ], | 184 | ], |
| 186 | { | 185 | { |
| 187 | env: { | 186 | env: { |
| 188 | ...process.env, | 187 | ...environment, |
| 189 | ...(codexSettings?.homePath | 188 | ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), |
| 190 | ? { CODEX_HOME: expandHomePath(codexSettings.homePath) } | ||
| 191 | : {}), | ||
| 192 | }, | 189 | }, |
| 193 | cwd, | 190 | cwd, |
| 194 | shell: process.platform === "win32", | 191 | shell: process.platform === "win32", |
| ... | @@ -288,13 +285,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -288,13 +285,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 288 | includeBranch: input.includeBranch === true, | 285 | includeBranch: input.includeBranch === true, |
| 289 | }); | 286 | }); |
| 290 | 287 | ||
| 291 | if (input.modelSelection.provider !== "codex") { | ||
| 292 | return yield* new TextGenerationError({ | ||
| 293 | operation: "generateCommitMessage", | ||
| 294 | detail: "Invalid model selection.", | ||
| 295 | }); | ||
| 296 | } | ||
| 297 | |||
| 298 | const generated = yield* runCodexJson({ | 288 | const generated = yield* runCodexJson({ |
| 299 | operation: "generateCommitMessage", | 289 | operation: "generateCommitMessage", |
| 300 | cwd: input.cwd, | 290 | cwd: input.cwd, |
| ... | @@ -323,13 +313,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -323,13 +313,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 323 | diffPatch: input.diffPatch, | 313 | diffPatch: input.diffPatch, |
| 324 | }); | 314 | }); |
| 325 | 315 | ||
| 326 | if (input.modelSelection.provider !== "codex") { | ||
| 327 | return yield* new TextGenerationError({ | ||
| 328 | operation: "generatePrContent", | ||
| 329 | detail: "Invalid model selection.", | ||
| 330 | }); | ||
| 331 | } | ||
| 332 | |||
| 333 | const generated = yield* runCodexJson({ | 316 | const generated = yield* runCodexJson({ |
| 334 | operation: "generatePrContent", | 317 | operation: "generatePrContent", |
| 335 | cwd: input.cwd, | 318 | cwd: input.cwd, |
| ... | @@ -356,13 +339,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -356,13 +339,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 356 | attachments: input.attachments, | 339 | attachments: input.attachments, |
| 357 | }); | 340 | }); |
| 358 | 341 | ||
| 359 | if (input.modelSelection.provider !== "codex") { | ||
| 360 | return yield* new TextGenerationError({ | ||
| 361 | operation: "generateBranchName", | ||
| 362 | detail: "Invalid model selection.", | ||
| 363 | }); | ||
| 364 | } | ||
| 365 | |||
| 366 | const generated = yield* runCodexJson({ | 342 | const generated = yield* runCodexJson({ |
| 367 | operation: "generateBranchName", | 343 | operation: "generateBranchName", |
| 368 | cwd: input.cwd, | 344 | cwd: input.cwd, |
| ... | @@ -389,13 +365,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -389,13 +365,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 389 | attachments: input.attachments, | 365 | attachments: input.attachments, |
| 390 | }); | 366 | }); |
| 391 | 367 | ||
| 392 | if (input.modelSelection.provider !== "codex") { | ||
| 393 | return yield* new TextGenerationError({ | ||
| 394 | operation: "generateThreadTitle", | ||
| 395 | detail: "Invalid model selection.", | ||
| 396 | }); | ||
| 397 | } | ||
| 398 | |||
| 399 | const generated = yield* runCodexJson({ | 368 | const generated = yield* runCodexJson({ |
| 400 | operation: "generateThreadTitle", | 369 | operation: "generateThreadTitle", |
| 401 | cwd: input.cwd, | 370 | cwd: input.cwd, |
| ... | @@ -418,4 +387,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -418,4 +387,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 418 | } satisfies TextGenerationShape; | 387 | } satisfies TextGenerationShape; |
| 419 | }); | 388 | }); |
| 420 | 389 | ||
| 421 | export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration); | 390 | // NOTE: `CodexTextGenerationLive` (the singleton Layer) has been removed. |
| 391 | // `makeCodexTextGeneration(codexConfig)` is now invoked directly by | ||
| 392 | // `CodexDriver.create()` for each configured instance. |
apps/server/src/git/Layers/CursorTextGeneration.test.ts+139-172| ... | @@ -5,16 +5,15 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync | ... | @@ -5,16 +5,15 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { it } from "@effect/vitest"; | 7 | import { it } from "@effect/vitest"; |
| 8 | import { Effect, Layer } from "effect"; | 8 | import { Effect, Layer, Schema } from "effect"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | import { expect } from "vitest"; | 10 | import { expect } from "vitest"; |
| 11 | 11 | ||
| 12 | import { ServerSettingsError } from "@t3tools/contracts"; | 12 | import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 13 | 13 | ||
| 14 | import { ServerConfig } from "../../config.ts"; | 14 | import { ServerConfig } from "../../config.ts"; |
| 15 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 15 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; |
| 16 | import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; | 16 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; |
| 17 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 18 | 17 | ||
| 19 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 18 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 20 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 19 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); |
| ... | @@ -23,15 +22,9 @@ function shellSingleQuote(value: string): string { | ... | @@ -23,15 +22,9 @@ function shellSingleQuote(value: string): string { |
| 23 | return `'${value.replaceAll("'", `'"'"'`)}'`; | 22 | return `'${value.replaceAll("'", `'"'"'`)}'`; |
| 24 | } | 23 | } |
| 25 | 24 | ||
| 26 | const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( | 25 | const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { |
| 27 | Layer.provideMerge(ServerSettingsService.layerTest()), | 26 | prefix: "t3code-cursor-text-generation-test-", |
| 28 | Layer.provideMerge( | 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); |
| 29 | ServerConfig.layerTest(process.cwd(), { | ||
| 30 | prefix: "t3code-cursor-text-generation-test-", | ||
| 31 | }), | ||
| 32 | ), | ||
| 33 | Layer.provideMerge(NodeServices.layer), | ||
| 34 | ); | ||
| 35 | 28 | ||
| 36 | function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { | 29 | function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { |
| 37 | const binDir = path.join(dir, "bin"); | 30 | const binDir = path.join(dir, "bin"); |
| ... | @@ -57,44 +50,20 @@ function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { | ... | @@ -57,44 +50,20 @@ function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { |
| 57 | 50 | ||
| 58 | function withFakeAcpAgent<A, E, R>( | 51 | function withFakeAcpAgent<A, E, R>( |
| 59 | env: Record<string, string>, | 52 | env: Record<string, string>, |
| 60 | effect: Effect.Effect<A, E, R>, | 53 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, |
| 61 | ): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> { | 54 | ) { |
| 62 | return Effect.gen(function* () { | 55 | return Effect.gen(function* () { |
| 63 | const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); | 56 | const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); |
| 64 | const agentPath = makeAcpAgentWrapper(tempDir, env); | 57 | yield* Effect.addFinalizer(() => |
| 65 | const serverSettings = yield* ServerSettingsService; | 58 | Effect.sync(() => { |
| 66 | const previousSettings = yield* serverSettings.getSettings; | 59 | rmSync(tempDir, { recursive: true, force: true }); |
| 67 | 60 | }), | |
| 68 | yield* serverSettings.updateSettings({ | ||
| 69 | providers: { | ||
| 70 | cursor: { | ||
| 71 | binaryPath: agentPath, | ||
| 72 | }, | ||
| 73 | }, | ||
| 74 | }); | ||
| 75 | |||
| 76 | return yield* effect.pipe( | ||
| 77 | Effect.ensuring( | ||
| 78 | serverSettings | ||
| 79 | .updateSettings({ | ||
| 80 | providers: { | ||
| 81 | cursor: { | ||
| 82 | binaryPath: previousSettings.providers.cursor.binaryPath, | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | }) | ||
| 86 | .pipe( | ||
| 87 | Effect.catch(() => Effect.void), | ||
| 88 | Effect.ensuring( | ||
| 89 | Effect.sync(() => { | ||
| 90 | rmSync(tempDir, { recursive: true, force: true }); | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | Effect.asVoid, | ||
| 94 | ), | ||
| 95 | ), | ||
| 96 | ); | 61 | ); |
| 97 | }); | 62 | const agentPath = makeAcpAgentWrapper(tempDir, env); |
| 63 | const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath }); | ||
| 64 | const textGeneration = yield* makeCursorTextGeneration(config); | ||
| 65 | return yield* effectFn(textGeneration); | ||
| 66 | }).pipe(Effect.scoped); | ||
| 98 | } | 67 | } |
| 99 | 68 | ||
| 100 | function waitForFileContent(path: string): Effect.Effect<string> { | 69 | function waitForFileContent(path: string): Effect.Effect<string> { |
| ... | @@ -126,85 +95,86 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { | ... | @@ -126,85 +95,86 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { |
| 126 | body: "- verify cursor acp model config path", | 95 | body: "- verify cursor acp model config path", |
| 127 | }), | 96 | }), |
| 128 | }, | 97 | }, |
| 129 | Effect.gen(function* () { | 98 | (textGeneration) => |
| 130 | const textGeneration = yield* TextGeneration; | 99 | Effect.gen(function* () { |
| 131 | 100 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 132 | const generated = yield* textGeneration.generateCommitMessage({ | 101 | cwd: process.cwd(), |
| 133 | cwd: process.cwd(), | 102 | branch: "feature/cursor-text-generation", |
| 134 | branch: "feature/cursor-text-generation", | 103 | stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", |
| 135 | stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", | 104 | stagedPatch: |
| 136 | stagedPatch: | 105 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", |
| 137 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", | 106 | modelSelection: { |
| 138 | modelSelection: { | 107 | ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ |
| 139 | ...createModelSelection("cursor", "gpt-5.4", [ | 108 | { id: "reasoning", value: "xhigh" }, |
| 140 | { id: "reasoning", value: "xhigh" }, | 109 | { id: "fastMode", value: true }, |
| 141 | { id: "fastMode", value: true }, | 110 | { id: "contextWindow", value: "1m" }, |
| 142 | { id: "contextWindow", value: "1m" }, | 111 | ]), |
| 143 | ]), | 112 | }, |
| 144 | }, | 113 | }); |
| 145 | }); | ||
| 146 | 114 | ||
| 147 | expect(generated.subject).toBe("Add generated commit message"); | 115 | expect(generated.subject).toBe("Add generated commit message"); |
| 148 | expect(generated.body).toBe("- verify cursor acp model config path"); | 116 | expect(generated.body).toBe("- verify cursor acp model config path"); |
| 149 | 117 | ||
| 150 | const requests = readFileSync(requestLogPath, "utf8") | 118 | const requests = readFileSync(requestLogPath, "utf8") |
| 151 | .trim() | 119 | .trim() |
| 152 | .split("\n") | 120 | .split("\n") |
| 153 | .filter((line) => line.length > 0) | 121 | .filter((line) => line.length > 0) |
| 154 | .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> }); | 122 | .map( |
| 123 | (line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> }, | ||
| 124 | ); | ||
| 155 | 125 | ||
| 156 | expect( | 126 | expect( |
| 157 | requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, | 127 | requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, |
| 158 | ).toMatchObject({ | 128 | ).toMatchObject({ |
| 159 | _meta: { | 129 | _meta: { |
| 160 | parameterizedModelPicker: true, | 130 | parameterizedModelPicker: true, |
| 161 | }, | 131 | }, |
| 162 | }); | 132 | }); |
| 163 | expect( | 133 | expect( |
| 164 | requests.some( | 134 | requests.some( |
| 165 | (request) => | 135 | (request) => |
| 166 | request.method === "session/set_config_option" && | 136 | request.method === "session/set_config_option" && |
| 167 | request.params?.configId === "model" && | 137 | request.params?.configId === "model" && |
| 168 | request.params?.value === "gpt-5.4", | 138 | request.params?.value === "gpt-5.4", |
| 169 | ), | 139 | ), |
| 170 | ).toBe(true); | 140 | ).toBe(true); |
| 171 | expect( | 141 | expect( |
| 172 | requests.some( | 142 | requests.some( |
| 173 | (request) => | 143 | (request) => |
| 174 | request.method === "session/set_config_option" && | 144 | request.method === "session/set_config_option" && |
| 175 | request.params?.configId === "reasoning" && | 145 | request.params?.configId === "reasoning" && |
| 176 | request.params?.value === "extra-high", | 146 | request.params?.value === "extra-high", |
| 177 | ), | 147 | ), |
| 178 | ).toBe(true); | 148 | ).toBe(true); |
| 179 | expect( | 149 | expect( |
| 180 | requests.some( | 150 | requests.some( |
| 181 | (request) => | 151 | (request) => |
| 182 | request.method === "session/set_config_option" && | 152 | request.method === "session/set_config_option" && |
| 183 | request.params?.configId === "context" && | 153 | request.params?.configId === "context" && |
| 184 | request.params?.value === "1m", | 154 | request.params?.value === "1m", |
| 185 | ), | 155 | ), |
| 186 | ).toBe(true); | 156 | ).toBe(true); |
| 187 | expect( | 157 | expect( |
| 188 | requests.some( | 158 | requests.some( |
| 189 | (request) => | 159 | (request) => |
| 190 | request.method === "session/set_config_option" && | 160 | request.method === "session/set_config_option" && |
| 191 | request.params?.configId === "fast" && | 161 | request.params?.configId === "fast" && |
| 192 | request.params?.value === "true", | 162 | request.params?.value === "true", |
| 193 | ), | 163 | ), |
| 194 | ).toBe(true); | 164 | ).toBe(true); |
| 195 | expect( | 165 | expect( |
| 196 | requests.find((request) => request.method === "session/prompt")?.params?.prompt, | 166 | requests.find((request) => request.method === "session/prompt")?.params?.prompt, |
| 197 | ).toEqual( | 167 | ).toEqual( |
| 198 | expect.arrayContaining([ | 168 | expect.arrayContaining([ |
| 199 | expect.objectContaining({ | 169 | expect.objectContaining({ |
| 200 | type: "text", | 170 | type: "text", |
| 201 | text: expect.stringContaining("Staged patch:"), | 171 | text: expect.stringContaining("Staged patch:"), |
| 202 | }), | 172 | }), |
| 203 | ]), | 173 | ]), |
| 204 | ); | 174 | ); |
| 205 | 175 | ||
| 206 | rmSync(requestLogDir, { recursive: true, force: true }); | 176 | rmSync(requestLogDir, { recursive: true, force: true }); |
| 207 | }), | 177 | }), |
| 208 | ); | 178 | ); |
| 209 | }); | 179 | }); |
| 210 | 180 | ||
| ... | @@ -214,23 +184,22 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { | ... | @@ -214,23 +184,22 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { |
| 214 | T3_ACP_PROMPT_RESPONSE_TEXT: | 184 | T3_ACP_PROMPT_RESPONSE_TEXT: |
| 215 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', | 185 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', |
| 216 | }, | 186 | }, |
| 217 | Effect.gen(function* () { | 187 | (textGeneration) => |
| 218 | const textGeneration = yield* TextGeneration; | 188 | Effect.gen(function* () { |
| 219 | 189 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 220 | const generated = yield* textGeneration.generateCommitMessage({ | 190 | cwd: process.cwd(), |
| 221 | cwd: process.cwd(), | 191 | branch: "feature/cursor-noisy-json", |
| 222 | branch: "feature/cursor-noisy-json", | 192 | stagedSummary: "M README.md", |
| 223 | stagedSummary: "M README.md", | 193 | stagedPatch: "diff --git a/README.md b/README.md", |
| 224 | stagedPatch: "diff --git a/README.md b/README.md", | 194 | modelSelection: { |
| 225 | modelSelection: { | 195 | instanceId: ProviderInstanceId.make("cursor"), |
| 226 | provider: "cursor", | 196 | model: "composer-2", |
| 227 | model: "composer-2", | 197 | }, |
| 228 | }, | 198 | }); |
| 229 | }); | ||
| 230 | 199 | ||
| 231 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | 200 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); |
| 232 | expect(generated.body).toBe(""); | 201 | expect(generated.body).toBe(""); |
| 233 | }), | 202 | }), |
| 234 | ), | 203 | ), |
| 235 | ); | 204 | ); |
| 236 | 205 | ||
| ... | @@ -241,20 +210,19 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { | ... | @@ -241,20 +210,19 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { |
| 241 | title: '"Trim reconnect spinner status after resume."', | 210 | title: '"Trim reconnect spinner status after resume."', |
| 242 | }), | 211 | }), |
| 243 | }, | 212 | }, |
| 244 | Effect.gen(function* () { | 213 | (textGeneration) => |
| 245 | const textGeneration = yield* TextGeneration; | 214 | Effect.gen(function* () { |
| 246 | 215 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 247 | const generated = yield* textGeneration.generateThreadTitle({ | 216 | cwd: process.cwd(), |
| 248 | cwd: process.cwd(), | 217 | message: "Fix the reconnect spinner after a resumed session.", |
| 249 | message: "Fix the reconnect spinner after a resumed session.", | 218 | modelSelection: { |
| 250 | modelSelection: { | 219 | instanceId: ProviderInstanceId.make("cursor"), |
| 251 | provider: "cursor", | 220 | model: "composer-2", |
| 252 | model: "composer-2", | 221 | }, |
| 253 | }, | 222 | }); |
| 254 | }); | ||
| 255 | 223 | ||
| 256 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); | 224 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); |
| 257 | }), | 225 | }), |
| 258 | ), | 226 | ), |
| 259 | ); | 227 | ); |
| 260 | 228 | ||
| ... | @@ -270,28 +238,27 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { | ... | @@ -270,28 +238,27 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { |
| 270 | body: "", | 238 | body: "", |
| 271 | }), | 239 | }), |
| 272 | }, | 240 | }, |
| 273 | Effect.gen(function* () { | 241 | (textGeneration) => |
| 274 | const textGeneration = yield* TextGeneration; | 242 | Effect.gen(function* () { |
| 275 | 243 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 276 | const generated = yield* textGeneration.generateCommitMessage({ | 244 | cwd: process.cwd(), |
| 277 | cwd: process.cwd(), | 245 | branch: "feature/cursor-runtime-close", |
| 278 | branch: "feature/cursor-runtime-close", | 246 | stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", |
| 279 | stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", | 247 | stagedPatch: |
| 280 | stagedPatch: | 248 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", |
| 281 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", | 249 | modelSelection: { |
| 282 | modelSelection: { | 250 | instanceId: ProviderInstanceId.make("cursor"), |
| 283 | provider: "cursor", | 251 | model: "composer-2", |
| 284 | model: "composer-2", | 252 | }, |
| 285 | }, | 253 | }); |
| 286 | }); | ||
| 287 | 254 | ||
| 288 | expect(generated.subject).toBe("Close runtime after generation"); | 255 | expect(generated.subject).toBe("Close runtime after generation"); |
| 289 | 256 | ||
| 290 | const exitLog = yield* waitForFileContent(exitLogPath); | 257 | const exitLog = yield* waitForFileContent(exitLogPath); |
| 291 | expect(exitLog).toContain("exit:0"); | 258 | expect(exitLog).toContain("exit:0"); |
| 292 | 259 | ||
| 293 | rmSync(exitLogDir, { recursive: true, force: true }); | 260 | rmSync(exitLogDir, { recursive: true, force: true }); |
| 294 | }), | 261 | }), |
| 295 | ); | 262 | ); |
| 296 | }); | 263 | }); |
| 297 | }); | 264 | }); |
apps/server/src/git/Layers/CursorTextGeneration.ts+15-41| ... | @@ -1,14 +1,13 @@ | ... | @@ -1,14 +1,13 @@ |
| 1 | import { Effect, Layer, Option, Ref, Schema } from "effect"; | 1 | import { Effect, Option, Ref, Schema } from "effect"; |
| 2 | import { ChildProcessSpawner } from "effect/unstable/process"; | 2 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 3 | 3 | ||
| 4 | import { CursorModelSelection } from "@t3tools/contracts"; | 4 | import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; |
| 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 6 | 6 | ||
| 7 | import { TextGenerationError } from "@t3tools/contracts"; | 7 | import { TextGenerationError } from "@t3tools/contracts"; |
| 8 | import { | 8 | import { |
| 9 | type ThreadTitleGenerationResult, | 9 | type ThreadTitleGenerationResult, |
| 10 | type TextGenerationShape, | 10 | type TextGenerationShape, |
| 11 | TextGeneration, | ||
| 12 | } from "../Services/TextGeneration.ts"; | 11 | } from "../Services/TextGeneration.ts"; |
| 13 | import { | 12 | import { |
| 14 | buildBranchNamePrompt, | 13 | buildBranchNamePrompt, |
| ... | @@ -26,7 +25,6 @@ import { | ... | @@ -26,7 +25,6 @@ import { |
| 26 | applyCursorAcpModelSelection, | 25 | applyCursorAcpModelSelection, |
| 27 | makeCursorAcpRuntime, | 26 | makeCursorAcpRuntime, |
| 28 | } from "../../provider/acp/CursorAcpSupport.ts"; | 27 | } from "../../provider/acp/CursorAcpSupport.ts"; |
| 29 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 30 | 28 | ||
| 31 | const CURSOR_TIMEOUT_MS = 180_000; | 29 | const CURSOR_TIMEOUT_MS = 180_000; |
| 32 | 30 | ||
| ... | @@ -55,9 +53,15 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { | ... | @@ -55,9 +53,15 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { |
| 55 | ); | 53 | ); |
| 56 | } | 54 | } |
| 57 | 55 | ||
| 58 | const makeCursorTextGeneration = Effect.gen(function* () { | 56 | /** |
| 57 | * Build a Cursor text-generation closure bound to a specific `CursorSettings` | ||
| 58 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | ||
| 59 | */ | ||
| 60 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( | ||
| 61 | cursorSettings: CursorSettings, | ||
| 62 | environment: NodeJS.ProcessEnv = process.env, | ||
| 63 | ) { | ||
| 59 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 64 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 60 | const serverSettingsService = yield* Effect.service(ServerSettingsService); | ||
| 61 | 65 | ||
| 62 | const runCursorJson = <S extends Schema.Top>({ | 66 | const runCursorJson = <S extends Schema.Top>({ |
| 63 | operation, | 67 | operation, |
| ... | @@ -74,17 +78,13 @@ const makeCursorTextGeneration = Effect.gen(function* () { | ... | @@ -74,17 +78,13 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 74 | cwd: string; | 78 | cwd: string; |
| 75 | prompt: string; | 79 | prompt: string; |
| 76 | outputSchemaJson: S; | 80 | outputSchemaJson: S; |
| 77 | modelSelection: CursorModelSelection; | 81 | modelSelection: ModelSelection; |
| 78 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | 82 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => |
| 79 | Effect.gen(function* () { | 83 | Effect.gen(function* () { |
| 80 | const cursorSettings = yield* Effect.map( | ||
| 81 | serverSettingsService.getSettings, | ||
| 82 | (settings) => settings.providers.cursor, | ||
| 83 | ).pipe(Effect.catch(() => Effect.undefined)); | ||
| 84 | |||
| 85 | const outputRef = yield* Ref.make(""); | 84 | const outputRef = yield* Ref.make(""); |
| 86 | const runtime = yield* makeCursorAcpRuntime({ | 85 | const runtime = yield* makeCursorAcpRuntime({ |
| 87 | cursorSettings, | 86 | cursorSettings, |
| 87 | environment, | ||
| 88 | childProcessSpawner: commandSpawner, | 88 | childProcessSpawner: commandSpawner, |
| 89 | cwd, | 89 | cwd, |
| 90 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, | 90 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, |
| ... | @@ -186,13 +186,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { | ... | @@ -186,13 +186,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 186 | includeBranch: input.includeBranch === true, | 186 | includeBranch: input.includeBranch === true, |
| 187 | }); | 187 | }); |
| 188 | 188 | ||
| 189 | if (input.modelSelection.provider !== "cursor") { | ||
| 190 | return yield* new TextGenerationError({ | ||
| 191 | operation: "generateCommitMessage", | ||
| 192 | detail: "Invalid model selection.", | ||
| 193 | }); | ||
| 194 | } | ||
| 195 | |||
| 196 | const generated = yield* runCursorJson({ | 189 | const generated = yield* runCursorJson({ |
| 197 | operation: "generateCommitMessage", | 190 | operation: "generateCommitMessage", |
| 198 | cwd: input.cwd, | 191 | cwd: input.cwd, |
| ... | @@ -221,13 +214,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { | ... | @@ -221,13 +214,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 221 | diffPatch: input.diffPatch, | 214 | diffPatch: input.diffPatch, |
| 222 | }); | 215 | }); |
| 223 | 216 | ||
| 224 | if (input.modelSelection.provider !== "cursor") { | ||
| 225 | return yield* new TextGenerationError({ | ||
| 226 | operation: "generatePrContent", | ||
| 227 | detail: "Invalid model selection.", | ||
| 228 | }); | ||
| 229 | } | ||
| 230 | |||
| 231 | const generated = yield* runCursorJson({ | 217 | const generated = yield* runCursorJson({ |
| 232 | operation: "generatePrContent", | 218 | operation: "generatePrContent", |
| 233 | cwd: input.cwd, | 219 | cwd: input.cwd, |
| ... | @@ -250,13 +236,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { | ... | @@ -250,13 +236,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 250 | attachments: input.attachments, | 236 | attachments: input.attachments, |
| 251 | }); | 237 | }); |
| 252 | 238 | ||
| 253 | if (input.modelSelection.provider !== "cursor") { | ||
| 254 | return yield* new TextGenerationError({ | ||
| 255 | operation: "generateBranchName", | ||
| 256 | detail: "Invalid model selection.", | ||
| 257 | }); | ||
| 258 | } | ||
| 259 | |||
| 260 | const generated = yield* runCursorJson({ | 239 | const generated = yield* runCursorJson({ |
| 261 | operation: "generateBranchName", | 240 | operation: "generateBranchName", |
| 262 | cwd: input.cwd, | 241 | cwd: input.cwd, |
| ... | @@ -278,13 +257,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { | ... | @@ -278,13 +257,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 278 | attachments: input.attachments, | 257 | attachments: input.attachments, |
| 279 | }); | 258 | }); |
| 280 | 259 | ||
| 281 | if (input.modelSelection.provider !== "cursor") { | ||
| 282 | return yield* new TextGenerationError({ | ||
| 283 | operation: "generateThreadTitle", | ||
| 284 | detail: "Invalid model selection.", | ||
| 285 | }); | ||
| 286 | } | ||
| 287 | |||
| 288 | const generated = yield* runCursorJson({ | 260 | const generated = yield* runCursorJson({ |
| 289 | operation: "generateThreadTitle", | 261 | operation: "generateThreadTitle", |
| 290 | cwd: input.cwd, | 262 | cwd: input.cwd, |
| ... | @@ -306,4 +278,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { | ... | @@ -306,4 +278,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 306 | } satisfies TextGenerationShape; | 278 | } satisfies TextGenerationShape; |
| 307 | }); | 279 | }); |
| 308 | 280 | ||
| 309 | export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration); | 281 | // NOTE: `CursorTextGenerationLive` (the singleton Layer) has been removed. |
| 282 | // `makeCursorTextGeneration(cursorConfig)` is now invoked directly by | ||
| 283 | // `CursorDriver.create()` so each provider instance owns its own closure. |
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts+180-174| ... | @@ -1,19 +1,19 @@ | ... | @@ -1,19 +1,19 @@ |
| 1 | import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it } from "@effect/vitest"; | 3 | import { it } from "@effect/vitest"; |
| 3 | import { Duration, Effect, Layer } from "effect"; | 4 | import { Duration, Effect, Layer, Schema } from "effect"; |
| 4 | import { TestClock } from "effect/testing"; | 5 | import { TestClock } from "effect/testing"; |
| 5 | import { NetService } from "@t3tools/shared/Net"; | 6 | import { NetService } from "@t3tools/shared/Net"; |
| 6 | import { beforeEach, expect } from "vitest"; | 7 | import { beforeEach, expect } from "vitest"; |
| 7 | 8 | ||
| 8 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 9 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 10 | import { | 10 | import { |
| 11 | OpenCodeRuntime, | 11 | OpenCodeRuntime, |
| 12 | OpenCodeRuntimeError, | 12 | OpenCodeRuntimeError, |
| 13 | type OpenCodeRuntimeShape, | 13 | type OpenCodeRuntimeShape, |
| 14 | } from "../../provider/opencodeRuntime.ts"; | 14 | } from "../../provider/opencodeRuntime.ts"; |
| 15 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 15 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; |
| 16 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | 16 | import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; |
| 17 | 17 | ||
| 18 | const runtimeMock = { | 18 | const runtimeMock = { |
| 19 | state: { | 19 | state: { |
| ... | @@ -97,23 +97,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -97,23 +97,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 97 | }; | 97 | }; |
| 98 | 98 | ||
| 99 | const DEFAULT_TEST_MODEL_SELECTION = { | 99 | const DEFAULT_TEST_MODEL_SELECTION = { |
| 100 | provider: "opencode" as const, | 100 | instanceId: ProviderInstanceId.make("opencode"), |
| 101 | model: "openai/gpt-5", | 101 | model: "openai/gpt-5", |
| 102 | }; | 102 | }; |
| 103 | 103 | ||
| 104 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | 104 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; |
| 105 | 105 | ||
| 106 | const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( | 106 | const OpenCodeTextGenerationTestLayer = Layer.succeed( |
| 107 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | 107 | OpenCodeRuntime, |
| 108 | Layer.provideMerge( | 108 | OpenCodeRuntimeTestDouble, |
| 109 | ServerSettingsService.layerTest({ | 109 | ).pipe( |
| 110 | providers: { | ||
| 111 | opencode: { | ||
| 112 | binaryPath: "fake-opencode", | ||
| 113 | }, | ||
| 114 | }, | ||
| 115 | }), | ||
| 116 | ), | ||
| 117 | Layer.provideMerge( | 110 | Layer.provideMerge( |
| 118 | ServerConfig.layerTest(process.cwd(), { | 111 | ServerConfig.layerTest(process.cwd(), { |
| 119 | prefix: "t3code-opencode-text-generation-test-", | 112 | prefix: "t3code-opencode-text-generation-test-", |
| ... | @@ -123,19 +116,10 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( | ... | @@ -123,19 +116,10 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( |
| 123 | Layer.provideMerge(NodeServices.layer), | 116 | Layer.provideMerge(NodeServices.layer), |
| 124 | ); | 117 | ); |
| 125 | 118 | ||
| 126 | const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( | 119 | const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( |
| 127 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | 120 | OpenCodeRuntime, |
| 128 | Layer.provideMerge( | 121 | OpenCodeRuntimeTestDouble, |
| 129 | ServerSettingsService.layerTest({ | 122 | ).pipe( |
| 130 | providers: { | ||
| 131 | opencode: { | ||
| 132 | binaryPath: "fake-opencode", | ||
| 133 | serverUrl: "http://127.0.0.1:9999", | ||
| 134 | serverPassword: "secret-password", | ||
| 135 | }, | ||
| 136 | }, | ||
| 137 | }), | ||
| 138 | ), | ||
| 139 | Layer.provideMerge( | 123 | Layer.provideMerge( |
| 140 | ServerConfig.layerTest(process.cwd(), { | 124 | ServerConfig.layerTest(process.cwd(), { |
| 141 | prefix: "t3code-opencode-text-generation-existing-server-test-", | 125 | prefix: "t3code-opencode-text-generation-existing-server-test-", |
| ... | @@ -145,6 +129,25 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive | ... | @@ -145,6 +129,25 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive |
| 145 | Layer.provideMerge(NodeServices.layer), | 129 | Layer.provideMerge(NodeServices.layer), |
| 146 | ); | 130 | ); |
| 147 | 131 | ||
| 132 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | ||
| 133 | binaryPath: "fake-opencode", | ||
| 134 | }); | ||
| 135 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | ||
| 136 | binaryPath: "fake-opencode", | ||
| 137 | serverUrl: "http://127.0.0.1:9999", | ||
| 138 | serverPassword: "secret-password", | ||
| 139 | }); | ||
| 140 | |||
| 141 | function withOpenCodeTextGeneration<A, E, R>( | ||
| 142 | settings: OpenCodeSettings, | ||
| 143 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 144 | ) { | ||
| 145 | return Effect.gen(function* () { | ||
| 146 | const textGeneration = yield* makeOpenCodeTextGeneration(settings); | ||
| 147 | return yield* effectFn(textGeneration); | ||
| 148 | }).pipe(Effect.scoped); | ||
| 149 | } | ||
| 150 | |||
| 148 | beforeEach(() => { | 151 | beforeEach(() => { |
| 149 | runtimeMock.reset(); | 152 | runtimeMock.reset(); |
| 150 | }); | 153 | }); |
| ... | @@ -157,155 +160,40 @@ const advanceIdleClock = Effect.gen(function* () { | ... | @@ -157,155 +160,40 @@ const advanceIdleClock = Effect.gen(function* () { |
| 157 | 160 | ||
| 158 | it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { | 161 | it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { |
| 159 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | 162 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => |
| 160 | Effect.gen(function* () { | 163 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => |
| 161 | const textGeneration = yield* TextGeneration; | 164 | Effect.gen(function* () { |
| 162 | 165 | yield* textGeneration.generateCommitMessage({ | |
| 163 | yield* textGeneration.generateCommitMessage({ | ||
| 164 | cwd: process.cwd(), | ||
| 165 | branch: "feature/opencode-reuse", | ||
| 166 | stagedSummary: "M README.md", | ||
| 167 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 168 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 169 | }); | ||
| 170 | yield* textGeneration.generateCommitMessage({ | ||
| 171 | cwd: process.cwd(), | ||
| 172 | branch: "feature/opencode-reuse", | ||
| 173 | stagedSummary: "M README.md", | ||
| 174 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 175 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 176 | }); | ||
| 177 | |||
| 178 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | ||
| 179 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 180 | "http://127.0.0.1:4301", | ||
| 181 | "http://127.0.0.1:4301", | ||
| 182 | ]); | ||
| 183 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 184 | |||
| 185 | yield* advanceIdleClock; | ||
| 186 | |||
| 187 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 188 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 189 | ); | ||
| 190 | |||
| 191 | it.effect("starts a new server after the warm server idles out", () => | ||
| 192 | Effect.gen(function* () { | ||
| 193 | const textGeneration = yield* TextGeneration; | ||
| 194 | |||
| 195 | yield* textGeneration.generateCommitMessage({ | ||
| 196 | cwd: process.cwd(), | ||
| 197 | branch: "feature/opencode-reuse", | ||
| 198 | stagedSummary: "M README.md", | ||
| 199 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 200 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 201 | }); | ||
| 202 | |||
| 203 | yield* advanceIdleClock; | ||
| 204 | |||
| 205 | yield* textGeneration.generateCommitMessage({ | ||
| 206 | cwd: process.cwd(), | ||
| 207 | branch: "feature/opencode-reuse", | ||
| 208 | stagedSummary: "M README.md", | ||
| 209 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 210 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 211 | }); | ||
| 212 | |||
| 213 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | ||
| 214 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 215 | "http://127.0.0.1:4301", | ||
| 216 | "http://127.0.0.1:4302", | ||
| 217 | ]); | ||
| 218 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 219 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 220 | ); | ||
| 221 | |||
| 222 | it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | runtimeMock.state.promptResult = { data: {} }; | ||
| 225 | const textGeneration = yield* TextGeneration; | ||
| 226 | |||
| 227 | const error = yield* textGeneration | ||
| 228 | .generateCommitMessage({ | ||
| 229 | cwd: process.cwd(), | 166 | cwd: process.cwd(), |
| 230 | branch: "feature/opencode-reuse", | 167 | branch: "feature/opencode-reuse", |
| 231 | stagedSummary: "M README.md", | 168 | stagedSummary: "M README.md", |
| 232 | stagedPatch: "diff --git a/README.md b/README.md", | 169 | stagedPatch: "diff --git a/README.md b/README.md", |
| 233 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 170 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 234 | }) | 171 | }); |
| 235 | .pipe(Effect.flip); | 172 | yield* textGeneration.generateCommitMessage({ |
| 236 | |||
| 237 | expect(error.message).toContain("OpenCode returned empty output."); | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | |||
| 241 | it.effect("parses JSON returned as plain text output", () => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | runtimeMock.state.promptResult = { | ||
| 244 | data: { | ||
| 245 | parts: [ | ||
| 246 | { | ||
| 247 | type: "text", | ||
| 248 | text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', | ||
| 249 | }, | ||
| 250 | ], | ||
| 251 | }, | ||
| 252 | }; | ||
| 253 | const textGeneration = yield* TextGeneration; | ||
| 254 | |||
| 255 | const result = yield* textGeneration.generateCommitMessage({ | ||
| 256 | cwd: process.cwd(), | ||
| 257 | branch: "feature/opencode-reuse", | ||
| 258 | stagedSummary: "M README.md", | ||
| 259 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 260 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 261 | }); | ||
| 262 | |||
| 263 | expect(result).toEqual({ | ||
| 264 | subject: "Tighten OpenCode parsing", | ||
| 265 | body: "Handle JSON text output locally.", | ||
| 266 | }); | ||
| 267 | }), | ||
| 268 | ); | ||
| 269 | |||
| 270 | it.effect("surfaces the upstream OpenCode structured-output error message", () => | ||
| 271 | Effect.gen(function* () { | ||
| 272 | runtimeMock.state.promptResult = { | ||
| 273 | data: { | ||
| 274 | info: { | ||
| 275 | error: { | ||
| 276 | name: "StructuredOutputError", | ||
| 277 | data: { | ||
| 278 | message: "Model did not produce structured output", | ||
| 279 | retries: 2, | ||
| 280 | }, | ||
| 281 | }, | ||
| 282 | }, | ||
| 283 | }, | ||
| 284 | }; | ||
| 285 | const textGeneration = yield* TextGeneration; | ||
| 286 | |||
| 287 | const error = yield* textGeneration | ||
| 288 | .generateCommitMessage({ | ||
| 289 | cwd: process.cwd(), | 173 | cwd: process.cwd(), |
| 290 | branch: "feature/opencode-reuse", | 174 | branch: "feature/opencode-reuse", |
| 291 | stagedSummary: "M README.md", | 175 | stagedSummary: "M README.md", |
| 292 | stagedPatch: "diff --git a/README.md b/README.md", | 176 | stagedPatch: "diff --git a/README.md b/README.md", |
| 293 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 177 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 294 | }) | 178 | }); |
| 295 | .pipe(Effect.flip); | ||
| 296 | 179 | ||
| 297 | expect(error.message).toContain("Model did not produce structured output"); | 180 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); |
| 298 | }), | 181 | expect(runtimeMock.state.promptUrls).toEqual([ |
| 182 | "http://127.0.0.1:4301", | ||
| 183 | "http://127.0.0.1:4301", | ||
| 184 | ]); | ||
| 185 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 186 | |||
| 187 | yield* advanceIdleClock; | ||
| 188 | |||
| 189 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 190 | }), | ||
| 191 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 299 | ); | 192 | ); |
| 300 | }); | ||
| 301 | 193 | ||
| 302 | it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | 194 | it.effect("starts a new server after the warm server idles out", () => |
| 303 | "OpenCodeTextGenerationLive with configured server URL", | 195 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => |
| 304 | (it) => { | ||
| 305 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | ||
| 306 | Effect.gen(function* () { | 196 | Effect.gen(function* () { |
| 307 | const textGeneration = yield* TextGeneration; | ||
| 308 | |||
| 309 | yield* textGeneration.generateCommitMessage({ | 197 | yield* textGeneration.generateCommitMessage({ |
| 310 | cwd: process.cwd(), | 198 | cwd: process.cwd(), |
| 311 | branch: "feature/opencode-reuse", | 199 | branch: "feature/opencode-reuse", |
| ... | @@ -313,6 +201,9 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | ... | @@ -313,6 +201,9 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( |
| 313 | stagedPatch: "diff --git a/README.md b/README.md", | 201 | stagedPatch: "diff --git a/README.md b/README.md", |
| 314 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 202 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 315 | }); | 203 | }); |
| 204 | |||
| 205 | yield* advanceIdleClock; | ||
| 206 | |||
| 316 | yield* textGeneration.generateCommitMessage({ | 207 | yield* textGeneration.generateCommitMessage({ |
| 317 | cwd: process.cwd(), | 208 | cwd: process.cwd(), |
| 318 | branch: "feature/opencode-reuse", | 209 | branch: "feature/opencode-reuse", |
| ... | @@ -321,20 +212,135 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | ... | @@ -321,20 +212,135 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( |
| 321 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | 212 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, |
| 322 | }); | 213 | }); |
| 323 | 214 | ||
| 324 | expect(runtimeMock.state.startCalls).toEqual([]); | 215 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); |
| 325 | expect(runtimeMock.state.promptUrls).toEqual([ | 216 | expect(runtimeMock.state.promptUrls).toEqual([ |
| 326 | "http://127.0.0.1:9999", | 217 | "http://127.0.0.1:4301", |
| 327 | "http://127.0.0.1:9999", | 218 | "http://127.0.0.1:4302", |
| 328 | ]); | ||
| 329 | expect(runtimeMock.state.authHeaders).toEqual([ | ||
| 330 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 331 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 332 | ]); | 219 | ]); |
| 220 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 221 | }), | ||
| 222 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 223 | ); | ||
| 333 | 224 | ||
| 334 | yield* advanceIdleClock; | 225 | it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => |
| 226 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | runtimeMock.state.promptResult = { data: {} }; | ||
| 229 | |||
| 230 | const error = yield* textGeneration | ||
| 231 | .generateCommitMessage({ | ||
| 232 | cwd: process.cwd(), | ||
| 233 | branch: "feature/opencode-reuse", | ||
| 234 | stagedSummary: "M README.md", | ||
| 235 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 236 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 237 | }) | ||
| 238 | .pipe(Effect.flip); | ||
| 239 | |||
| 240 | expect(error.message).toContain("OpenCode returned empty output."); | ||
| 241 | }), | ||
| 242 | ), | ||
| 243 | ); | ||
| 335 | 244 | ||
| 336 | expect(runtimeMock.state.closeCalls).toEqual([]); | 245 | it.effect("parses JSON returned as plain text output", () => |
| 337 | }).pipe(Effect.provide(TestClock.layer())), | 246 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => |
| 247 | Effect.gen(function* () { | ||
| 248 | runtimeMock.state.promptResult = { | ||
| 249 | data: { | ||
| 250 | parts: [ | ||
| 251 | { | ||
| 252 | type: "text", | ||
| 253 | text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', | ||
| 254 | }, | ||
| 255 | ], | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | |||
| 259 | const result = yield* textGeneration.generateCommitMessage({ | ||
| 260 | cwd: process.cwd(), | ||
| 261 | branch: "feature/opencode-reuse", | ||
| 262 | stagedSummary: "M README.md", | ||
| 263 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 264 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 265 | }); | ||
| 266 | |||
| 267 | expect(result).toEqual({ | ||
| 268 | subject: "Tighten OpenCode parsing", | ||
| 269 | body: "Handle JSON text output locally.", | ||
| 270 | }); | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect("surfaces the upstream OpenCode structured-output error message", () => | ||
| 276 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | runtimeMock.state.promptResult = { | ||
| 279 | data: { | ||
| 280 | info: { | ||
| 281 | error: { | ||
| 282 | name: "StructuredOutputError", | ||
| 283 | data: { | ||
| 284 | message: "Model did not produce structured output", | ||
| 285 | retries: 2, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | }, | ||
| 289 | }, | ||
| 290 | }; | ||
| 291 | |||
| 292 | const error = yield* textGeneration | ||
| 293 | .generateCommitMessage({ | ||
| 294 | cwd: process.cwd(), | ||
| 295 | branch: "feature/opencode-reuse", | ||
| 296 | stagedSummary: "M README.md", | ||
| 297 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 298 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 299 | }) | ||
| 300 | .pipe(Effect.flip); | ||
| 301 | |||
| 302 | expect(error.message).toContain("Model did not produce structured output"); | ||
| 303 | }), | ||
| 304 | ), | ||
| 305 | ); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | ||
| 309 | "OpenCodeTextGenerationLive with configured server URL", | ||
| 310 | (it) => { | ||
| 311 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | ||
| 312 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | yield* textGeneration.generateCommitMessage({ | ||
| 315 | cwd: process.cwd(), | ||
| 316 | branch: "feature/opencode-reuse", | ||
| 317 | stagedSummary: "M README.md", | ||
| 318 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 319 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 320 | }); | ||
| 321 | yield* textGeneration.generateCommitMessage({ | ||
| 322 | cwd: process.cwd(), | ||
| 323 | branch: "feature/opencode-reuse", | ||
| 324 | stagedSummary: "M README.md", | ||
| 325 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 326 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 327 | }); | ||
| 328 | |||
| 329 | expect(runtimeMock.state.startCalls).toEqual([]); | ||
| 330 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 331 | "http://127.0.0.1:9999", | ||
| 332 | "http://127.0.0.1:9999", | ||
| 333 | ]); | ||
| 334 | expect(runtimeMock.state.authHeaders).toEqual([ | ||
| 335 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 336 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 337 | ]); | ||
| 338 | |||
| 339 | yield* advanceIdleClock; | ||
| 340 | |||
| 341 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 342 | }), | ||
| 343 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 338 | ); | 344 | ); |
| 339 | }, | 345 | }, |
| 340 | ); | 346 | ); |
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+15-62| ... | @@ -1,24 +1,24 @@ | ... | @@ -1,24 +1,24 @@ |
| 1 | import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; | 1 | import { Effect, Exit, Fiber, Schema, Scope } from "effect"; |
| 2 | import * as Semaphore from "effect/Semaphore"; | 2 | import * as Semaphore from "effect/Semaphore"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | TextGenerationError, | 5 | TextGenerationError, |
| 6 | type ChatAttachment, | 6 | type ChatAttachment, |
| 7 | type OpenCodeModelSelection, | 7 | type ModelSelection, |
| 8 | type OpenCodeSettings, | ||
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 10 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 10 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 11 | 12 | ||
| 12 | import { ServerConfig } from "../../config.ts"; | 13 | import { ServerConfig } from "../../config.ts"; |
| 13 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 14 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 14 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 15 | import { | 15 | import { |
| 16 | buildBranchNamePrompt, | 16 | buildBranchNamePrompt, |
| 17 | buildCommitMessagePrompt, | 17 | buildCommitMessagePrompt, |
| 18 | buildPrContentPrompt, | 18 | buildPrContentPrompt, |
| 19 | buildThreadTitlePrompt, | 19 | buildThreadTitlePrompt, |
| 20 | } from "../Prompts.ts"; | 20 | } from "../Prompts.ts"; |
| 21 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | 21 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; |
| 22 | import { | 22 | import { |
| 23 | extractJsonObject, | 23 | extractJsonObject, |
| 24 | sanitizeCommitSubject, | 24 | sanitizeCommitSubject, |
| ... | @@ -93,9 +93,11 @@ interface SharedOpenCodeTextGenerationServerState { | ... | @@ -93,9 +93,11 @@ interface SharedOpenCodeTextGenerationServerState { |
| 93 | idleCloseFiber: Fiber.Fiber<void, never> | null; | 93 | idleCloseFiber: Fiber.Fiber<void, never> | null; |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | const makeOpenCodeTextGeneration = Effect.gen(function* () { | 96 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( |
| 97 | openCodeSettings: OpenCodeSettings, | ||
| 98 | environment: NodeJS.ProcessEnv = process.env, | ||
| 99 | ) { | ||
| 97 | const serverConfig = yield* ServerConfig; | 100 | const serverConfig = yield* ServerConfig; |
| 98 | const serverSettingsService = yield* ServerSettingsService; | ||
| 99 | const openCodeRuntime = yield* OpenCodeRuntime; | 101 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 100 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | 102 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => |
| 101 | Scope.close(scope, Exit.void), | 103 | Scope.close(scope, Exit.void), |
| ... | @@ -202,6 +204,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -202,6 +204,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 202 | openCodeRuntime | 204 | openCodeRuntime |
| 203 | .startOpenCodeServerProcess({ | 205 | .startOpenCodeServerProcess({ |
| 204 | binaryPath: input.binaryPath, | 206 | binaryPath: input.binaryPath, |
| 207 | environment, | ||
| 205 | }) | 208 | }) |
| 206 | .pipe( | 209 | .pipe( |
| 207 | Effect.provideService(Scope.Scope, serverScope), | 210 | Effect.provideService(Scope.Scope, serverScope), |
| ... | @@ -267,7 +270,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -267,7 +270,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 267 | readonly cwd: string; | 270 | readonly cwd: string; |
| 268 | readonly prompt: string; | 271 | readonly prompt: string; |
| 269 | readonly outputSchemaJson: S; | 272 | readonly outputSchemaJson: S; |
| 270 | readonly modelSelection: OpenCodeModelSelection; | 273 | readonly modelSelection: ModelSelection; |
| 271 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | 274 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 272 | }) { | 275 | }) { |
| 273 | const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); | 276 | const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); |
| ... | @@ -278,26 +281,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -278,26 +281,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 278 | }); | 281 | }); |
| 279 | } | 282 | } |
| 280 | 283 | ||
| 281 | const settings = yield* serverSettingsService.getSettings.pipe( | ||
| 282 | Effect.map( | ||
| 283 | (value) => | ||
| 284 | value.providers?.opencode ?? { | ||
| 285 | enabled: true, | ||
| 286 | binaryPath: "opencode", | ||
| 287 | serverUrl: "", | ||
| 288 | serverPassword: "", | ||
| 289 | customModels: [], | ||
| 290 | }, | ||
| 291 | ), | ||
| 292 | Effect.orElseSucceed(() => ({ | ||
| 293 | enabled: true, | ||
| 294 | binaryPath: "opencode", | ||
| 295 | serverUrl: "", | ||
| 296 | serverPassword: "", | ||
| 297 | customModels: [], | ||
| 298 | })), | ||
| 299 | ); | ||
| 300 | |||
| 301 | const fileParts = toOpenCodeFileParts({ | 284 | const fileParts = toOpenCodeFileParts({ |
| 302 | attachments: input.attachments, | 285 | attachments: input.attachments, |
| 303 | resolveAttachmentPath: (attachment) => | 286 | resolveAttachmentPath: (attachment) => |
| ... | @@ -310,8 +293,8 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -310,8 +293,8 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 310 | const client = openCodeRuntime.createOpenCodeSdkClient({ | 293 | const client = openCodeRuntime.createOpenCodeSdkClient({ |
| 311 | baseUrl: server.url, | 294 | baseUrl: server.url, |
| 312 | directory: input.cwd, | 295 | directory: input.cwd, |
| 313 | ...(settings.serverUrl.length > 0 && settings.serverPassword | 296 | ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword |
| 314 | ? { serverPassword: settings.serverPassword } | 297 | ? { serverPassword: openCodeSettings.serverPassword } |
| 315 | : {}), | 298 | : {}), |
| 316 | }); | 299 | }); |
| 317 | const session = await client.session.create({ | 300 | const session = await client.session.create({ |
| ... | @@ -354,11 +337,11 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -354,11 +337,11 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 354 | }); | 337 | }); |
| 355 | 338 | ||
| 356 | const rawOutput = | 339 | const rawOutput = |
| 357 | settings.serverUrl.length > 0 | 340 | openCodeSettings.serverUrl.length > 0 |
| 358 | ? yield* runAgainstServer({ url: settings.serverUrl }) | 341 | ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) |
| 359 | : yield* Effect.acquireUseRelease( | 342 | : yield* Effect.acquireUseRelease( |
| 360 | acquireSharedServer({ | 343 | acquireSharedServer({ |
| 361 | binaryPath: settings.binaryPath, | 344 | binaryPath: openCodeSettings.binaryPath, |
| 362 | operation: input.operation, | 345 | operation: input.operation, |
| 363 | }), | 346 | }), |
| 364 | runAgainstServer, | 347 | runAgainstServer, |
| ... | @@ -383,13 +366,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -383,13 +366,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 383 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | 366 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( |
| 384 | "OpenCodeTextGeneration.generateCommitMessage", | 367 | "OpenCodeTextGeneration.generateCommitMessage", |
| 385 | )(function* (input) { | 368 | )(function* (input) { |
| 386 | if (input.modelSelection.provider !== "opencode") { | ||
| 387 | return yield* new TextGenerationError({ | ||
| 388 | operation: "generateCommitMessage", | ||
| 389 | detail: "Invalid model selection.", | ||
| 390 | }); | ||
| 391 | } | ||
| 392 | |||
| 393 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | 369 | const { prompt, outputSchema } = buildCommitMessagePrompt({ |
| 394 | branch: input.branch, | 370 | branch: input.branch, |
| 395 | stagedSummary: input.stagedSummary, | 371 | stagedSummary: input.stagedSummary, |
| ... | @@ -416,13 +392,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -416,13 +392,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 416 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | 392 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( |
| 417 | "OpenCodeTextGeneration.generatePrContent", | 393 | "OpenCodeTextGeneration.generatePrContent", |
| 418 | )(function* (input) { | 394 | )(function* (input) { |
| 419 | if (input.modelSelection.provider !== "opencode") { | ||
| 420 | return yield* new TextGenerationError({ | ||
| 421 | operation: "generatePrContent", | ||
| 422 | detail: "Invalid model selection.", | ||
| 423 | }); | ||
| 424 | } | ||
| 425 | |||
| 426 | const { prompt, outputSchema } = buildPrContentPrompt({ | 395 | const { prompt, outputSchema } = buildPrContentPrompt({ |
| 427 | baseBranch: input.baseBranch, | 396 | baseBranch: input.baseBranch, |
| 428 | headBranch: input.headBranch, | 397 | headBranch: input.headBranch, |
| ... | @@ -447,13 +416,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -447,13 +416,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 447 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | 416 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( |
| 448 | "OpenCodeTextGeneration.generateBranchName", | 417 | "OpenCodeTextGeneration.generateBranchName", |
| 449 | )(function* (input) { | 418 | )(function* (input) { |
| 450 | if (input.modelSelection.provider !== "opencode") { | ||
| 451 | return yield* new TextGenerationError({ | ||
| 452 | operation: "generateBranchName", | ||
| 453 | detail: "Invalid model selection.", | ||
| 454 | }); | ||
| 455 | } | ||
| 456 | |||
| 457 | const { prompt, outputSchema } = buildBranchNamePrompt({ | 419 | const { prompt, outputSchema } = buildBranchNamePrompt({ |
| 458 | message: input.message, | 420 | message: input.message, |
| 459 | attachments: input.attachments, | 421 | attachments: input.attachments, |
| ... | @@ -475,13 +437,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -475,13 +437,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 475 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | 437 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( |
| 476 | "OpenCodeTextGeneration.generateThreadTitle", | 438 | "OpenCodeTextGeneration.generateThreadTitle", |
| 477 | )(function* (input) { | 439 | )(function* (input) { |
| 478 | if (input.modelSelection.provider !== "opencode") { | ||
| 479 | return yield* new TextGenerationError({ | ||
| 480 | operation: "generateThreadTitle", | ||
| 481 | detail: "Invalid model selection.", | ||
| 482 | }); | ||
| 483 | } | ||
| 484 | |||
| 485 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | 440 | const { prompt, outputSchema } = buildThreadTitlePrompt({ |
| 486 | message: input.message, | 441 | message: input.message, |
| 487 | attachments: input.attachments, | 442 | attachments: input.attachments, |
| ... | @@ -507,5 +462,3 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -507,5 +462,3 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 507 | generateThreadTitle, | 462 | generateThreadTitle, |
| 508 | } satisfies TextGenerationShape; | 463 | } satisfies TextGenerationShape; |
| 509 | }); | 464 | }); |
| 510 | |||
| 511 | export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); |
apps/server/src/git/Layers/RoutingTextGeneration.ts deleted-104| ... | @@ -1,104 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * RoutingTextGeneration – Dispatches text generation requests to either the | ||
| 3 | * Codex CLI or Claude CLI implementation based on the provider in each | ||
| 4 | * request input. | ||
| 5 | * | ||
| 6 | * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to | ||
| 7 | * the Claude layer; for any other value (including the default `undefined`) it | ||
| 8 | * falls through to the Codex layer. | ||
| 9 | * | ||
| 10 | * @module RoutingTextGeneration | ||
| 11 | */ | ||
| 12 | import { Effect, Layer, Context } from "effect"; | ||
| 13 | |||
| 14 | import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 15 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | ||
| 16 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; | ||
| 17 | import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; | ||
| 18 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | ||
| 19 | |||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | // Internal service tags so both concrete layers can coexist. | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | class CodexTextGen extends Context.Service<CodexTextGen, TextGenerationShape>()( | ||
| 25 | "t3/git/Layers/RoutingTextGeneration/CodexTextGen", | ||
| 26 | ) {} | ||
| 27 | |||
| 28 | class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>()( | ||
| 29 | "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", | ||
| 30 | ) {} | ||
| 31 | |||
| 32 | class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()( | ||
| 33 | "t3/git/Layers/RoutingTextGeneration/CursorTextGen", | ||
| 34 | ) {} | ||
| 35 | |||
| 36 | class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()( | ||
| 37 | "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", | ||
| 38 | ) {} | ||
| 39 | |||
| 40 | // --------------------------------------------------------------------------- | ||
| 41 | // Routing implementation | ||
| 42 | // --------------------------------------------------------------------------- | ||
| 43 | |||
| 44 | const makeRoutingTextGeneration = Effect.gen(function* () { | ||
| 45 | const byProvider = { | ||
| 46 | codex: yield* CodexTextGen, | ||
| 47 | claudeAgent: yield* ClaudeTextGen, | ||
| 48 | cursor: yield* CursorTextGen, | ||
| 49 | opencode: yield* OpenCodeTextGen, | ||
| 50 | }; | ||
| 51 | |||
| 52 | return { | ||
| 53 | generateCommitMessage: (input) => | ||
| 54 | byProvider[input.modelSelection.provider].generateCommitMessage(input), | ||
| 55 | generatePrContent: (input) => | ||
| 56 | byProvider[input.modelSelection.provider].generatePrContent(input), | ||
| 57 | generateBranchName: (input) => | ||
| 58 | byProvider[input.modelSelection.provider].generateBranchName(input), | ||
| 59 | generateThreadTitle: (input) => | ||
| 60 | byProvider[input.modelSelection.provider].generateThreadTitle(input), | ||
| 61 | } satisfies TextGenerationShape; | ||
| 62 | }); | ||
| 63 | |||
| 64 | const InternalCodexLayer = Layer.effect( | ||
| 65 | CodexTextGen, | ||
| 66 | Effect.gen(function* () { | ||
| 67 | const svc = yield* TextGeneration; | ||
| 68 | return svc; | ||
| 69 | }), | ||
| 70 | ).pipe(Layer.provide(CodexTextGenerationLive)); | ||
| 71 | |||
| 72 | const InternalClaudeLayer = Layer.effect( | ||
| 73 | ClaudeTextGen, | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const svc = yield* TextGeneration; | ||
| 76 | return svc; | ||
| 77 | }), | ||
| 78 | ).pipe(Layer.provide(ClaudeTextGenerationLive)); | ||
| 79 | |||
| 80 | const InternalCursorLayer = Layer.effect( | ||
| 81 | CursorTextGen, | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const svc = yield* TextGeneration; | ||
| 84 | return svc; | ||
| 85 | }), | ||
| 86 | ).pipe(Layer.provide(CursorTextGenerationLive)); | ||
| 87 | |||
| 88 | const InternalOpenCodeLayer = Layer.effect( | ||
| 89 | OpenCodeTextGen, | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const svc = yield* TextGeneration; | ||
| 92 | return svc; | ||
| 93 | }), | ||
| 94 | ).pipe(Layer.provide(OpenCodeTextGenerationLive)); | ||
| 95 | |||
| 96 | export const RoutingTextGenerationLive = Layer.effect( | ||
| 97 | TextGeneration, | ||
| 98 | makeRoutingTextGeneration, | ||
| 99 | ).pipe( | ||
| 100 | Layer.provide(InternalCodexLayer), | ||
| 101 | Layer.provide(InternalClaudeLayer), | ||
| 102 | Layer.provide(InternalCursorLayer), | ||
| 103 | Layer.provide(InternalOpenCodeLayer), | ||
| 104 | ); | ||
apps/server/src/git/Layers/TextGenerationLive.test.ts created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | import { it } from "@effect/vitest"; | ||
| 2 | import { Effect, PubSub, Result, Stream } from "effect"; | ||
| 3 | import { describe, expect } from "vitest"; | ||
| 4 | |||
| 5 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 7 | |||
| 8 | import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; | ||
| 9 | import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts"; | ||
| 10 | import type { TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 11 | |||
| 12 | import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts"; | ||
| 13 | |||
| 14 | const makeStubTextGeneration = (overrides: Partial<TextGenerationShape>): TextGenerationShape => ({ | ||
| 15 | generateCommitMessage: () => | ||
| 16 | Effect.die("generateCommitMessage stub not configured for this test"), | ||
| 17 | generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), | ||
| 18 | generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), | ||
| 19 | generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), | ||
| 20 | ...overrides, | ||
| 21 | }); | ||
| 22 | |||
| 23 | const makeStubInstance = ( | ||
| 24 | instanceId: ProviderInstanceId, | ||
| 25 | textGeneration: TextGenerationShape, | ||
| 26 | ): ProviderInstance => | ||
| 27 | ({ | ||
| 28 | instanceId, | ||
| 29 | driverKind: instanceId as unknown as ProviderInstance["driverKind"], | ||
| 30 | continuationIdentity: { | ||
| 31 | driverKind: instanceId as unknown as ProviderInstance["driverKind"], | ||
| 32 | continuationKey: `${instanceId}:test`, | ||
| 33 | }, | ||
| 34 | displayName: undefined, | ||
| 35 | enabled: true, | ||
| 36 | snapshot: {} as ProviderInstance["snapshot"], | ||
| 37 | adapter: {} as ProviderInstance["adapter"], | ||
| 38 | textGeneration, | ||
| 39 | }) satisfies ProviderInstance; | ||
| 40 | |||
| 41 | const makeStubRegistry = ( | ||
| 42 | instances: ReadonlyArray<ProviderInstance>, | ||
| 43 | ): ProviderInstanceRegistryShape => { | ||
| 44 | const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); | ||
| 45 | return { | ||
| 46 | getInstance: (id) => Effect.succeed(byId.get(id)), | ||
| 47 | listInstances: Effect.succeed(instances), | ||
| 48 | listUnavailable: Effect.succeed([]), | ||
| 49 | streamChanges: Stream.empty, | ||
| 50 | // Tests never drive changes through this stub; acquire a throwaway | ||
| 51 | // subscription on an unused PubSub so the shape is satisfied. | ||
| 52 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 53 | PubSub.subscribe(pubsub), | ||
| 54 | ), | ||
| 55 | }; | ||
| 56 | }; | ||
| 57 | |||
| 58 | describe("makeTextGenerationFromRegistry", () => { | ||
| 59 | it.effect("delegates to the matching instance's textGeneration closure", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 62 | const personalCalls: string[] = []; | ||
| 63 | const personal = makeStubInstance( | ||
| 64 | personalId, | ||
| 65 | makeStubTextGeneration({ | ||
| 66 | generateBranchName: (input) => { | ||
| 67 | personalCalls.push(input.message); | ||
| 68 | return Effect.succeed({ branch: "personal-branch" }); | ||
| 69 | }, | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | |||
| 73 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 74 | const work = makeStubInstance( | ||
| 75 | workId, | ||
| 76 | makeStubTextGeneration({ | ||
| 77 | generateBranchName: () => Effect.succeed({ branch: "work-branch" }), | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); | ||
| 82 | |||
| 83 | const result = yield* tg.generateBranchName({ | ||
| 84 | cwd: process.cwd(), | ||
| 85 | message: "Refactor the routing layer", | ||
| 86 | modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), | ||
| 87 | }); | ||
| 88 | |||
| 89 | expect(result.branch).toBe("personal-branch"); | ||
| 90 | expect(personalCalls).toEqual(["Refactor the routing layer"]); | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | it.effect("fails with TextGenerationError when the instance is unknown", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); | ||
| 97 | |||
| 98 | const result = yield* tg | ||
| 99 | .generateBranchName({ | ||
| 100 | cwd: process.cwd(), | ||
| 101 | message: "anything", | ||
| 102 | modelSelection: createModelSelection( | ||
| 103 | ProviderInstanceId.make("missing_instance"), | ||
| 104 | "gpt-5", | ||
| 105 | ), | ||
| 106 | }) | ||
| 107 | .pipe(Effect.result); | ||
| 108 | |||
| 109 | expect(Result.isFailure(result)).toBe(true); | ||
| 110 | if (Result.isFailure(result)) { | ||
| 111 | expect(result.failure._tag).toBe("TextGenerationError"); | ||
| 112 | expect(result.failure.operation).toBe("generateBranchName"); | ||
| 113 | expect(result.failure.detail).toContain("missing_instance"); | ||
| 114 | } | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | }); | ||
apps/server/src/git/Layers/TextGenerationLive.ts created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | /** | ||
| 2 | * TextGenerationLive — registry-backed implementation of the `TextGeneration` | ||
| 3 | * service tag. | ||
| 4 | * | ||
| 5 | * The `TextGeneration` tag is kept as a thin facade over | ||
| 6 | * `ProviderInstanceRegistry`. Every op pulls `modelSelection.instanceId`, | ||
| 7 | * looks up the matching `ProviderInstance`, and delegates to that instance's | ||
| 8 | * own `textGeneration` closure (built by its driver's `create()`). | ||
| 9 | * | ||
| 10 | * There is deliberately no per-driver dispatch here — the registry already | ||
| 11 | * knows which driver backs each instance, and each `ProviderInstance` | ||
| 12 | * carries the fully-bound `TextGenerationShape` produced by its driver. | ||
| 13 | * That means: | ||
| 14 | * | ||
| 15 | * - Multiple instances of the same driver (e.g. `codex_personal`, | ||
| 16 | * `codex_work`) each get their own text-generation closure bound to | ||
| 17 | * their own settings — the routing is by instance, not by driver. | ||
| 18 | * - Unknown or disabled instances surface a `TextGenerationError` with | ||
| 19 | * the missing `instanceId`, instead of silently falling back to a | ||
| 20 | * default. | ||
| 21 | * | ||
| 22 | * This replaces the old `RoutingTextGenerationLive`, which tried to route | ||
| 23 | * by driver-kind and misused `modelSelection.instanceId` as a driver-id | ||
| 24 | * literal. | ||
| 25 | * | ||
| 26 | * @module git/Layers/TextGenerationLive | ||
| 27 | */ | ||
| 28 | import { Effect, Layer } from "effect"; | ||
| 29 | |||
| 30 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 31 | import type { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 32 | |||
| 33 | import { | ||
| 34 | ProviderInstanceRegistry, | ||
| 35 | type ProviderInstanceRegistryShape, | ||
| 36 | } from "../../provider/Services/ProviderInstanceRegistry.ts"; | ||
| 37 | import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; | ||
| 38 | import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 39 | |||
| 40 | type TextGenerationOp = | ||
| 41 | | "generateCommitMessage" | ||
| 42 | | "generatePrContent" | ||
| 43 | | "generateBranchName" | ||
| 44 | | "generateThreadTitle"; | ||
| 45 | |||
| 46 | const resolveInstance = ( | ||
| 47 | registry: ProviderInstanceRegistryShape, | ||
| 48 | operation: TextGenerationOp, | ||
| 49 | instanceId: ProviderInstanceId, | ||
| 50 | ): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> => | ||
| 51 | registry.getInstance(instanceId).pipe( | ||
| 52 | Effect.flatMap((instance) => | ||
| 53 | instance | ||
| 54 | ? Effect.succeed(instance.textGeneration) | ||
| 55 | : Effect.fail( | ||
| 56 | new TextGenerationError({ | ||
| 57 | operation, | ||
| 58 | detail: `No provider instance registered for id '${instanceId}'.`, | ||
| 59 | }), | ||
| 60 | ), | ||
| 61 | ), | ||
| 62 | ); | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Build a `TextGenerationShape` that routes every call through the | ||
| 66 | * registry. Exposed separately from the Layer so tests can construct it | ||
| 67 | * against a stub registry without layering gymnastics. | ||
| 68 | */ | ||
| 69 | export const makeTextGenerationFromRegistry = ( | ||
| 70 | registry: ProviderInstanceRegistryShape, | ||
| 71 | ): TextGenerationShape => ({ | ||
| 72 | generateCommitMessage: (input) => | ||
| 73 | resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( | ||
| 74 | Effect.flatMap((tg) => tg.generateCommitMessage(input)), | ||
| 75 | ), | ||
| 76 | generatePrContent: (input) => | ||
| 77 | resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( | ||
| 78 | Effect.flatMap((tg) => tg.generatePrContent(input)), | ||
| 79 | ), | ||
| 80 | generateBranchName: (input) => | ||
| 81 | resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( | ||
| 82 | Effect.flatMap((tg) => tg.generateBranchName(input)), | ||
| 83 | ), | ||
| 84 | generateThreadTitle: (input) => | ||
| 85 | resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( | ||
| 86 | Effect.flatMap((tg) => tg.generateThreadTitle(input)), | ||
| 87 | ), | ||
| 88 | }); | ||
| 89 | |||
| 90 | /** | ||
| 91 | * `TextGeneration` Layer wired to the `ProviderInstanceRegistry`. The rest | ||
| 92 | * of the server keeps using `yield* TextGeneration` — only the underlying | ||
| 93 | * wiring changed from kind-based routing to instance-based routing. | ||
| 94 | */ | ||
| 95 | export const TextGenerationLive = Layer.effect( | ||
| 96 | TextGeneration, | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const registry = yield* ProviderInstanceRegistry; | ||
| 99 | return makeTextGenerationFromRegistry(registry); | ||
| 100 | }), | ||
| 101 | ); | ||
apps/server/src/observability/Metrics.test.ts+6-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | import { Effect, Metric } from "effect"; | 3 | import { Effect, Metric } from "effect"; |
| 3 | 4 | ||
| 4 | import { withMetrics } from "./Metrics.ts"; | 5 | import { withMetrics } from "./Metrics.ts"; |
| ... | @@ -75,10 +76,11 @@ describe("withMetrics", () => { | ... | @@ -75,10 +76,11 @@ describe("withMetrics", () => { |
| 75 | Effect.gen(function* () { | 76 | Effect.gen(function* () { |
| 76 | const counter = Metric.counter("with_metrics_lazy_total"); | 77 | const counter = Metric.counter("with_metrics_lazy_total"); |
| 77 | const timer = Metric.timer("with_metrics_lazy_duration"); | 78 | const timer = Metric.timer("with_metrics_lazy_duration"); |
| 78 | let provider = "unknown"; | 79 | let provider = ProviderDriverKind.make("unknown"); |
| 80 | const lazyInittedProvider = ProviderDriverKind.make("codex"); | ||
| 79 | 81 | ||
| 80 | yield* Effect.sync(() => { | 82 | yield* Effect.sync(() => { |
| 81 | provider = "codex"; | 83 | provider = lazyInittedProvider; |
| 82 | }).pipe( | 84 | }).pipe( |
| 83 | withMetrics({ | 85 | withMetrics({ |
| 84 | counter, | 86 | counter, |
| ... | @@ -93,7 +95,7 @@ describe("withMetrics", () => { | ... | @@ -93,7 +95,7 @@ describe("withMetrics", () => { |
| 93 | const snapshots = yield* Metric.snapshot; | 95 | const snapshots = yield* Metric.snapshot; |
| 94 | assert.equal( | 96 | assert.equal( |
| 95 | hasMetricSnapshot(snapshots, "with_metrics_lazy_total", { | 97 | hasMetricSnapshot(snapshots, "with_metrics_lazy_total", { |
| 96 | provider: "codex", | 98 | provider: lazyInittedProvider, |
| 97 | operation: "lazy", | 99 | operation: "lazy", |
| 98 | outcome: "success", | 100 | outcome: "success", |
| 99 | }), | 101 | }), |
| ... | @@ -101,7 +103,7 @@ describe("withMetrics", () => { | ... | @@ -101,7 +103,7 @@ describe("withMetrics", () => { |
| 101 | ); | 103 | ); |
| 102 | assert.equal( | 104 | assert.equal( |
| 103 | hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", { | 105 | hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", { |
| 104 | provider: "codex", | 106 | provider: lazyInittedProvider, |
| 105 | operation: "lazy", | 107 | operation: "lazy", |
| 106 | }), | 108 | }), |
| 107 | true, | 109 | true, |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+38-22| ... | @@ -3,7 +3,12 @@ import os from "node:os"; | ... | @@ -3,7 +3,12 @@ import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | import { execFileSync } from "node:child_process"; | 4 | import { execFileSync } from "node:child_process"; |
| 5 | 5 | ||
| 6 | import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | 6 | import { |
| 7 | ProviderDriverKind, | ||
| 8 | ProviderRuntimeEvent, | ||
| 9 | ProviderSession, | ||
| 10 | ProviderInstanceId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 7 | import { | 12 | import { |
| 8 | CommandId, | 13 | CommandId, |
| 9 | DEFAULT_PROVIDER_INTERACTION_MODE, | 14 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -50,7 +55,7 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); | ... | @@ -50,7 +55,7 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); |
| 50 | type LegacyProviderRuntimeEvent = { | 55 | type LegacyProviderRuntimeEvent = { |
| 51 | readonly type: string; | 56 | readonly type: string; |
| 52 | readonly eventId: EventId; | 57 | readonly eventId: EventId; |
| 53 | readonly provider: ProviderKind; | 58 | readonly provider: ProviderDriverKind; |
| 54 | readonly createdAt: string; | 59 | readonly createdAt: string; |
| 55 | readonly threadId: ThreadId; | 60 | readonly threadId: ThreadId; |
| 56 | readonly turnId?: string | undefined; | 61 | readonly turnId?: string | undefined; |
| ... | @@ -64,7 +69,7 @@ function createProviderServiceHarness( | ... | @@ -64,7 +69,7 @@ function createProviderServiceHarness( |
| 64 | cwd: string, | 69 | cwd: string, |
| 65 | hasSession = true, | 70 | hasSession = true, |
| 66 | sessionCwd = cwd, | 71 | sessionCwd = cwd, |
| 67 | providerName: ProviderSession["provider"] = "codex", | 72 | providerName: ProviderSession["provider"] = ProviderDriverKind.make("codex"), |
| 68 | ) { | 73 | ) { |
| 69 | const now = new Date().toISOString(); | 74 | const now = new Date().toISOString(); |
| 70 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 75 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| ... | @@ -97,6 +102,17 @@ function createProviderServiceHarness( | ... | @@ -97,6 +102,17 @@ function createProviderServiceHarness( |
| 97 | stopSession: () => unsupported(), | 102 | stopSession: () => unsupported(), |
| 98 | listSessions, | 103 | listSessions, |
| 99 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | 104 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| 105 | getInstanceInfo: (instanceId) => | ||
| 106 | Effect.succeed({ | ||
| 107 | instanceId, | ||
| 108 | driverKind: ProviderDriverKind.make(providerName), | ||
| 109 | displayName: undefined, | ||
| 110 | enabled: true, | ||
| 111 | continuationIdentity: { | ||
| 112 | driverKind: ProviderDriverKind.make(providerName), | ||
| 113 | continuationKey: `${providerName}:instance:${instanceId}`, | ||
| 114 | }, | ||
| 115 | }), | ||
| 100 | rollbackConversation, | 116 | rollbackConversation, |
| 101 | get streamEvents() { | 117 | get streamEvents() { |
| 102 | return Stream.fromPubSub(runtimeEventPubSub); | 118 | return Stream.fromPubSub(runtimeEventPubSub); |
| ... | @@ -243,7 +259,7 @@ describe("CheckpointReactor", () => { | ... | @@ -243,7 +259,7 @@ describe("CheckpointReactor", () => { |
| 243 | readonly projectWorkspaceRoot?: string; | 259 | readonly projectWorkspaceRoot?: string; |
| 244 | readonly threadWorktreePath?: string | null; | 260 | readonly threadWorktreePath?: string | null; |
| 245 | readonly providerSessionCwd?: string; | 261 | readonly providerSessionCwd?: string; |
| 246 | readonly providerName?: ProviderKind; | 262 | readonly providerName?: ProviderDriverKind; |
| 247 | readonly gitStatusRefreshCalls?: Array<string>; | 263 | readonly gitStatusRefreshCalls?: Array<string>; |
| 248 | }) { | 264 | }) { |
| 249 | const cwd = createGitRepository(); | 265 | const cwd = createGitRepository(); |
| ... | @@ -252,7 +268,7 @@ describe("CheckpointReactor", () => { | ... | @@ -252,7 +268,7 @@ describe("CheckpointReactor", () => { |
| 252 | cwd, | 268 | cwd, |
| 253 | options?.hasSession ?? true, | 269 | options?.hasSession ?? true, |
| 254 | options?.providerSessionCwd ?? cwd, | 270 | options?.providerSessionCwd ?? cwd, |
| 255 | options?.providerName ?? "codex", | 271 | options?.providerName ?? ProviderDriverKind.make("codex"), |
| 256 | ); | 272 | ); |
| 257 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 273 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 258 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | 274 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| ... | @@ -315,7 +331,7 @@ describe("CheckpointReactor", () => { | ... | @@ -315,7 +331,7 @@ describe("CheckpointReactor", () => { |
| 315 | title: "Test Project", | 331 | title: "Test Project", |
| 316 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, | 332 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, |
| 317 | defaultModelSelection: { | 333 | defaultModelSelection: { |
| 318 | provider: "codex", | 334 | instanceId: ProviderInstanceId.make("codex"), |
| 319 | model: "gpt-5-codex", | 335 | model: "gpt-5-codex", |
| 320 | }, | 336 | }, |
| 321 | createdAt, | 337 | createdAt, |
| ... | @@ -329,7 +345,7 @@ describe("CheckpointReactor", () => { | ... | @@ -329,7 +345,7 @@ describe("CheckpointReactor", () => { |
| 329 | projectId: asProjectId("project-1"), | 345 | projectId: asProjectId("project-1"), |
| 330 | title: "Thread", | 346 | title: "Thread", |
| 331 | modelSelection: { | 347 | modelSelection: { |
| 332 | provider: "codex", | 348 | instanceId: ProviderInstanceId.make("codex"), |
| 333 | model: "gpt-5-codex", | 349 | model: "gpt-5-codex", |
| 334 | }, | 350 | }, |
| 335 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 351 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -396,7 +412,7 @@ describe("CheckpointReactor", () => { | ... | @@ -396,7 +412,7 @@ describe("CheckpointReactor", () => { |
| 396 | harness.provider.emit({ | 412 | harness.provider.emit({ |
| 397 | type: "turn.started", | 413 | type: "turn.started", |
| 398 | eventId: EventId.make("evt-turn-started-1"), | 414 | eventId: EventId.make("evt-turn-started-1"), |
| 399 | provider: "codex", | 415 | provider: ProviderDriverKind.make("codex"), |
| 400 | 416 | ||
| 401 | createdAt: new Date().toISOString(), | 417 | createdAt: new Date().toISOString(), |
| 402 | threadId: ThreadId.make("thread-1"), | 418 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -411,7 +427,7 @@ describe("CheckpointReactor", () => { | ... | @@ -411,7 +427,7 @@ describe("CheckpointReactor", () => { |
| 411 | harness.provider.emit({ | 427 | harness.provider.emit({ |
| 412 | type: "turn.completed", | 428 | type: "turn.completed", |
| 413 | eventId: EventId.make("evt-turn-completed-1"), | 429 | eventId: EventId.make("evt-turn-completed-1"), |
| 414 | provider: "codex", | 430 | provider: ProviderDriverKind.make("codex"), |
| 415 | 431 | ||
| 416 | createdAt: new Date().toISOString(), | 432 | createdAt: new Date().toISOString(), |
| 417 | threadId: ThreadId.make("thread-1"), | 433 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -457,7 +473,7 @@ describe("CheckpointReactor", () => { | ... | @@ -457,7 +473,7 @@ describe("CheckpointReactor", () => { |
| 457 | harness.provider.emit({ | 473 | harness.provider.emit({ |
| 458 | type: "turn.completed", | 474 | type: "turn.completed", |
| 459 | eventId: EventId.make("evt-turn-completed-refresh-local-status"), | 475 | eventId: EventId.make("evt-turn-completed-refresh-local-status"), |
| 460 | provider: "codex", | 476 | provider: ProviderDriverKind.make("codex"), |
| 461 | createdAt: new Date().toISOString(), | 477 | createdAt: new Date().toISOString(), |
| 462 | threadId: ThreadId.make("thread-1"), | 478 | threadId: ThreadId.make("thread-1"), |
| 463 | turnId: asTurnId("turn-refresh-local-status"), | 479 | turnId: asTurnId("turn-refresh-local-status"), |
| ... | @@ -494,7 +510,7 @@ describe("CheckpointReactor", () => { | ... | @@ -494,7 +510,7 @@ describe("CheckpointReactor", () => { |
| 494 | harness.provider.emit({ | 510 | harness.provider.emit({ |
| 495 | type: "turn.started", | 511 | type: "turn.started", |
| 496 | eventId: EventId.make("evt-turn-started-main"), | 512 | eventId: EventId.make("evt-turn-started-main"), |
| 497 | provider: "codex", | 513 | provider: ProviderDriverKind.make("codex"), |
| 498 | 514 | ||
| 499 | createdAt: new Date().toISOString(), | 515 | createdAt: new Date().toISOString(), |
| 500 | threadId: ThreadId.make("thread-1"), | 516 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -510,7 +526,7 @@ describe("CheckpointReactor", () => { | ... | @@ -510,7 +526,7 @@ describe("CheckpointReactor", () => { |
| 510 | harness.provider.emit({ | 526 | harness.provider.emit({ |
| 511 | type: "turn.completed", | 527 | type: "turn.completed", |
| 512 | eventId: EventId.make("evt-turn-completed-aux"), | 528 | eventId: EventId.make("evt-turn-completed-aux"), |
| 513 | provider: "codex", | 529 | provider: ProviderDriverKind.make("codex"), |
| 514 | 530 | ||
| 515 | createdAt: new Date().toISOString(), | 531 | createdAt: new Date().toISOString(), |
| 516 | threadId: ThreadId.make("thread-1"), | 532 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -526,7 +542,7 @@ describe("CheckpointReactor", () => { | ... | @@ -526,7 +542,7 @@ describe("CheckpointReactor", () => { |
| 526 | harness.provider.emit({ | 542 | harness.provider.emit({ |
| 527 | type: "turn.completed", | 543 | type: "turn.completed", |
| 528 | eventId: EventId.make("evt-turn-completed-main"), | 544 | eventId: EventId.make("evt-turn-completed-main"), |
| 529 | provider: "codex", | 545 | provider: ProviderDriverKind.make("codex"), |
| 530 | 546 | ||
| 531 | createdAt: new Date().toISOString(), | 547 | createdAt: new Date().toISOString(), |
| 532 | threadId: ThreadId.make("thread-1"), | 548 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -544,7 +560,7 @@ describe("CheckpointReactor", () => { | ... | @@ -544,7 +560,7 @@ describe("CheckpointReactor", () => { |
| 544 | it("captures pre-turn and completion checkpoints for claude runtime events", async () => { | 560 | it("captures pre-turn and completion checkpoints for claude runtime events", async () => { |
| 545 | const harness = await createHarness({ | 561 | const harness = await createHarness({ |
| 546 | seedFilesystemCheckpoints: false, | 562 | seedFilesystemCheckpoints: false, |
| 547 | providerName: "claudeAgent", | 563 | providerName: ProviderDriverKind.make("claudeAgent"), |
| 548 | }); | 564 | }); |
| 549 | const createdAt = new Date().toISOString(); | 565 | const createdAt = new Date().toISOString(); |
| 550 | 566 | ||
| ... | @@ -569,7 +585,7 @@ describe("CheckpointReactor", () => { | ... | @@ -569,7 +585,7 @@ describe("CheckpointReactor", () => { |
| 569 | harness.provider.emit({ | 585 | harness.provider.emit({ |
| 570 | type: "turn.started", | 586 | type: "turn.started", |
| 571 | eventId: EventId.make("evt-turn-started-claude-1"), | 587 | eventId: EventId.make("evt-turn-started-claude-1"), |
| 572 | provider: "claudeAgent", | 588 | provider: ProviderDriverKind.make("claudeAgent"), |
| 573 | createdAt: new Date().toISOString(), | 589 | createdAt: new Date().toISOString(), |
| 574 | threadId: ThreadId.make("thread-1"), | 590 | threadId: ThreadId.make("thread-1"), |
| 575 | turnId: asTurnId("turn-claude-1"), | 591 | turnId: asTurnId("turn-claude-1"), |
| ... | @@ -583,7 +599,7 @@ describe("CheckpointReactor", () => { | ... | @@ -583,7 +599,7 @@ describe("CheckpointReactor", () => { |
| 583 | harness.provider.emit({ | 599 | harness.provider.emit({ |
| 584 | type: "turn.completed", | 600 | type: "turn.completed", |
| 585 | eventId: EventId.make("evt-turn-completed-claude-1"), | 601 | eventId: EventId.make("evt-turn-completed-claude-1"), |
| 586 | provider: "claudeAgent", | 602 | provider: ProviderDriverKind.make("claudeAgent"), |
| 587 | createdAt: new Date().toISOString(), | 603 | createdAt: new Date().toISOString(), |
| 588 | threadId: ThreadId.make("thread-1"), | 604 | threadId: ThreadId.make("thread-1"), |
| 589 | turnId: asTurnId("turn-claude-1"), | 605 | turnId: asTurnId("turn-claude-1"), |
| ... | @@ -627,7 +643,7 @@ describe("CheckpointReactor", () => { | ... | @@ -627,7 +643,7 @@ describe("CheckpointReactor", () => { |
| 627 | harness.provider.emit({ | 643 | harness.provider.emit({ |
| 628 | type: "turn.completed", | 644 | type: "turn.completed", |
| 629 | eventId: EventId.make("evt-turn-completed-missing-baseline"), | 645 | eventId: EventId.make("evt-turn-completed-missing-baseline"), |
| 630 | provider: "codex", | 646 | provider: ProviderDriverKind.make("codex"), |
| 631 | 647 | ||
| 632 | createdAt: new Date().toISOString(), | 648 | createdAt: new Date().toISOString(), |
| 633 | threadId: ThreadId.make("thread-1"), | 649 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -716,7 +732,7 @@ describe("CheckpointReactor", () => { | ... | @@ -716,7 +732,7 @@ describe("CheckpointReactor", () => { |
| 716 | harness.provider.emit({ | 732 | harness.provider.emit({ |
| 717 | type: "turn.completed", | 733 | type: "turn.completed", |
| 718 | eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), | 734 | eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), |
| 719 | provider: "codex", | 735 | provider: ProviderDriverKind.make("codex"), |
| 720 | 736 | ||
| 721 | createdAt: new Date().toISOString(), | 737 | createdAt: new Date().toISOString(), |
| 722 | threadId: ThreadId.make("thread-1"), | 738 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -762,7 +778,7 @@ describe("CheckpointReactor", () => { | ... | @@ -762,7 +778,7 @@ describe("CheckpointReactor", () => { |
| 762 | harness.provider.emit({ | 778 | harness.provider.emit({ |
| 763 | type: "checkpoint.captured", | 779 | type: "checkpoint.captured", |
| 764 | eventId: EventId.make("evt-checkpoint-captured-3"), | 780 | eventId: EventId.make("evt-checkpoint-captured-3"), |
| 765 | provider: "codex", | 781 | provider: ProviderDriverKind.make("codex"), |
| 766 | 782 | ||
| 767 | createdAt: new Date().toISOString(), | 783 | createdAt: new Date().toISOString(), |
| 768 | threadId: ThreadId.make("thread-1"), | 784 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -812,7 +828,7 @@ describe("CheckpointReactor", () => { | ... | @@ -812,7 +828,7 @@ describe("CheckpointReactor", () => { |
| 812 | harness.provider.emit({ | 828 | harness.provider.emit({ |
| 813 | type: "turn.completed", | 829 | type: "turn.completed", |
| 814 | eventId: EventId.make("evt-runtime-capture-failure"), | 830 | eventId: EventId.make("evt-runtime-capture-failure"), |
| 815 | provider: "codex", | 831 | provider: ProviderDriverKind.make("codex"), |
| 816 | 832 | ||
| 817 | createdAt: new Date().toISOString(), | 833 | createdAt: new Date().toISOString(), |
| 818 | threadId: ThreadId.make("thread-1"), | 834 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -823,7 +839,7 @@ describe("CheckpointReactor", () => { | ... | @@ -823,7 +839,7 @@ describe("CheckpointReactor", () => { |
| 823 | harness.provider.emit({ | 839 | harness.provider.emit({ |
| 824 | type: "turn.started", | 840 | type: "turn.started", |
| 825 | eventId: EventId.make("evt-turn-started-after-runtime-failure"), | 841 | eventId: EventId.make("evt-turn-started-after-runtime-failure"), |
| 826 | provider: "codex", | 842 | provider: ProviderDriverKind.make("codex"), |
| 827 | 843 | ||
| 828 | createdAt: new Date().toISOString(), | 844 | createdAt: new Date().toISOString(), |
| 829 | threadId: ThreadId.make("thread-1"), | 845 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -918,7 +934,7 @@ describe("CheckpointReactor", () => { | ... | @@ -918,7 +934,7 @@ describe("CheckpointReactor", () => { |
| 918 | }); | 934 | }); |
| 919 | 935 | ||
| 920 | it("executes provider revert and emits thread.reverted for claude sessions", async () => { | 936 | it("executes provider revert and emits thread.reverted for claude sessions", async () => { |
| 921 | const harness = await createHarness({ providerName: "claudeAgent" }); | 937 | const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); |
| 922 | const createdAt = new Date().toISOString(); | 938 | const createdAt = new Date().toISOString(); |
| 923 | 939 | ||
| 924 | await Effect.runPromise( | 940 | await Effect.runPromise( |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+26-25| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | TurnId, | 8 | TurnId, |
| 9 | type OrchestrationEvent, | 9 | type OrchestrationEvent, |
| 10 | ProviderInstanceId, | ||
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect"; | 12 | import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect"; |
| 12 | import { describe, expect, it } from "vitest"; | 13 | import { describe, expect, it } from "vitest"; |
| ... | @@ -104,7 +105,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -104,7 +105,7 @@ describe("OrchestrationEngine", () => { |
| 104 | title: "Bootstrap Project", | 105 | title: "Bootstrap Project", |
| 105 | workspaceRoot: "/tmp/project-bootstrap", | 106 | workspaceRoot: "/tmp/project-bootstrap", |
| 106 | defaultModelSelection: { | 107 | defaultModelSelection: { |
| 107 | provider: "codex" as const, | 108 | instanceId: ProviderInstanceId.make("codex"), |
| 108 | model: "gpt-5-codex", | 109 | model: "gpt-5-codex", |
| 109 | }, | 110 | }, |
| 110 | scripts: [], | 111 | scripts: [], |
| ... | @@ -119,7 +120,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -119,7 +120,7 @@ describe("OrchestrationEngine", () => { |
| 119 | projectId: asProjectId("project-bootstrap"), | 120 | projectId: asProjectId("project-bootstrap"), |
| 120 | title: "Bootstrap Thread", | 121 | title: "Bootstrap Thread", |
| 121 | modelSelection: { | 122 | modelSelection: { |
| 122 | provider: "codex" as const, | 123 | instanceId: ProviderInstanceId.make("codex"), |
| 123 | model: "gpt-5-codex", | 124 | model: "gpt-5-codex", |
| 124 | }, | 125 | }, |
| 125 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 126 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -198,7 +199,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -198,7 +199,7 @@ describe("OrchestrationEngine", () => { |
| 198 | title: "Project 1", | 199 | title: "Project 1", |
| 199 | workspaceRoot: "/tmp/project-1", | 200 | workspaceRoot: "/tmp/project-1", |
| 200 | defaultModelSelection: { | 201 | defaultModelSelection: { |
| 201 | provider: "codex", | 202 | instanceId: ProviderInstanceId.make("codex"), |
| 202 | model: "gpt-5-codex", | 203 | model: "gpt-5-codex", |
| 203 | }, | 204 | }, |
| 204 | createdAt, | 205 | createdAt, |
| ... | @@ -212,7 +213,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -212,7 +213,7 @@ describe("OrchestrationEngine", () => { |
| 212 | projectId: asProjectId("project-1"), | 213 | projectId: asProjectId("project-1"), |
| 213 | title: "Thread", | 214 | title: "Thread", |
| 214 | modelSelection: { | 215 | modelSelection: { |
| 215 | provider: "codex", | 216 | instanceId: ProviderInstanceId.make("codex"), |
| 216 | model: "gpt-5-codex", | 217 | model: "gpt-5-codex", |
| 217 | }, | 218 | }, |
| 218 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 219 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -258,7 +259,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -258,7 +259,7 @@ describe("OrchestrationEngine", () => { |
| 258 | title: "Project Archive", | 259 | title: "Project Archive", |
| 259 | workspaceRoot: "/tmp/project-archive", | 260 | workspaceRoot: "/tmp/project-archive", |
| 260 | defaultModelSelection: { | 261 | defaultModelSelection: { |
| 261 | provider: "codex", | 262 | instanceId: ProviderInstanceId.make("codex"), |
| 262 | model: "gpt-5-codex", | 263 | model: "gpt-5-codex", |
| 263 | }, | 264 | }, |
| 264 | createdAt, | 265 | createdAt, |
| ... | @@ -272,7 +273,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -272,7 +273,7 @@ describe("OrchestrationEngine", () => { |
| 272 | projectId: asProjectId("project-archive"), | 273 | projectId: asProjectId("project-archive"), |
| 273 | title: "Archive me", | 274 | title: "Archive me", |
| 274 | modelSelection: { | 275 | modelSelection: { |
| 275 | provider: "codex", | 276 | instanceId: ProviderInstanceId.make("codex"), |
| 276 | model: "gpt-5-codex", | 277 | model: "gpt-5-codex", |
| 277 | }, | 278 | }, |
| 278 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 279 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -325,7 +326,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -325,7 +326,7 @@ describe("OrchestrationEngine", () => { |
| 325 | title: "Replay Project", | 326 | title: "Replay Project", |
| 326 | workspaceRoot: "/tmp/project-replay", | 327 | workspaceRoot: "/tmp/project-replay", |
| 327 | defaultModelSelection: { | 328 | defaultModelSelection: { |
| 328 | provider: "codex", | 329 | instanceId: ProviderInstanceId.make("codex"), |
| 329 | model: "gpt-5-codex", | 330 | model: "gpt-5-codex", |
| 330 | }, | 331 | }, |
| 331 | createdAt, | 332 | createdAt, |
| ... | @@ -339,7 +340,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -339,7 +340,7 @@ describe("OrchestrationEngine", () => { |
| 339 | projectId: asProjectId("project-replay"), | 340 | projectId: asProjectId("project-replay"), |
| 340 | title: "replay", | 341 | title: "replay", |
| 341 | modelSelection: { | 342 | modelSelection: { |
| 342 | provider: "codex", | 343 | instanceId: ProviderInstanceId.make("codex"), |
| 343 | model: "gpt-5-codex", | 344 | model: "gpt-5-codex", |
| 344 | }, | 345 | }, |
| 345 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 346 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -383,7 +384,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -383,7 +384,7 @@ describe("OrchestrationEngine", () => { |
| 383 | title: "Stream Project", | 384 | title: "Stream Project", |
| 384 | workspaceRoot: "/tmp/project-stream", | 385 | workspaceRoot: "/tmp/project-stream", |
| 385 | defaultModelSelection: { | 386 | defaultModelSelection: { |
| 386 | provider: "codex", | 387 | instanceId: ProviderInstanceId.make("codex"), |
| 387 | model: "gpt-5-codex", | 388 | model: "gpt-5-codex", |
| 388 | }, | 389 | }, |
| 389 | createdAt, | 390 | createdAt, |
| ... | @@ -407,7 +408,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -407,7 +408,7 @@ describe("OrchestrationEngine", () => { |
| 407 | projectId: asProjectId("project-stream"), | 408 | projectId: asProjectId("project-stream"), |
| 408 | title: "domain-stream", | 409 | title: "domain-stream", |
| 409 | modelSelection: { | 410 | modelSelection: { |
| 410 | provider: "codex", | 411 | instanceId: ProviderInstanceId.make("codex"), |
| 411 | model: "gpt-5-codex", | 412 | model: "gpt-5-codex", |
| 412 | }, | 413 | }, |
| 413 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 414 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -444,7 +445,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -444,7 +445,7 @@ describe("OrchestrationEngine", () => { |
| 444 | title: "Ack Project", | 445 | title: "Ack Project", |
| 445 | workspaceRoot: "/tmp/project-ack", | 446 | workspaceRoot: "/tmp/project-ack", |
| 446 | defaultModelSelection: { | 447 | defaultModelSelection: { |
| 447 | provider: "codex", | 448 | instanceId: ProviderInstanceId.make("codex"), |
| 448 | model: "gpt-5-codex", | 449 | model: "gpt-5-codex", |
| 449 | }, | 450 | }, |
| 450 | createdAt, | 451 | createdAt, |
| ... | @@ -459,7 +460,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -459,7 +460,7 @@ describe("OrchestrationEngine", () => { |
| 459 | projectId: asProjectId("project-ack"), | 460 | projectId: asProjectId("project-ack"), |
| 460 | title: "Ack Thread", | 461 | title: "Ack Thread", |
| 461 | modelSelection: { | 462 | modelSelection: { |
| 462 | provider: "codex", | 463 | instanceId: ProviderInstanceId.make("codex"), |
| 463 | model: "gpt-5-codex", | 464 | model: "gpt-5-codex", |
| 464 | }, | 465 | }, |
| 465 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 466 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -496,7 +497,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -496,7 +497,7 @@ describe("OrchestrationEngine", () => { |
| 496 | projectId: asProjectId("project-missing"), | 497 | projectId: asProjectId("project-missing"), |
| 497 | title: "Missing Project Thread", | 498 | title: "Missing Project Thread", |
| 498 | modelSelection: { | 499 | modelSelection: { |
| 499 | provider: "codex", | 500 | instanceId: ProviderInstanceId.make("codex"), |
| 500 | model: "gpt-5-codex", | 501 | model: "gpt-5-codex", |
| 501 | }, | 502 | }, |
| 502 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 503 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -533,7 +534,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -533,7 +534,7 @@ describe("OrchestrationEngine", () => { |
| 533 | title: "Turn Diff Project", | 534 | title: "Turn Diff Project", |
| 534 | workspaceRoot: "/tmp/project-turn-diff", | 535 | workspaceRoot: "/tmp/project-turn-diff", |
| 535 | defaultModelSelection: { | 536 | defaultModelSelection: { |
| 536 | provider: "codex", | 537 | instanceId: ProviderInstanceId.make("codex"), |
| 537 | model: "gpt-5-codex", | 538 | model: "gpt-5-codex", |
| 538 | }, | 539 | }, |
| 539 | createdAt, | 540 | createdAt, |
| ... | @@ -547,7 +548,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -547,7 +548,7 @@ describe("OrchestrationEngine", () => { |
| 547 | projectId: asProjectId("project-turn-diff"), | 548 | projectId: asProjectId("project-turn-diff"), |
| 548 | title: "Turn diff thread", | 549 | title: "Turn diff thread", |
| 549 | modelSelection: { | 550 | modelSelection: { |
| 550 | provider: "codex", | 551 | instanceId: ProviderInstanceId.make("codex"), |
| 551 | model: "gpt-5-codex", | 552 | model: "gpt-5-codex", |
| 552 | }, | 553 | }, |
| 553 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 554 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -652,7 +653,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -652,7 +653,7 @@ describe("OrchestrationEngine", () => { |
| 652 | title: "Flaky Project", | 653 | title: "Flaky Project", |
| 653 | workspaceRoot: "/tmp/project-flaky", | 654 | workspaceRoot: "/tmp/project-flaky", |
| 654 | defaultModelSelection: { | 655 | defaultModelSelection: { |
| 655 | provider: "codex", | 656 | instanceId: ProviderInstanceId.make("codex"), |
| 656 | model: "gpt-5-codex", | 657 | model: "gpt-5-codex", |
| 657 | }, | 658 | }, |
| 658 | createdAt, | 659 | createdAt, |
| ... | @@ -668,7 +669,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -668,7 +669,7 @@ describe("OrchestrationEngine", () => { |
| 668 | projectId: asProjectId("project-flaky"), | 669 | projectId: asProjectId("project-flaky"), |
| 669 | title: "flaky-fail", | 670 | title: "flaky-fail", |
| 670 | modelSelection: { | 671 | modelSelection: { |
| 671 | provider: "codex", | 672 | instanceId: ProviderInstanceId.make("codex"), |
| 672 | model: "gpt-5-codex", | 673 | model: "gpt-5-codex", |
| 673 | }, | 674 | }, |
| 674 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 675 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -688,7 +689,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -688,7 +689,7 @@ describe("OrchestrationEngine", () => { |
| 688 | projectId: asProjectId("project-flaky"), | 689 | projectId: asProjectId("project-flaky"), |
| 689 | title: "flaky-ok", | 690 | title: "flaky-ok", |
| 690 | modelSelection: { | 691 | modelSelection: { |
| 691 | provider: "codex", | 692 | instanceId: ProviderInstanceId.make("codex"), |
| 692 | model: "gpt-5-codex", | 693 | model: "gpt-5-codex", |
| 693 | }, | 694 | }, |
| 694 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 695 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -747,7 +748,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -747,7 +748,7 @@ describe("OrchestrationEngine", () => { |
| 747 | title: "Atomic Project", | 748 | title: "Atomic Project", |
| 748 | workspaceRoot: "/tmp/project-atomic", | 749 | workspaceRoot: "/tmp/project-atomic", |
| 749 | defaultModelSelection: { | 750 | defaultModelSelection: { |
| 750 | provider: "codex", | 751 | instanceId: ProviderInstanceId.make("codex"), |
| 751 | model: "gpt-5-codex", | 752 | model: "gpt-5-codex", |
| 752 | }, | 753 | }, |
| 753 | createdAt, | 754 | createdAt, |
| ... | @@ -761,7 +762,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -761,7 +762,7 @@ describe("OrchestrationEngine", () => { |
| 761 | projectId: asProjectId("project-atomic"), | 762 | projectId: asProjectId("project-atomic"), |
| 762 | title: "atomic", | 763 | title: "atomic", |
| 763 | modelSelection: { | 764 | modelSelection: { |
| 764 | provider: "codex", | 765 | instanceId: ProviderInstanceId.make("codex"), |
| 765 | model: "gpt-5-codex", | 766 | model: "gpt-5-codex", |
| 766 | }, | 767 | }, |
| 767 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 768 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -890,7 +891,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -890,7 +891,7 @@ describe("OrchestrationEngine", () => { |
| 890 | title: "Sync Project", | 891 | title: "Sync Project", |
| 891 | workspaceRoot: "/tmp/project-sync", | 892 | workspaceRoot: "/tmp/project-sync", |
| 892 | defaultModelSelection: { | 893 | defaultModelSelection: { |
| 893 | provider: "codex", | 894 | instanceId: ProviderInstanceId.make("codex"), |
| 894 | model: "gpt-5-codex", | 895 | model: "gpt-5-codex", |
| 895 | }, | 896 | }, |
| 896 | createdAt, | 897 | createdAt, |
| ... | @@ -904,7 +905,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -904,7 +905,7 @@ describe("OrchestrationEngine", () => { |
| 904 | projectId: asProjectId("project-sync"), | 905 | projectId: asProjectId("project-sync"), |
| 905 | title: "sync-before", | 906 | title: "sync-before", |
| 906 | modelSelection: { | 907 | modelSelection: { |
| 907 | provider: "codex", | 908 | instanceId: ProviderInstanceId.make("codex"), |
| 908 | model: "gpt-5-codex", | 909 | model: "gpt-5-codex", |
| 909 | }, | 910 | }, |
| 910 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 911 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -975,7 +976,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -975,7 +976,7 @@ describe("OrchestrationEngine", () => { |
| 975 | title: "Duplicate Project", | 976 | title: "Duplicate Project", |
| 976 | workspaceRoot: "/tmp/project-duplicate", | 977 | workspaceRoot: "/tmp/project-duplicate", |
| 977 | defaultModelSelection: { | 978 | defaultModelSelection: { |
| 978 | provider: "codex", | 979 | instanceId: ProviderInstanceId.make("codex"), |
| 979 | model: "gpt-5-codex", | 980 | model: "gpt-5-codex", |
| 980 | }, | 981 | }, |
| 981 | createdAt, | 982 | createdAt, |
| ... | @@ -990,7 +991,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -990,7 +991,7 @@ describe("OrchestrationEngine", () => { |
| 990 | projectId: asProjectId("project-duplicate"), | 991 | projectId: asProjectId("project-duplicate"), |
| 991 | title: "duplicate", | 992 | title: "duplicate", |
| 992 | modelSelection: { | 993 | modelSelection: { |
| 993 | provider: "codex", | 994 | instanceId: ProviderInstanceId.make("codex"), |
| 994 | model: "gpt-5-codex", | 995 | model: "gpt-5-codex", |
| 995 | }, | 996 | }, |
| 996 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 997 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -1010,7 +1011,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -1010,7 +1011,7 @@ describe("OrchestrationEngine", () => { |
| 1010 | projectId: asProjectId("project-duplicate"), | 1011 | projectId: asProjectId("project-duplicate"), |
| 1011 | title: "duplicate", | 1012 | title: "duplicate", |
| 1012 | modelSelection: { | 1013 | modelSelection: { |
| 1013 | provider: "codex", | 1014 | instanceId: ProviderInstanceId.make("codex"), |
| 1014 | model: "gpt-5-codex", | 1015 | model: "gpt-5-codex", |
| 1015 | }, | 1016 | }, |
| 1016 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1017 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+17-16| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | ProjectId, | 7 | ProjectId, |
| 8 | ThreadId, | 8 | ThreadId, |
| 9 | TurnId, | 9 | TurnId, |
| 10 | ProviderInstanceId, | ||
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 12 | import { assert, it } from "@effect/vitest"; | 13 | import { assert, it } from "@effect/vitest"; |
| ... | @@ -92,7 +93,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -92,7 +93,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 92 | projectId: ProjectId.make("project-1"), | 93 | projectId: ProjectId.make("project-1"), |
| 93 | title: "Thread 1", | 94 | title: "Thread 1", |
| 94 | modelSelection: { | 95 | modelSelection: { |
| 95 | provider: "codex", | 96 | instanceId: ProviderInstanceId.make("codex"), |
| 96 | model: "gpt-5-codex", | 97 | model: "gpt-5-codex", |
| 97 | }, | 98 | }, |
| 98 | runtimeMode: "full-access", | 99 | runtimeMode: "full-access", |
| ... | @@ -364,7 +365,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -364,7 +365,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 364 | projectId: ProjectId.make("project-clear-attachments"), | 365 | projectId: ProjectId.make("project-clear-attachments"), |
| 365 | title: "Thread Clear Attachments", | 366 | title: "Thread Clear Attachments", |
| 366 | modelSelection: { | 367 | modelSelection: { |
| 367 | provider: "codex", | 368 | instanceId: ProviderInstanceId.make("codex"), |
| 368 | model: "gpt-5-codex", | 369 | model: "gpt-5-codex", |
| 369 | }, | 370 | }, |
| 370 | runtimeMode: "full-access", | 371 | runtimeMode: "full-access", |
| ... | @@ -492,7 +493,7 @@ it.layer( | ... | @@ -492,7 +493,7 @@ it.layer( |
| 492 | projectId: ProjectId.make("project-overwrite"), | 493 | projectId: ProjectId.make("project-overwrite"), |
| 493 | title: "Thread Overwrite", | 494 | title: "Thread Overwrite", |
| 494 | modelSelection: { | 495 | modelSelection: { |
| 495 | provider: "codex", | 496 | instanceId: ProviderInstanceId.make("codex"), |
| 496 | model: "gpt-5-codex", | 497 | model: "gpt-5-codex", |
| 497 | }, | 498 | }, |
| 498 | runtimeMode: "full-access", | 499 | runtimeMode: "full-access", |
| ... | @@ -640,7 +641,7 @@ it.layer( | ... | @@ -640,7 +641,7 @@ it.layer( |
| 640 | projectId: ProjectId.make("project-rollback"), | 641 | projectId: ProjectId.make("project-rollback"), |
| 641 | title: "Thread Rollback", | 642 | title: "Thread Rollback", |
| 642 | modelSelection: { | 643 | modelSelection: { |
| 643 | provider: "codex", | 644 | instanceId: ProviderInstanceId.make("codex"), |
| 644 | model: "gpt-5-codex", | 645 | model: "gpt-5-codex", |
| 645 | }, | 646 | }, |
| 646 | runtimeMode: "full-access", | 647 | runtimeMode: "full-access", |
| ... | @@ -769,7 +770,7 @@ it.layer( | ... | @@ -769,7 +770,7 @@ it.layer( |
| 769 | projectId: ProjectId.make("project-revert-files"), | 770 | projectId: ProjectId.make("project-revert-files"), |
| 770 | title: "Thread Revert Files", | 771 | title: "Thread Revert Files", |
| 771 | modelSelection: { | 772 | modelSelection: { |
| 772 | provider: "codex", | 773 | instanceId: ProviderInstanceId.make("codex"), |
| 773 | model: "gpt-5-codex", | 774 | model: "gpt-5-codex", |
| 774 | }, | 775 | }, |
| 775 | runtimeMode: "full-access", | 776 | runtimeMode: "full-access", |
| ... | @@ -977,7 +978,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta | ... | @@ -977,7 +978,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta |
| 977 | projectId: ProjectId.make("project-delete-files"), | 978 | projectId: ProjectId.make("project-delete-files"), |
| 978 | title: "Thread Delete Files", | 979 | title: "Thread Delete Files", |
| 979 | modelSelection: { | 980 | modelSelection: { |
| 980 | provider: "codex", | 981 | instanceId: ProviderInstanceId.make("codex"), |
| 981 | model: "gpt-5-codex", | 982 | model: "gpt-5-codex", |
| 982 | }, | 983 | }, |
| 983 | runtimeMode: "full-access", | 984 | runtimeMode: "full-access", |
| ... | @@ -1140,7 +1141,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1140,7 +1141,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1140 | projectId: ProjectId.make("project-a"), | 1141 | projectId: ProjectId.make("project-a"), |
| 1141 | title: "Thread A", | 1142 | title: "Thread A", |
| 1142 | modelSelection: { | 1143 | modelSelection: { |
| 1143 | provider: "codex", | 1144 | instanceId: ProviderInstanceId.make("codex"), |
| 1144 | model: "gpt-5-codex", | 1145 | model: "gpt-5-codex", |
| 1145 | }, | 1146 | }, |
| 1146 | runtimeMode: "full-access", | 1147 | runtimeMode: "full-access", |
| ... | @@ -1267,7 +1268,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1267,7 +1268,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1267 | projectId: ProjectId.make("project-empty"), | 1268 | projectId: ProjectId.make("project-empty"), |
| 1268 | title: "Thread Empty", | 1269 | title: "Thread Empty", |
| 1269 | modelSelection: { | 1270 | modelSelection: { |
| 1270 | provider: "codex", | 1271 | instanceId: ProviderInstanceId.make("codex"), |
| 1271 | model: "gpt-5-codex", | 1272 | model: "gpt-5-codex", |
| 1272 | }, | 1273 | }, |
| 1273 | runtimeMode: "full-access", | 1274 | runtimeMode: "full-access", |
| ... | @@ -1407,7 +1408,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1407,7 +1408,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1407 | projectId: ProjectId.make("project-conflict"), | 1408 | projectId: ProjectId.make("project-conflict"), |
| 1408 | title: "Thread Conflict", | 1409 | title: "Thread Conflict", |
| 1409 | modelSelection: { | 1410 | modelSelection: { |
| 1410 | provider: "codex", | 1411 | instanceId: ProviderInstanceId.make("codex"), |
| 1411 | model: "gpt-5-codex", | 1412 | model: "gpt-5-codex", |
| 1412 | }, | 1413 | }, |
| 1413 | runtimeMode: "full-access", | 1414 | runtimeMode: "full-access", |
| ... | @@ -1551,7 +1552,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1551,7 +1552,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1551 | projectId: ProjectId.make("project-stale-approval"), | 1552 | projectId: ProjectId.make("project-stale-approval"), |
| 1552 | title: "Thread Stale Approval", | 1553 | title: "Thread Stale Approval", |
| 1553 | modelSelection: { | 1554 | modelSelection: { |
| 1554 | provider: "codex", | 1555 | instanceId: ProviderInstanceId.make("codex"), |
| 1555 | model: "gpt-5-codex", | 1556 | model: "gpt-5-codex", |
| 1556 | }, | 1557 | }, |
| 1557 | runtimeMode: "approval-required", | 1558 | runtimeMode: "approval-required", |
| ... | @@ -1694,7 +1695,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1694,7 +1695,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1694 | projectId: ProjectId.make("project-nonstale-approval"), | 1695 | projectId: ProjectId.make("project-nonstale-approval"), |
| 1695 | title: "Thread Non-Stale Approval", | 1696 | title: "Thread Non-Stale Approval", |
| 1696 | modelSelection: { | 1697 | modelSelection: { |
| 1697 | provider: "codex", | 1698 | instanceId: ProviderInstanceId.make("codex"), |
| 1698 | model: "gpt-5-codex", | 1699 | model: "gpt-5-codex", |
| 1699 | }, | 1700 | }, |
| 1700 | runtimeMode: "approval-required", | 1701 | runtimeMode: "approval-required", |
| ... | @@ -1874,7 +1875,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1874,7 +1875,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1874 | projectId: ProjectId.make("project-revert"), | 1875 | projectId: ProjectId.make("project-revert"), |
| 1875 | title: "Thread Revert", | 1876 | title: "Thread Revert", |
| 1876 | modelSelection: { | 1877 | modelSelection: { |
| 1877 | provider: "codex", | 1878 | instanceId: ProviderInstanceId.make("codex"), |
| 1878 | model: "gpt-5-codex", | 1879 | model: "gpt-5-codex", |
| 1879 | }, | 1880 | }, |
| 1880 | runtimeMode: "full-access", | 1881 | runtimeMode: "full-access", |
| ... | @@ -2195,7 +2196,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -2195,7 +2196,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 2195 | title: "Live Project", | 2196 | title: "Live Project", |
| 2196 | workspaceRoot: "/tmp/project-live", | 2197 | workspaceRoot: "/tmp/project-live", |
| 2197 | defaultModelSelection: { | 2198 | defaultModelSelection: { |
| 2198 | provider: "codex", | 2199 | instanceId: ProviderInstanceId.make("codex"), |
| 2199 | model: "gpt-5-codex", | 2200 | model: "gpt-5-codex", |
| 2200 | }, | 2201 | }, |
| 2201 | createdAt, | 2202 | createdAt, |
| ... | @@ -2233,7 +2234,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -2233,7 +2234,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 2233 | title: "Scripts Project", | 2234 | title: "Scripts Project", |
| 2234 | workspaceRoot: "/tmp/project-scripts", | 2235 | workspaceRoot: "/tmp/project-scripts", |
| 2235 | defaultModelSelection: { | 2236 | defaultModelSelection: { |
| 2236 | provider: "codex", | 2237 | instanceId: ProviderInstanceId.make("codex"), |
| 2237 | model: "gpt-5-codex", | 2238 | model: "gpt-5-codex", |
| 2238 | }, | 2239 | }, |
| 2239 | createdAt, | 2240 | createdAt, |
| ... | @@ -2253,7 +2254,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -2253,7 +2254,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 2253 | }, | 2254 | }, |
| 2254 | ], | 2255 | ], |
| 2255 | defaultModelSelection: { | 2256 | defaultModelSelection: { |
| 2256 | provider: "codex", | 2257 | instanceId: ProviderInstanceId.make("codex"), |
| 2257 | model: "gpt-5", | 2258 | model: "gpt-5", |
| 2258 | }, | 2259 | }, |
| 2259 | }); | 2260 | }); |
| ... | @@ -2272,7 +2273,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -2272,7 +2273,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 2272 | { | 2273 | { |
| 2273 | scriptsJson: | 2274 | scriptsJson: |
| 2274 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', | 2275 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', |
| 2275 | defaultModelSelection: '{"provider":"codex","model":"gpt-5"}', | 2276 | defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', |
| 2276 | }, | 2277 | }, |
| 2277 | ]); | 2278 | ]); |
| 2278 | }), | 2279 | }), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1| ... | @@ -945,6 +945,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -945,6 +945,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 945 | threadId: event.payload.threadId, | 945 | threadId: event.payload.threadId, |
| 946 | status: event.payload.session.status, | 946 | status: event.payload.session.status, |
| 947 | providerName: event.payload.session.providerName, | 947 | providerName: event.payload.session.providerName, |
| 948 | providerInstanceId: event.payload.session.providerInstanceId ?? null, | ||
| 948 | runtimeMode: event.payload.session.runtimeMode, | 949 | runtimeMode: event.payload.session.runtimeMode, |
| 949 | activeTurnId: event.payload.session.activeTurnId, | 950 | activeTurnId: event.payload.session.activeTurnId, |
| 950 | lastError: event.payload.session.lastError, | 951 | lastError: event.payload.session.lastError, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+13-5| ... | @@ -1,4 +1,12 @@ | ... | @@ -1,4 +1,12 @@ |
| 1 | import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | 1 | import { |
| 2 | CheckpointRef, | ||
| 3 | EventId, | ||
| 4 | MessageId, | ||
| 5 | ProjectId, | ||
| 6 | ThreadId, | ||
| 7 | TurnId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | 10 | import { assert, it } from "@effect/vitest"; |
| 3 | import { Effect, Layer } from "effect"; | 11 | import { Effect, Layer } from "effect"; |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 12 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | @@ -252,7 +260,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -252,7 +260,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 252 | workspaceRoot: "/tmp/project-1", | 260 | workspaceRoot: "/tmp/project-1", |
| 253 | repositoryIdentity: null, | 261 | repositoryIdentity: null, |
| 254 | defaultModelSelection: { | 262 | defaultModelSelection: { |
| 255 | provider: "codex", | 263 | instanceId: ProviderInstanceId.make("codex"), |
| 256 | model: "gpt-5-codex", | 264 | model: "gpt-5-codex", |
| 257 | }, | 265 | }, |
| 258 | scripts: [ | 266 | scripts: [ |
| ... | @@ -275,7 +283,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -275,7 +283,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 275 | projectId: asProjectId("project-1"), | 283 | projectId: asProjectId("project-1"), |
| 276 | title: "Thread 1", | 284 | title: "Thread 1", |
| 277 | modelSelection: { | 285 | modelSelection: { |
| 278 | provider: "codex", | 286 | instanceId: ProviderInstanceId.make("codex"), |
| 279 | model: "gpt-5-codex", | 287 | model: "gpt-5-codex", |
| 280 | }, | 288 | }, |
| 281 | interactionMode: "default", | 289 | interactionMode: "default", |
| ... | @@ -363,7 +371,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -363,7 +371,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 363 | workspaceRoot: "/tmp/project-1", | 371 | workspaceRoot: "/tmp/project-1", |
| 364 | repositoryIdentity: null, | 372 | repositoryIdentity: null, |
| 365 | defaultModelSelection: { | 373 | defaultModelSelection: { |
| 366 | provider: "codex", | 374 | instanceId: ProviderInstanceId.make("codex"), |
| 367 | model: "gpt-5-codex", | 375 | model: "gpt-5-codex", |
| 368 | }, | 376 | }, |
| 369 | scripts: [ | 377 | scripts: [ |
| ... | @@ -385,7 +393,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -385,7 +393,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 385 | projectId: asProjectId("project-1"), | 393 | projectId: asProjectId("project-1"), |
| 386 | title: "Thread 1", | 394 | title: "Thread 1", |
| 387 | modelSelection: { | 395 | modelSelection: { |
| 388 | provider: "codex", | 396 | instanceId: ProviderInstanceId.make("codex"), |
| 389 | model: "gpt-5-codex", | 397 | model: "gpt-5-codex", |
| 390 | }, | 398 | }, |
| 391 | interactionMode: "default", | 399 | interactionMode: "default", |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+6| ... | @@ -195,6 +195,7 @@ function mapSessionRow( | ... | @@ -195,6 +195,7 @@ function mapSessionRow( |
| 195 | threadId: row.threadId, | 195 | threadId: row.threadId, |
| 196 | status: row.status, | 196 | status: row.status, |
| 197 | providerName: row.providerName, | 197 | providerName: row.providerName, |
| 198 | ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), | ||
| 198 | runtimeMode: row.runtimeMode, | 199 | runtimeMode: row.runtimeMode, |
| 199 | activeTurnId: row.activeTurnId, | 200 | activeTurnId: row.activeTurnId, |
| 200 | lastError: row.lastError, | 201 | lastError: row.lastError, |
| ... | @@ -350,6 +351,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -350,6 +351,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 350 | thread_id AS "threadId", | 351 | thread_id AS "threadId", |
| 351 | status, | 352 | status, |
| 352 | provider_name AS "providerName", | 353 | provider_name AS "providerName", |
| 354 | provider_instance_id AS "providerInstanceId", | ||
| 353 | provider_session_id AS "providerSessionId", | 355 | provider_session_id AS "providerSessionId", |
| 354 | provider_thread_id AS "providerThreadId", | 356 | provider_thread_id AS "providerThreadId", |
| 355 | runtime_mode AS "runtimeMode", | 357 | runtime_mode AS "runtimeMode", |
| ... | @@ -608,6 +610,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -608,6 +610,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 608 | thread_id AS "threadId", | 610 | thread_id AS "threadId", |
| 609 | status, | 611 | status, |
| 610 | provider_name AS "providerName", | 612 | provider_name AS "providerName", |
| 613 | provider_instance_id AS "providerInstanceId", | ||
| 611 | runtime_mode AS "runtimeMode", | 614 | runtime_mode AS "runtimeMode", |
| 612 | active_turn_id AS "activeTurnId", | 615 | active_turn_id AS "activeTurnId", |
| 613 | last_error AS "lastError", | 616 | last_error AS "lastError", |
| ... | @@ -879,6 +882,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -879,6 +882,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 879 | threadId: row.threadId, | 882 | threadId: row.threadId, |
| 880 | status: row.status, | 883 | status: row.status, |
| 881 | providerName: row.providerName, | 884 | providerName: row.providerName, |
| 885 | ...(row.providerInstanceId !== null | ||
| 886 | ? { providerInstanceId: row.providerInstanceId } | ||
| 887 | : {}), | ||
| 882 | runtimeMode: row.runtimeMode, | 888 | runtimeMode: row.runtimeMode, |
| 883 | activeTurnId: row.activeTurnId, | 889 | activeTurnId: row.activeTurnId, |
| 884 | lastError: row.lastError, | 890 | lastError: row.lastError, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+370-68| ... | @@ -2,7 +2,13 @@ import fs from "node:fs"; | ... | @@ -2,7 +2,13 @@ import fs from "node:fs"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | 5 | import { |
| 6 | ModelSelection, | ||
| 7 | ProviderRuntimeEvent, | ||
| 8 | ProviderSession, | ||
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 6 | import { createModelSelection } from "@t3tools/shared/model"; | 12 | import { createModelSelection } from "@t3tools/shared/model"; |
| 7 | import { | 13 | import { |
| 8 | ApprovalRequestId, | 14 | ApprovalRequestId, |
| ... | @@ -37,7 +43,11 @@ import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryI | ... | @@ -37,7 +43,11 @@ import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryI |
| 37 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 43 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 38 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 44 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 39 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 45 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 40 | import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; | 46 | import { |
| 47 | providerErrorLabel, | ||
| 48 | providerErrorLabelFromInstanceHint, | ||
| 49 | ProviderCommandReactorLive, | ||
| 50 | } from "./ProviderCommandReactor.ts"; | ||
| 41 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 51 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 42 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 52 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 43 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 53 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | @@ -98,6 +108,30 @@ describe("ProviderCommandReactor", () => { | ... | @@ -98,6 +108,30 @@ describe("ProviderCommandReactor", () => { |
| 98 | createdBaseDirs.clear(); | 108 | createdBaseDirs.clear(); |
| 99 | }); | 109 | }); |
| 100 | 110 | ||
| 111 | describe("provider error attribution", () => { | ||
| 112 | it("uses the current provider instance slug when current instance lookup fails", () => { | ||
| 113 | expect( | ||
| 114 | providerErrorLabelFromInstanceHint({ | ||
| 115 | instanceId: "codex_personal", | ||
| 116 | modelSelectionInstanceId: "codex", | ||
| 117 | sessionProvider: "codex", | ||
| 118 | }), | ||
| 119 | ).toBe("codex_personal"); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("uses the desired provider instance slug when desired instance lookup fails", () => { | ||
| 123 | expect( | ||
| 124 | providerErrorLabelFromInstanceHint({ | ||
| 125 | instanceId: "claude_openrouter", | ||
| 126 | }), | ||
| 127 | ).toBe("claude_openrouter"); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("uses the unknown driver kind when the resolved driver is not registered locally", () => { | ||
| 131 | expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 101 | async function createHarness(input?: { | 135 | async function createHarness(input?: { |
| 102 | readonly baseDir?: string; | 136 | readonly baseDir?: string; |
| 103 | readonly threadModelSelection?: ModelSelection; | 137 | readonly threadModelSelection?: ModelSelection; |
| ... | @@ -112,7 +146,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -112,7 +146,7 @@ describe("ProviderCommandReactor", () => { |
| 112 | let nextSessionIndex = 1; | 146 | let nextSessionIndex = 1; |
| 113 | const runtimeSessions: Array<ProviderSession> = []; | 147 | const runtimeSessions: Array<ProviderSession> = []; |
| 114 | const modelSelection = input?.threadModelSelection ?? { | 148 | const modelSelection = input?.threadModelSelection ?? { |
| 115 | provider: "codex", | 149 | instanceId: ProviderInstanceId.make("codex"), |
| 116 | model: "gpt-5-codex", | 150 | model: "gpt-5-codex", |
| 117 | }; | 151 | }; |
| 118 | const startSession = vi.fn((_: unknown, input: unknown) => { | 152 | const startSession = vi.fn((_: unknown, input: unknown) => { |
| ... | @@ -128,8 +162,24 @@ describe("ProviderCommandReactor", () => { | ... | @@ -128,8 +162,24 @@ describe("ProviderCommandReactor", () => { |
| 128 | typeof input.threadId === "string" | 162 | typeof input.threadId === "string" |
| 129 | ? ThreadId.make(input.threadId) | 163 | ? ThreadId.make(input.threadId) |
| 130 | : ThreadId.make(`thread-${sessionIndex}`); | 164 | : ThreadId.make(`thread-${sessionIndex}`); |
| 165 | const inputModelSelection = | ||
| 166 | typeof input === "object" && input !== null && "modelSelection" in input | ||
| 167 | ? (input.modelSelection as ModelSelection | undefined) | ||
| 168 | : undefined; | ||
| 169 | const providerInstanceId = | ||
| 170 | typeof input === "object" && input !== null && "providerInstanceId" in input | ||
| 171 | ? (input.providerInstanceId as ProviderInstanceId | undefined) | ||
| 172 | : inputModelSelection?.instanceId; | ||
| 173 | const provider = | ||
| 174 | typeof input === "object" && | ||
| 175 | input !== null && | ||
| 176 | "provider" in input && | ||
| 177 | typeof input.provider === "string" | ||
| 178 | ? (input.provider as ProviderSession["provider"]) | ||
| 179 | : ProviderDriverKind.make(inputModelSelection?.instanceId ?? modelSelection.instanceId); | ||
| 131 | const session: ProviderSession = { | 180 | const session: ProviderSession = { |
| 132 | provider: modelSelection.provider, | 181 | provider, |
| 182 | ...(providerInstanceId ? { providerInstanceId } : {}), | ||
| 133 | status: "ready" as const, | 183 | status: "ready" as const, |
| 134 | runtimeMode: | 184 | runtimeMode: |
| 135 | typeof input === "object" && | 185 | typeof input === "object" && |
| ... | @@ -144,7 +194,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -144,7 +194,9 @@ describe("ProviderCommandReactor", () => { |
| 144 | typeof input.cwd === "string" | 194 | typeof input.cwd === "string" |
| 145 | ? { cwd: input.cwd } | 195 | ? { cwd: input.cwd } |
| 146 | : {}), | 196 | : {}), |
| 147 | ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}), | 197 | ...((inputModelSelection?.model ?? modelSelection.model) |
| 198 | ? { model: inputModelSelection?.model ?? modelSelection.model } | ||
| 199 | : {}), | ||
| 148 | threadId, | 200 | threadId, |
| 149 | resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, | 201 | resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, |
| 150 | createdAt: now, | 202 | createdAt: now, |
| ... | @@ -236,6 +288,25 @@ describe("ProviderCommandReactor", () => { | ... | @@ -236,6 +288,25 @@ describe("ProviderCommandReactor", () => { |
| 236 | Effect.succeed({ | 288 | Effect.succeed({ |
| 237 | sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", | 289 | sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", |
| 238 | }), | 290 | }), |
| 291 | getInstanceInfo: (instanceId) => { | ||
| 292 | const raw = String(instanceId); | ||
| 293 | const driverKind = ProviderDriverKind.make( | ||
| 294 | raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, | ||
| 295 | ); | ||
| 296 | return Effect.succeed({ | ||
| 297 | instanceId, | ||
| 298 | driverKind, | ||
| 299 | displayName: undefined, | ||
| 300 | enabled: true, | ||
| 301 | continuationIdentity: { | ||
| 302 | driverKind, | ||
| 303 | continuationKey: | ||
| 304 | driverKind === ProviderDriverKind.make("codex") | ||
| 305 | ? "codex:home:/shared-codex" | ||
| 306 | : `${driverKind}:instance:${instanceId}`, | ||
| 307 | }, | ||
| 308 | }); | ||
| 309 | }, | ||
| 239 | rollbackConversation: () => unsupported(), | 310 | rollbackConversation: () => unsupported(), |
| 240 | get streamEvents() { | 311 | get streamEvents() { |
| 241 | return Stream.fromPubSub(runtimeEventPubSub); | 312 | return Stream.fromPubSub(runtimeEventPubSub); |
| ... | @@ -320,6 +391,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -320,6 +391,7 @@ describe("ProviderCommandReactor", () => { |
| 320 | refreshStatus, | 391 | refreshStatus, |
| 321 | generateBranchName, | 392 | generateBranchName, |
| 322 | generateThreadTitle, | 393 | generateThreadTitle, |
| 394 | runtimeSessions, | ||
| 323 | stateDir, | 395 | stateDir, |
| 324 | drain, | 396 | drain, |
| 325 | }; | 397 | }; |
| ... | @@ -352,7 +424,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -352,7 +424,7 @@ describe("ProviderCommandReactor", () => { |
| 352 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 424 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 353 | cwd: "/tmp/provider-project", | 425 | cwd: "/tmp/provider-project", |
| 354 | modelSelection: { | 426 | modelSelection: { |
| 355 | provider: "codex", | 427 | instanceId: ProviderInstanceId.make("codex"), |
| 356 | model: "gpt-5-codex", | 428 | model: "gpt-5-codex", |
| 357 | }, | 429 | }, |
| 358 | runtimeMode: "approval-required", | 430 | runtimeMode: "approval-required", |
| ... | @@ -574,7 +646,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -574,7 +646,7 @@ describe("ProviderCommandReactor", () => { |
| 574 | text: "hello fast mode", | 646 | text: "hello fast mode", |
| 575 | attachments: [], | 647 | attachments: [], |
| 576 | }, | 648 | }, |
| 577 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 649 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 578 | { id: "reasoningEffort", value: "high" }, | 650 | { id: "reasoningEffort", value: "high" }, |
| 579 | { id: "fastMode", value: true }, | 651 | { id: "fastMode", value: true }, |
| 580 | ]), | 652 | ]), |
| ... | @@ -587,14 +659,14 @@ describe("ProviderCommandReactor", () => { | ... | @@ -587,14 +659,14 @@ describe("ProviderCommandReactor", () => { |
| 587 | await waitFor(() => harness.startSession.mock.calls.length === 1); | 659 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 588 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 660 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 589 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 661 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 590 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 662 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 591 | { id: "reasoningEffort", value: "high" }, | 663 | { id: "reasoningEffort", value: "high" }, |
| 592 | { id: "fastMode", value: true }, | 664 | { id: "fastMode", value: true }, |
| 593 | ]), | 665 | ]), |
| 594 | }); | 666 | }); |
| 595 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | 667 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 596 | threadId: ThreadId.make("thread-1"), | 668 | threadId: ThreadId.make("thread-1"), |
| 597 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 669 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 598 | { id: "reasoningEffort", value: "high" }, | 670 | { id: "reasoningEffort", value: "high" }, |
| 599 | { id: "fastMode", value: true }, | 671 | { id: "fastMode", value: true }, |
| 600 | ]), | 672 | ]), |
| ... | @@ -603,7 +675,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -603,7 +675,10 @@ describe("ProviderCommandReactor", () => { |
| 603 | 675 | ||
| 604 | it("forwards claude effort options through session start and turn send", async () => { | 676 | it("forwards claude effort options through session start and turn send", async () => { |
| 605 | const harness = await createHarness({ | 677 | const harness = await createHarness({ |
| 606 | threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | 678 | threadModelSelection: { |
| 679 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 680 | model: "claude-sonnet-4-6", | ||
| 681 | }, | ||
| 607 | }); | 682 | }); |
| 608 | const now = new Date().toISOString(); | 683 | const now = new Date().toISOString(); |
| 609 | 684 | ||
| ... | @@ -618,9 +693,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -618,9 +693,11 @@ describe("ProviderCommandReactor", () => { |
| 618 | text: "hello with effort", | 693 | text: "hello with effort", |
| 619 | attachments: [], | 694 | attachments: [], |
| 620 | }, | 695 | }, |
| 621 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 696 | modelSelection: createModelSelection( |
| 622 | { id: "effort", value: "max" }, | 697 | ProviderInstanceId.make("claudeAgent"), |
| 623 | ]), | 698 | "claude-sonnet-4-6", |
| 699 | [{ id: "effort", value: "max" }], | ||
| 700 | ), | ||
| 624 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 701 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 625 | runtimeMode: "approval-required", | 702 | runtimeMode: "approval-required", |
| 626 | createdAt: now, | 703 | createdAt: now, |
| ... | @@ -630,21 +707,28 @@ describe("ProviderCommandReactor", () => { | ... | @@ -630,21 +707,28 @@ describe("ProviderCommandReactor", () => { |
| 630 | await waitFor(() => harness.startSession.mock.calls.length === 1); | 707 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 631 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 708 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 632 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 709 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 633 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 710 | modelSelection: createModelSelection( |
| 634 | { id: "effort", value: "max" }, | 711 | ProviderInstanceId.make("claudeAgent"), |
| 635 | ]), | 712 | "claude-sonnet-4-6", |
| 713 | [{ id: "effort", value: "max" }], | ||
| 714 | ), | ||
| 636 | }); | 715 | }); |
| 637 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | 716 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 638 | threadId: ThreadId.make("thread-1"), | 717 | threadId: ThreadId.make("thread-1"), |
| 639 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 718 | modelSelection: createModelSelection( |
| 640 | { id: "effort", value: "max" }, | 719 | ProviderInstanceId.make("claudeAgent"), |
| 641 | ]), | 720 | "claude-sonnet-4-6", |
| 721 | [{ id: "effort", value: "max" }], | ||
| 722 | ), | ||
| 642 | }); | 723 | }); |
| 643 | }); | 724 | }); |
| 644 | 725 | ||
| 645 | it("forwards claude fast mode options through session start and turn send", async () => { | 726 | it("forwards claude fast mode options through session start and turn send", async () => { |
| 646 | const harness = await createHarness({ | 727 | const harness = await createHarness({ |
| 647 | threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, | 728 | threadModelSelection: { |
| 729 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 730 | model: "claude-opus-4-6", | ||
| 731 | }, | ||
| 648 | }); | 732 | }); |
| 649 | const now = new Date().toISOString(); | 733 | const now = new Date().toISOString(); |
| 650 | 734 | ||
| ... | @@ -659,9 +743,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -659,9 +743,11 @@ describe("ProviderCommandReactor", () => { |
| 659 | text: "hello with fast mode", | 743 | text: "hello with fast mode", |
| 660 | attachments: [], | 744 | attachments: [], |
| 661 | }, | 745 | }, |
| 662 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 746 | modelSelection: createModelSelection( |
| 663 | { id: "fastMode", value: true }, | 747 | ProviderInstanceId.make("claudeAgent"), |
| 664 | ]), | 748 | "claude-opus-4-6", |
| 749 | [{ id: "fastMode", value: true }], | ||
| 750 | ), | ||
| 665 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 751 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 666 | runtimeMode: "approval-required", | 752 | runtimeMode: "approval-required", |
| 667 | createdAt: now, | 753 | createdAt: now, |
| ... | @@ -671,15 +757,19 @@ describe("ProviderCommandReactor", () => { | ... | @@ -671,15 +757,19 @@ describe("ProviderCommandReactor", () => { |
| 671 | await waitFor(() => harness.startSession.mock.calls.length === 1); | 757 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 672 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 758 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 673 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 759 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 674 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 760 | modelSelection: createModelSelection( |
| 675 | { id: "fastMode", value: true }, | 761 | ProviderInstanceId.make("claudeAgent"), |
| 676 | ]), | 762 | "claude-opus-4-6", |
| 763 | [{ id: "fastMode", value: true }], | ||
| 764 | ), | ||
| 677 | }); | 765 | }); |
| 678 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | 766 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 679 | threadId: ThreadId.make("thread-1"), | 767 | threadId: ThreadId.make("thread-1"), |
| 680 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 768 | modelSelection: createModelSelection( |
| 681 | { id: "fastMode", value: true }, | 769 | ProviderInstanceId.make("claudeAgent"), |
| 682 | ]), | 770 | "claude-opus-4-6", |
| 771 | [{ id: "fastMode", value: true }], | ||
| 772 | ), | ||
| 683 | }); | 773 | }); |
| 684 | }); | 774 | }); |
| 685 | 775 | ||
| ... | @@ -766,15 +856,15 @@ describe("ProviderCommandReactor", () => { | ... | @@ -766,15 +856,15 @@ describe("ProviderCommandReactor", () => { |
| 766 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ | 856 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| 767 | threadId: ThreadId.make("thread-1"), | 857 | threadId: ThreadId.make("thread-1"), |
| 768 | modelSelection: { | 858 | modelSelection: { |
| 769 | provider: "codex", | 859 | instanceId: ProviderInstanceId.make("codex"), |
| 770 | model: "gpt-5-codex", | 860 | model: "gpt-5-codex", |
| 771 | }, | 861 | }, |
| 772 | }); | 862 | }); |
| 773 | }); | 863 | }); |
| 774 | 864 | ||
| 775 | it("rejects a first turn when requested provider conflicts with the thread model", async () => { | 865 | it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { |
| 776 | const harness = await createHarness({ | 866 | const harness = await createHarness({ |
| 777 | threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, | 867 | threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, |
| 778 | }); | 868 | }); |
| 779 | const now = new Date().toISOString(); | 869 | const now = new Date().toISOString(); |
| 780 | 870 | ||
| ... | @@ -790,7 +880,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -790,7 +880,7 @@ describe("ProviderCommandReactor", () => { |
| 790 | attachments: [], | 880 | attachments: [], |
| 791 | }, | 881 | }, |
| 792 | modelSelection: { | 882 | modelSelection: { |
| 793 | provider: "claudeAgent", | 883 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 794 | model: "claude-opus-4-6", | 884 | model: "claude-opus-4-6", |
| 795 | }, | 885 | }, |
| 796 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 886 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -799,29 +889,25 @@ describe("ProviderCommandReactor", () => { | ... | @@ -799,29 +889,25 @@ describe("ProviderCommandReactor", () => { |
| 799 | }), | 889 | }), |
| 800 | ); | 890 | ); |
| 801 | 891 | ||
| 802 | await waitFor(async () => { | 892 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 803 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 804 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 805 | return ( | ||
| 806 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | ||
| 807 | false | ||
| 808 | ); | ||
| 809 | }); | ||
| 810 | 893 | ||
| 811 | expect(harness.startSession).not.toHaveBeenCalled(); | 894 | expect(harness.startSession).toHaveBeenCalledTimes(1); |
| 812 | expect(harness.sendTurn).not.toHaveBeenCalled(); | 895 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 896 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 897 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 898 | modelSelection: { | ||
| 899 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 900 | model: "claude-opus-4-6", | ||
| 901 | }, | ||
| 902 | }); | ||
| 813 | 903 | ||
| 814 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 904 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 815 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | 905 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 816 | expect(thread?.session).toBeNull(); | 906 | expect(thread?.session?.providerName).toBe("claudeAgent"); |
| 907 | expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("claudeAgent")); | ||
| 817 | expect( | 908 | expect( |
| 818 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | 909 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), |
| 819 | ).toMatchObject({ | 910 | ).toBeUndefined(); |
| 820 | summary: "Provider turn start failed", | ||
| 821 | payload: { | ||
| 822 | detail: expect.stringContaining("cannot switch to 'claudeAgent'"), | ||
| 823 | }, | ||
| 824 | }); | ||
| 825 | }); | 911 | }); |
| 826 | 912 | ||
| 827 | it("reuses the same provider session when runtime mode is unchanged", async () => { | 913 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| ... | @@ -870,9 +956,74 @@ describe("ProviderCommandReactor", () => { | ... | @@ -870,9 +956,74 @@ describe("ProviderCommandReactor", () => { |
| 870 | expect(harness.stopSession.mock.calls.length).toBe(0); | 956 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 871 | }); | 957 | }); |
| 872 | 958 | ||
| 959 | it("restarts an existing Codex thread on a compatible requested instance", async () => { | ||
| 960 | const harness = await createHarness(); | ||
| 961 | const now = new Date().toISOString(); | ||
| 962 | |||
| 963 | await Effect.runPromise( | ||
| 964 | harness.engine.dispatch({ | ||
| 965 | type: "thread.turn.start", | ||
| 966 | commandId: CommandId.make("cmd-turn-start-compatible-codex-1"), | ||
| 967 | threadId: ThreadId.make("thread-1"), | ||
| 968 | message: { | ||
| 969 | messageId: asMessageId("user-message-compatible-codex-1"), | ||
| 970 | role: "user", | ||
| 971 | text: "first", | ||
| 972 | attachments: [], | ||
| 973 | }, | ||
| 974 | modelSelection: { | ||
| 975 | instanceId: ProviderInstanceId.make("codex"), | ||
| 976 | model: "gpt-5-codex", | ||
| 977 | }, | ||
| 978 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 979 | runtimeMode: "approval-required", | ||
| 980 | createdAt: now, | ||
| 981 | }), | ||
| 982 | ); | ||
| 983 | |||
| 984 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | ||
| 985 | |||
| 986 | await Effect.runPromise( | ||
| 987 | harness.engine.dispatch({ | ||
| 988 | type: "thread.turn.start", | ||
| 989 | commandId: CommandId.make("cmd-turn-start-compatible-codex-2"), | ||
| 990 | threadId: ThreadId.make("thread-1"), | ||
| 991 | message: { | ||
| 992 | messageId: asMessageId("user-message-compatible-codex-2"), | ||
| 993 | role: "user", | ||
| 994 | text: "second", | ||
| 995 | attachments: [], | ||
| 996 | }, | ||
| 997 | modelSelection: { | ||
| 998 | instanceId: ProviderInstanceId.make("codex_work"), | ||
| 999 | model: "gpt-5-codex", | ||
| 1000 | }, | ||
| 1001 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1002 | runtimeMode: "approval-required", | ||
| 1003 | createdAt: new Date().toISOString(), | ||
| 1004 | }), | ||
| 1005 | ); | ||
| 1006 | |||
| 1007 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | ||
| 1008 | |||
| 1009 | expect(harness.startSession).toHaveBeenCalledTimes(2); | ||
| 1010 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | ||
| 1011 | provider: ProviderDriverKind.make("codex"), | ||
| 1012 | providerInstanceId: ProviderInstanceId.make("codex_work"), | ||
| 1013 | resumeCursor: { opaque: "resume-1" }, | ||
| 1014 | }); | ||
| 1015 | |||
| 1016 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 1017 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 1018 | expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); | ||
| 1019 | }); | ||
| 1020 | |||
| 873 | it("restarts the provider session when the thread workspace changes", async () => { | 1021 | it("restarts the provider session when the thread workspace changes", async () => { |
| 874 | const harness = await createHarness({ | 1022 | const harness = await createHarness({ |
| 875 | threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | 1023 | threadModelSelection: { |
| 1024 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 1025 | model: "claude-sonnet-4-6", | ||
| 1026 | }, | ||
| 876 | }); | 1027 | }); |
| 877 | const now = new Date().toISOString(); | 1028 | const now = new Date().toISOString(); |
| 878 | 1029 | ||
| ... | @@ -933,7 +1084,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -933,7 +1084,7 @@ describe("ProviderCommandReactor", () => { |
| 933 | cwd: "/tmp/provider-project-worktree", | 1084 | cwd: "/tmp/provider-project-worktree", |
| 934 | resumeCursor: { opaque: "resume-1" }, | 1085 | resumeCursor: { opaque: "resume-1" }, |
| 935 | modelSelection: { | 1086 | modelSelection: { |
| 936 | provider: "claudeAgent", | 1087 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 937 | model: "claude-sonnet-4-6", | 1088 | model: "claude-sonnet-4-6", |
| 938 | }, | 1089 | }, |
| 939 | runtimeMode: "approval-required", | 1090 | runtimeMode: "approval-required", |
| ... | @@ -942,7 +1093,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -942,7 +1093,10 @@ describe("ProviderCommandReactor", () => { |
| 942 | 1093 | ||
| 943 | it("restarts claude sessions when claude effort changes", async () => { | 1094 | it("restarts claude sessions when claude effort changes", async () => { |
| 944 | const harness = await createHarness({ | 1095 | const harness = await createHarness({ |
| 945 | threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | 1096 | threadModelSelection: { |
| 1097 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 1098 | model: "claude-sonnet-4-6", | ||
| 1099 | }, | ||
| 946 | }); | 1100 | }); |
| 947 | const now = new Date().toISOString(); | 1101 | const now = new Date().toISOString(); |
| 948 | 1102 | ||
| ... | @@ -957,9 +1111,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -957,9 +1111,11 @@ describe("ProviderCommandReactor", () => { |
| 957 | text: "first claude turn", | 1111 | text: "first claude turn", |
| 958 | attachments: [], | 1112 | attachments: [], |
| 959 | }, | 1113 | }, |
| 960 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 1114 | modelSelection: createModelSelection( |
| 961 | { id: "effort", value: "medium" }, | 1115 | ProviderInstanceId.make("claudeAgent"), |
| 962 | ]), | 1116 | "claude-sonnet-4-6", |
| 1117 | [{ id: "effort", value: "medium" }], | ||
| 1118 | ), | ||
| 963 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1119 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 964 | runtimeMode: "approval-required", | 1120 | runtimeMode: "approval-required", |
| 965 | createdAt: now, | 1121 | createdAt: now, |
| ... | @@ -980,9 +1136,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -980,9 +1136,11 @@ describe("ProviderCommandReactor", () => { |
| 980 | text: "second claude turn", | 1136 | text: "second claude turn", |
| 981 | attachments: [], | 1137 | attachments: [], |
| 982 | }, | 1138 | }, |
| 983 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 1139 | modelSelection: createModelSelection( |
| 984 | { id: "effort", value: "max" }, | 1140 | ProviderInstanceId.make("claudeAgent"), |
| 985 | ]), | 1141 | "claude-sonnet-4-6", |
| 1142 | [{ id: "effort", value: "max" }], | ||
| 1143 | ), | ||
| 986 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1144 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 987 | runtimeMode: "approval-required", | 1145 | runtimeMode: "approval-required", |
| 988 | createdAt: now, | 1146 | createdAt: now, |
| ... | @@ -993,9 +1151,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -993,9 +1151,11 @@ describe("ProviderCommandReactor", () => { |
| 993 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | 1151 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 994 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | 1152 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 995 | resumeCursor: { opaque: "resume-1" }, | 1153 | resumeCursor: { opaque: "resume-1" }, |
| 996 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 1154 | modelSelection: createModelSelection( |
| 997 | { id: "effort", value: "max" }, | 1155 | ProviderInstanceId.make("claudeAgent"), |
| 998 | ]), | 1156 | "claude-sonnet-4-6", |
| 1157 | [{ id: "effort", value: "max" }], | ||
| 1158 | ), | ||
| 999 | }); | 1159 | }); |
| 1000 | }); | 1160 | }); |
| 1001 | 1161 | ||
| ... | @@ -1086,7 +1246,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1086,7 +1246,10 @@ describe("ProviderCommandReactor", () => { |
| 1086 | 1246 | ||
| 1087 | it("does not inject derived model options when restarting claude on runtime mode changes", async () => { | 1247 | it("does not inject derived model options when restarting claude on runtime mode changes", async () => { |
| 1088 | const harness = await createHarness({ | 1248 | const harness = await createHarness({ |
| 1089 | threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, | 1249 | threadModelSelection: { |
| 1250 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 1251 | model: "claude-opus-4-6", | ||
| 1252 | }, | ||
| 1090 | }); | 1253 | }); |
| 1091 | const now = new Date().toISOString(); | 1254 | const now = new Date().toISOString(); |
| 1092 | 1255 | ||
| ... | @@ -1122,7 +1285,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1122,7 +1285,7 @@ describe("ProviderCommandReactor", () => { |
| 1122 | 1285 | ||
| 1123 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 1286 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 1124 | modelSelection: { | 1287 | modelSelection: { |
| 1125 | provider: "claudeAgent", | 1288 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 1126 | model: "claude-opus-4-6", | 1289 | model: "claude-opus-4-6", |
| 1127 | }, | 1290 | }, |
| 1128 | runtimeMode: "approval-required", | 1291 | runtimeMode: "approval-required", |
| ... | @@ -1230,7 +1393,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1230,7 +1393,7 @@ describe("ProviderCommandReactor", () => { |
| 1230 | attachments: [], | 1393 | attachments: [], |
| 1231 | }, | 1394 | }, |
| 1232 | modelSelection: { | 1395 | modelSelection: { |
| 1233 | provider: "claudeAgent", | 1396 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 1234 | model: "claude-opus-4-6", | 1397 | model: "claude-opus-4-6", |
| 1235 | }, | 1398 | }, |
| 1236 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1399 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -1266,6 +1429,72 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1266,6 +1429,72 @@ describe("ProviderCommandReactor", () => { |
| 1266 | }); | 1429 | }); |
| 1267 | }); | 1430 | }); |
| 1268 | 1431 | ||
| 1432 | it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { | ||
| 1433 | const harness = await createHarness(); | ||
| 1434 | const now = new Date().toISOString(); | ||
| 1435 | |||
| 1436 | await Effect.runPromise( | ||
| 1437 | harness.engine.dispatch({ | ||
| 1438 | type: "thread.session.set", | ||
| 1439 | commandId: CommandId.make("cmd-session-set-stopped-provider-switch"), | ||
| 1440 | threadId: ThreadId.make("thread-1"), | ||
| 1441 | session: { | ||
| 1442 | threadId: ThreadId.make("thread-1"), | ||
| 1443 | status: "stopped", | ||
| 1444 | providerName: "codex", | ||
| 1445 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 1446 | runtimeMode: "approval-required", | ||
| 1447 | activeTurnId: null, | ||
| 1448 | lastError: null, | ||
| 1449 | updatedAt: now, | ||
| 1450 | }, | ||
| 1451 | createdAt: now, | ||
| 1452 | }), | ||
| 1453 | ); | ||
| 1454 | |||
| 1455 | await Effect.runPromise( | ||
| 1456 | harness.engine.dispatch({ | ||
| 1457 | type: "thread.turn.start", | ||
| 1458 | commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"), | ||
| 1459 | threadId: ThreadId.make("thread-1"), | ||
| 1460 | message: { | ||
| 1461 | messageId: asMessageId("user-message-stopped-provider-switch"), | ||
| 1462 | role: "user", | ||
| 1463 | text: "continue with claude", | ||
| 1464 | attachments: [], | ||
| 1465 | }, | ||
| 1466 | modelSelection: { | ||
| 1467 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 1468 | model: "claude-opus-4-6", | ||
| 1469 | }, | ||
| 1470 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1471 | runtimeMode: "approval-required", | ||
| 1472 | createdAt: now, | ||
| 1473 | }), | ||
| 1474 | ); | ||
| 1475 | |||
| 1476 | await waitFor(async () => { | ||
| 1477 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 1478 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 1479 | return ( | ||
| 1480 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | ||
| 1481 | false | ||
| 1482 | ); | ||
| 1483 | }); | ||
| 1484 | |||
| 1485 | expect(harness.startSession.mock.calls.length).toBe(0); | ||
| 1486 | expect(harness.sendTurn.mock.calls.length).toBe(0); | ||
| 1487 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 1488 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 1489 | expect( | ||
| 1490 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | ||
| 1491 | ).toMatchObject({ | ||
| 1492 | payload: { | ||
| 1493 | detail: expect.stringContaining("cannot switch to 'claudeAgent'"), | ||
| 1494 | }, | ||
| 1495 | }); | ||
| 1496 | }); | ||
| 1497 | |||
| 1269 | it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { | 1498 | it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { |
| 1270 | const harness = await createHarness(); | 1499 | const harness = await createHarness(); |
| 1271 | const now = new Date().toISOString(); | 1500 | const now = new Date().toISOString(); |
| ... | @@ -1349,7 +1578,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1349,7 +1578,7 @@ describe("ProviderCommandReactor", () => { |
| 1349 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 1578 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 1350 | threadId: ThreadId.make("thread-1"), | 1579 | threadId: ThreadId.make("thread-1"), |
| 1351 | modelSelection: { | 1580 | modelSelection: { |
| 1352 | provider: "codex", | 1581 | instanceId: ProviderInstanceId.make("codex"), |
| 1353 | model: "gpt-5-codex", | 1582 | model: "gpt-5-codex", |
| 1354 | }, | 1583 | }, |
| 1355 | runtimeMode: "approval-required", | 1584 | runtimeMode: "approval-required", |
| ... | @@ -1359,6 +1588,77 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1359,6 +1588,77 @@ describe("ProviderCommandReactor", () => { |
| 1359 | }); | 1588 | }); |
| 1360 | }); | 1589 | }); |
| 1361 | 1590 | ||
| 1591 | it("rejects active runtime sessions that are missing provider instance ids", async () => { | ||
| 1592 | const harness = await createHarness(); | ||
| 1593 | const now = new Date().toISOString(); | ||
| 1594 | |||
| 1595 | await Effect.runPromise( | ||
| 1596 | harness.engine.dispatch({ | ||
| 1597 | type: "thread.session.set", | ||
| 1598 | commandId: CommandId.make("cmd-session-set-missing-instance"), | ||
| 1599 | threadId: ThreadId.make("thread-1"), | ||
| 1600 | session: { | ||
| 1601 | threadId: ThreadId.make("thread-1"), | ||
| 1602 | status: "ready", | ||
| 1603 | providerName: "codex", | ||
| 1604 | runtimeMode: "approval-required", | ||
| 1605 | activeTurnId: null, | ||
| 1606 | lastError: null, | ||
| 1607 | updatedAt: now, | ||
| 1608 | }, | ||
| 1609 | createdAt: now, | ||
| 1610 | }), | ||
| 1611 | ); | ||
| 1612 | harness.runtimeSessions.push({ | ||
| 1613 | provider: ProviderDriverKind.make("codex"), | ||
| 1614 | status: "ready", | ||
| 1615 | runtimeMode: "approval-required", | ||
| 1616 | threadId: ThreadId.make("thread-1"), | ||
| 1617 | cwd: "/tmp/provider-project", | ||
| 1618 | resumeCursor: { opaque: "resume-without-instance" }, | ||
| 1619 | createdAt: now, | ||
| 1620 | updatedAt: now, | ||
| 1621 | }); | ||
| 1622 | |||
| 1623 | await Effect.runPromise( | ||
| 1624 | harness.engine.dispatch({ | ||
| 1625 | type: "thread.turn.start", | ||
| 1626 | commandId: CommandId.make("cmd-turn-start-missing-instance"), | ||
| 1627 | threadId: ThreadId.make("thread-1"), | ||
| 1628 | message: { | ||
| 1629 | messageId: asMessageId("user-message-missing-instance"), | ||
| 1630 | role: "user", | ||
| 1631 | text: "resume codex", | ||
| 1632 | attachments: [], | ||
| 1633 | }, | ||
| 1634 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1635 | runtimeMode: "approval-required", | ||
| 1636 | createdAt: now, | ||
| 1637 | }), | ||
| 1638 | ); | ||
| 1639 | |||
| 1640 | await waitFor(async () => { | ||
| 1641 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 1642 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 1643 | return ( | ||
| 1644 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | ||
| 1645 | false | ||
| 1646 | ); | ||
| 1647 | }); | ||
| 1648 | |||
| 1649 | expect(harness.startSession.mock.calls.length).toBe(0); | ||
| 1650 | expect(harness.sendTurn.mock.calls.length).toBe(0); | ||
| 1651 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 1652 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 1653 | expect( | ||
| 1654 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | ||
| 1655 | ).toMatchObject({ | ||
| 1656 | payload: { | ||
| 1657 | detail: expect.stringContaining("without a provider instance id"), | ||
| 1658 | }, | ||
| 1659 | }); | ||
| 1660 | }); | ||
| 1661 | |||
| 1362 | it("reacts to thread.approval.respond by forwarding provider approval response", async () => { | 1662 | it("reacts to thread.approval.respond by forwarding provider approval response", async () => { |
| 1363 | const harness = await createHarness(); | 1663 | const harness = await createHarness(); |
| 1364 | const now = new Date().toISOString(); | 1664 | const now = new Date().toISOString(); |
| ... | @@ -1451,7 +1751,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1451,7 +1751,7 @@ describe("ProviderCommandReactor", () => { |
| 1451 | harness.respondToRequest.mockImplementation(() => | 1751 | harness.respondToRequest.mockImplementation(() => |
| 1452 | Effect.fail( | 1752 | Effect.fail( |
| 1453 | new ProviderAdapterRequestError({ | 1753 | new ProviderAdapterRequestError({ |
| 1454 | provider: "codex", | 1754 | provider: ProviderDriverKind.make("codex"), |
| 1455 | method: "session/request_permission", | 1755 | method: "session/request_permission", |
| 1456 | detail: "Unknown pending permission request: approval-request-1", | 1756 | detail: "Unknown pending permission request: approval-request-1", |
| 1457 | }), | 1757 | }), |
| ... | @@ -1546,7 +1846,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1546,7 +1846,7 @@ describe("ProviderCommandReactor", () => { |
| 1546 | harness.respondToUserInput.mockImplementation(() => | 1846 | harness.respondToUserInput.mockImplementation(() => |
| 1547 | Effect.fail( | 1847 | Effect.fail( |
| 1548 | new ProviderAdapterRequestError({ | 1848 | new ProviderAdapterRequestError({ |
| 1549 | provider: "claudeAgent", | 1849 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1550 | method: "item/tool/respondToUserInput", | 1850 | method: "item/tool/respondToUserInput", |
| 1551 | detail: "Unknown pending user-input request: user-input-request-1", | 1851 | detail: "Unknown pending user-input request: user-input-request-1", |
| 1552 | }), | 1852 | }), |
| ... | @@ -1662,6 +1962,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1662,6 +1962,7 @@ describe("ProviderCommandReactor", () => { |
| 1662 | threadId: ThreadId.make("thread-1"), | 1962 | threadId: ThreadId.make("thread-1"), |
| 1663 | status: "ready", | 1963 | status: "ready", |
| 1664 | providerName: "codex", | 1964 | providerName: "codex", |
| 1965 | providerInstanceId: ProviderInstanceId.make("codex_work"), | ||
| 1665 | runtimeMode: "approval-required", | 1966 | runtimeMode: "approval-required", |
| 1666 | activeTurnId: null, | 1967 | activeTurnId: null, |
| 1667 | lastError: null, | 1968 | lastError: null, |
| ... | @@ -1686,6 +1987,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1686,6 +1987,7 @@ describe("ProviderCommandReactor", () => { |
| 1686 | expect(thread?.session).not.toBeNull(); | 1987 | expect(thread?.session).not.toBeNull(); |
| 1687 | expect(thread?.session?.status).toBe("stopped"); | 1988 | expect(thread?.session?.status).toBe("stopped"); |
| 1688 | expect(thread?.session?.threadId).toBe("thread-1"); | 1989 | expect(thread?.session?.threadId).toBe("thread-1"); |
| 1990 | expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); | ||
| 1689 | expect(thread?.session?.activeTurnId).toBeNull(); | 1991 | expect(thread?.session?.activeTurnId).toBeNull(); |
| 1690 | }); | 1992 | }); |
| 1691 | }); | 1993 | }); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+144-39| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | EventId, | 4 | EventId, |
| 5 | type ModelSelection, | 5 | type ModelSelection, |
| 6 | type OrchestrationEvent, | 6 | type OrchestrationEvent, |
| 7 | ProviderKind, | 7 | ProviderDriverKind, |
| 8 | type OrchestrationSession, | 8 | type OrchestrationSession, |
| 9 | ThreadId, | 9 | ThreadId, |
| 10 | type ProviderSession, | 10 | type ProviderSession, |
| ... | @@ -77,6 +77,21 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); | ... | @@ -77,6 +77,21 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 77 | const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 77 | const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 78 | const DEFAULT_THREAD_TITLE = "New thread"; | 78 | const DEFAULT_THREAD_TITLE = "New thread"; |
| 79 | 79 | ||
| 80 | export function providerErrorLabel(value: string | undefined): string { | ||
| 81 | const normalized = value?.trim(); | ||
| 82 | return normalized && normalized.length > 0 ? normalized : "unknown"; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function providerErrorLabelFromInstanceHint(input: { | ||
| 86 | readonly instanceId?: string | undefined; | ||
| 87 | readonly modelSelectionInstanceId?: string | undefined; | ||
| 88 | readonly sessionProvider?: string | undefined; | ||
| 89 | }): string { | ||
| 90 | return providerErrorLabel( | ||
| 91 | input.instanceId ?? input.modelSelectionInstanceId ?? input.sessionProvider, | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 80 | function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { | 95 | function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { |
| 81 | const trimmedCurrentTitle = currentTitle.trim(); | 96 | const trimmedCurrentTitle = currentTitle.trim(); |
| 82 | if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { | 97 | if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { |
| ... | @@ -271,42 +286,108 @@ const make = Effect.gen(function* () { | ... | @@ -271,42 +286,108 @@ const make = Effect.gen(function* () { |
| 271 | } | 286 | } |
| 272 | 287 | ||
| 273 | const desiredRuntimeMode = thread.runtimeMode; | 288 | const desiredRuntimeMode = thread.runtimeMode; |
| 274 | const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)( | ||
| 275 | thread.session?.providerName, | ||
| 276 | ) | ||
| 277 | ? thread.session.providerName | ||
| 278 | : undefined; | ||
| 279 | const requestedModelSelection = options?.modelSelection; | 289 | const requestedModelSelection = options?.modelSelection; |
| 280 | const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider; | 290 | const resolveActiveSession = (threadId: ThreadId) => |
| 291 | providerService | ||
| 292 | .listSessions() | ||
| 293 | .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); | ||
| 294 | |||
| 295 | const activeSession = yield* resolveActiveSession(threadId); | ||
| 296 | const activeThreadSession = | ||
| 297 | thread.session !== null && thread.session.status !== "stopped" && activeSession | ||
| 298 | ? thread.session | ||
| 299 | : null; | ||
| 281 | if ( | 300 | if ( |
| 282 | requestedModelSelection !== undefined && | 301 | activeThreadSession !== null && |
| 283 | requestedModelSelection.provider !== threadProvider | 302 | activeSession !== undefined && |
| 303 | (activeThreadSession.providerInstanceId === undefined || | ||
| 304 | activeSession.providerInstanceId === undefined) | ||
| 284 | ) { | 305 | ) { |
| 285 | return yield* new ProviderAdapterRequestError({ | 306 | return yield* new ProviderAdapterRequestError({ |
| 286 | provider: threadProvider, | 307 | provider: providerErrorLabel(activeThreadSession.providerName ?? undefined), |
| 287 | method: "thread.turn.start", | 308 | method: "thread.turn.start", |
| 288 | detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, | 309 | detail: `Thread '${threadId}' has an active provider session without a provider instance id.`, |
| 289 | }); | 310 | }); |
| 290 | } | 311 | } |
| 291 | const preferredProvider: ProviderKind = threadProvider; | 312 | const currentInstanceId = |
| 313 | activeThreadSession !== null && | ||
| 314 | activeSession !== undefined && | ||
| 315 | activeSession.providerInstanceId !== undefined | ||
| 316 | ? activeSession.providerInstanceId | ||
| 317 | : thread.modelSelection.instanceId; | ||
| 292 | const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; | 318 | const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; |
| 319 | const desiredInstanceId = desiredModelSelection.instanceId; | ||
| 320 | const currentInfo = yield* providerService.getInstanceInfo(currentInstanceId).pipe( | ||
| 321 | Effect.mapError( | ||
| 322 | () => | ||
| 323 | new ProviderAdapterRequestError({ | ||
| 324 | provider: providerErrorLabelFromInstanceHint({ | ||
| 325 | instanceId: String(currentInstanceId), | ||
| 326 | modelSelectionInstanceId: String(thread.modelSelection.instanceId), | ||
| 327 | sessionProvider: thread.session?.providerName ?? undefined, | ||
| 328 | }), | ||
| 329 | method: "thread.turn.start", | ||
| 330 | detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, | ||
| 331 | }), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | const desiredInfo = yield* providerService.getInstanceInfo(desiredInstanceId).pipe( | ||
| 335 | Effect.mapError( | ||
| 336 | () => | ||
| 337 | new ProviderAdapterRequestError({ | ||
| 338 | provider: providerErrorLabelFromInstanceHint({ | ||
| 339 | instanceId: String(desiredModelSelection.instanceId), | ||
| 340 | }), | ||
| 341 | method: "thread.turn.start", | ||
| 342 | detail: `Requested provider instance '${desiredInstanceId}' is not configured in this build.`, | ||
| 343 | }), | ||
| 344 | ), | ||
| 345 | ); | ||
| 346 | const desiredDriverKind = desiredInfo.driverKind; | ||
| 347 | if (!Schema.is(ProviderDriverKind)(desiredDriverKind)) { | ||
| 348 | return yield* new ProviderAdapterRequestError({ | ||
| 349 | provider: providerErrorLabel(String(desiredDriverKind)), | ||
| 350 | method: "thread.turn.start", | ||
| 351 | detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, | ||
| 352 | }); | ||
| 353 | } | ||
| 354 | const preferredProvider: ProviderDriverKind = desiredDriverKind; | ||
| 355 | if ( | ||
| 356 | thread.session !== null && | ||
| 357 | requestedModelSelection !== undefined && | ||
| 358 | requestedModelSelection.instanceId !== currentInstanceId | ||
| 359 | ) { | ||
| 360 | if (currentInfo.driverKind !== desiredInfo.driverKind) { | ||
| 361 | return yield* new ProviderAdapterRequestError({ | ||
| 362 | provider: preferredProvider, | ||
| 363 | method: "thread.turn.start", | ||
| 364 | detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'.`, | ||
| 365 | }); | ||
| 366 | } | ||
| 367 | if ( | ||
| 368 | currentInfo.continuationIdentity.continuationKey !== | ||
| 369 | desiredInfo.continuationIdentity.continuationKey | ||
| 370 | ) { | ||
| 371 | return yield* new ProviderAdapterRequestError({ | ||
| 372 | provider: preferredProvider, | ||
| 373 | method: "thread.turn.start", | ||
| 374 | detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because their provider resume state is incompatible.`, | ||
| 375 | }); | ||
| 376 | } | ||
| 377 | } | ||
| 293 | const effectiveCwd = resolveThreadWorkspaceCwd({ | 378 | const effectiveCwd = resolveThreadWorkspaceCwd({ |
| 294 | thread, | 379 | thread, |
| 295 | projects: readModel.projects, | 380 | projects: readModel.projects, |
| 296 | }); | 381 | }); |
| 297 | 382 | ||
| 298 | const resolveActiveSession = (threadId: ThreadId) => | ||
| 299 | providerService | ||
| 300 | .listSessions() | ||
| 301 | .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); | ||
| 302 | |||
| 303 | const startProviderSession = (input?: { | 383 | const startProviderSession = (input?: { |
| 304 | readonly resumeCursor?: unknown; | 384 | readonly resumeCursor?: unknown; |
| 305 | readonly provider?: ProviderKind; | 385 | readonly provider?: ProviderDriverKind; |
| 306 | }) => | 386 | }) => |
| 307 | providerService.startSession(threadId, { | 387 | providerService.startSession(threadId, { |
| 308 | threadId, | 388 | threadId, |
| 309 | ...(preferredProvider ? { provider: preferredProvider } : {}), | 389 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 390 | providerInstanceId: desiredInstanceId, | ||
| 310 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 391 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 311 | modelSelection: desiredModelSelection, | 392 | modelSelection: desiredModelSelection, |
| 312 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | 393 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| ... | @@ -314,44 +395,55 @@ const make = Effect.gen(function* () { | ... | @@ -314,44 +395,55 @@ const make = Effect.gen(function* () { |
| 314 | }); | 395 | }); |
| 315 | 396 | ||
| 316 | const bindSessionToThread = (session: ProviderSession) => | 397 | const bindSessionToThread = (session: ProviderSession) => |
| 317 | setThreadSession({ | 398 | Effect.gen(function* () { |
| 318 | threadId, | 399 | if (session.providerInstanceId === undefined) { |
| 319 | session: { | 400 | return yield* new ProviderAdapterRequestError({ |
| 401 | provider: providerErrorLabel(session.provider), | ||
| 402 | method: "thread.turn.start", | ||
| 403 | detail: `Provider session '${session.threadId}' started without a provider instance id.`, | ||
| 404 | }); | ||
| 405 | } | ||
| 406 | yield* setThreadSession({ | ||
| 320 | threadId, | 407 | threadId, |
| 321 | status: mapProviderSessionStatusToOrchestrationStatus(session.status), | 408 | session: { |
| 322 | providerName: session.provider, | 409 | threadId, |
| 323 | runtimeMode: desiredRuntimeMode, | 410 | status: mapProviderSessionStatusToOrchestrationStatus(session.status), |
| 324 | // Provider turn ids are not orchestration turn ids. | 411 | providerName: session.provider, |
| 325 | activeTurnId: null, | 412 | providerInstanceId: session.providerInstanceId, |
| 326 | lastError: session.lastError ?? null, | 413 | runtimeMode: desiredRuntimeMode, |
| 327 | updatedAt: session.updatedAt, | 414 | // Provider turn ids are not orchestration turn ids. |
| 328 | }, | 415 | activeTurnId: null, |
| 329 | createdAt, | 416 | lastError: session.lastError ?? null, |
| 417 | updatedAt: session.updatedAt, | ||
| 418 | }, | ||
| 419 | createdAt, | ||
| 420 | }); | ||
| 330 | }); | 421 | }); |
| 331 | 422 | ||
| 332 | const activeSession = yield* resolveActiveSession(threadId); | ||
| 333 | const existingSessionThreadId = | 423 | const existingSessionThreadId = |
| 334 | thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; | 424 | thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; |
| 335 | if (existingSessionThreadId) { | 425 | if (existingSessionThreadId) { |
| 336 | const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; | 426 | const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; |
| 337 | const cwdChanged = effectiveCwd !== activeSession?.cwd; | 427 | const cwdChanged = effectiveCwd !== activeSession?.cwd; |
| 338 | const sessionModelSwitch = | 428 | const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) |
| 339 | currentProvider === undefined | 429 | .sessionModelSwitch; |
| 340 | ? "in-session" | ||
| 341 | : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; | ||
| 342 | const modelChanged = | 430 | const modelChanged = |
| 343 | requestedModelSelection !== undefined && | 431 | requestedModelSelection !== undefined && |
| 344 | requestedModelSelection.model !== activeSession?.model; | 432 | requestedModelSelection.model !== activeSession?.model; |
| 433 | const instanceChanged = | ||
| 434 | requestedModelSelection !== undefined && | ||
| 435 | activeSession?.providerInstanceId !== requestedModelSelection.instanceId; | ||
| 345 | const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; | 436 | const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; |
| 346 | const previousModelSelection = threadModelSelections.get(threadId); | 437 | const previousModelSelection = threadModelSelections.get(threadId); |
| 347 | const shouldRestartForModelSelectionChange = | 438 | const shouldRestartForModelSelectionChange = |
| 348 | currentProvider === "claudeAgent" && | 439 | preferredProvider === "claudeAgent" && |
| 349 | requestedModelSelection !== undefined && | 440 | requestedModelSelection !== undefined && |
| 350 | !Equal.equals(previousModelSelection, requestedModelSelection); | 441 | !Equal.equals(previousModelSelection, requestedModelSelection); |
| 351 | 442 | ||
| 352 | if ( | 443 | if ( |
| 353 | !runtimeModeChanged && | 444 | !runtimeModeChanged && |
| 354 | !cwdChanged && | 445 | !cwdChanged && |
| 446 | !instanceChanged && | ||
| 355 | !shouldRestartForModelChange && | 447 | !shouldRestartForModelChange && |
| 356 | !shouldRestartForModelSelectionChange | 448 | !shouldRestartForModelSelectionChange |
| 357 | ) { | 449 | ) { |
| ... | @@ -364,8 +456,10 @@ const make = Effect.gen(function* () { | ... | @@ -364,8 +456,10 @@ const make = Effect.gen(function* () { |
| 364 | yield* Effect.logInfo("provider command reactor restarting provider session", { | 456 | yield* Effect.logInfo("provider command reactor restarting provider session", { |
| 365 | threadId, | 457 | threadId, |
| 366 | existingSessionThreadId, | 458 | existingSessionThreadId, |
| 367 | currentProvider, | 459 | currentProvider: activeSession?.provider, |
| 368 | desiredProvider: desiredModelSelection.provider, | 460 | currentInstanceId, |
| 461 | desiredInstanceId, | ||
| 462 | desiredProvider: desiredModelSelection.instanceId, | ||
| 369 | currentRuntimeMode: thread.session?.runtimeMode, | 463 | currentRuntimeMode: thread.session?.runtimeMode, |
| 370 | desiredRuntimeMode: thread.runtimeMode, | 464 | desiredRuntimeMode: thread.runtimeMode, |
| 371 | runtimeModeChanged, | 465 | runtimeModeChanged, |
| ... | @@ -373,6 +467,7 @@ const make = Effect.gen(function* () { | ... | @@ -373,6 +467,7 @@ const make = Effect.gen(function* () { |
| 373 | desiredCwd: effectiveCwd, | 467 | desiredCwd: effectiveCwd, |
| 374 | cwdChanged, | 468 | cwdChanged, |
| 375 | modelChanged, | 469 | modelChanged, |
| 470 | instanceChanged, | ||
| 376 | shouldRestartForModelChange, | 471 | shouldRestartForModelChange, |
| 377 | shouldRestartForModelSelectionChange, | 472 | shouldRestartForModelSelectionChange, |
| 378 | hasResumeCursor: resumeCursor !== undefined, | 473 | hasResumeCursor: resumeCursor !== undefined, |
| ... | @@ -429,7 +524,14 @@ const make = Effect.gen(function* () { | ... | @@ -429,7 +524,14 @@ const make = Effect.gen(function* () { |
| 429 | const sessionModelSwitch = | 524 | const sessionModelSwitch = |
| 430 | activeSession === undefined | 525 | activeSession === undefined |
| 431 | ? "in-session" | 526 | ? "in-session" |
| 432 | : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; | 527 | : activeSession.providerInstanceId === undefined |
| 528 | ? yield* new ProviderAdapterRequestError({ | ||
| 529 | provider: providerErrorLabel(activeSession.provider), | ||
| 530 | method: "thread.turn.start", | ||
| 531 | detail: `Active provider session '${activeSession.threadId}' is missing a provider instance id.`, | ||
| 532 | }) | ||
| 533 | : (yield* providerService.getCapabilities(activeSession.providerInstanceId)) | ||
| 534 | .sessionModelSwitch; | ||
| 433 | const requestedModelSelection = | 535 | const requestedModelSelection = |
| 434 | input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; | 536 | input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; |
| 435 | const modelForTurn = | 537 | const modelForTurn = |
| ... | @@ -800,6 +902,9 @@ const make = Effect.gen(function* () { | ... | @@ -800,6 +902,9 @@ const make = Effect.gen(function* () { |
| 800 | threadId: thread.id, | 902 | threadId: thread.id, |
| 801 | status: "stopped", | 903 | status: "stopped", |
| 802 | providerName: thread.session?.providerName ?? null, | 904 | providerName: thread.session?.providerName ?? null, |
| 905 | ...(thread.session?.providerInstanceId !== undefined | ||
| 906 | ? { providerInstanceId: thread.session.providerInstanceId } | ||
| 907 | : {}), | ||
| 803 | runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, | 908 | runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 804 | activeTurnId: null, | 909 | activeTurnId: null, |
| 805 | lastError: thread.session?.lastError ?? null, | 910 | lastError: thread.session?.lastError ?? null, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+122-107| ... | @@ -2,10 +2,12 @@ import fs from "node:fs"; | ... | @@ -2,10 +2,12 @@ import fs from "node:fs"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import type { | 5 | import { |
| 6 | OrchestrationReadModel, | 6 | OrchestrationReadModel, |
| 7 | ProviderDriverKind, | ||
| 7 | ProviderRuntimeEvent, | 8 | ProviderRuntimeEvent, |
| 8 | ProviderSession, | 9 | ProviderSession, |
| 10 | ProviderInstanceId, | ||
| 9 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 10 | import { | 12 | import { |
| 11 | ApprovalRequestId, | 13 | ApprovalRequestId, |
| ... | @@ -98,6 +100,19 @@ function createProviderServiceHarness() { | ... | @@ -98,6 +100,19 @@ function createProviderServiceHarness() { |
| 98 | stopSession: () => unsupported(), | 100 | stopSession: () => unsupported(), |
| 99 | listSessions: () => Effect.succeed([...runtimeSessions]), | 101 | listSessions: () => Effect.succeed([...runtimeSessions]), |
| 100 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | 102 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| 103 | getInstanceInfo: (instanceId) => { | ||
| 104 | const driverKind = ProviderDriverKind.make(String(instanceId)); | ||
| 105 | return Effect.succeed({ | ||
| 106 | instanceId, | ||
| 107 | driverKind, | ||
| 108 | displayName: undefined, | ||
| 109 | enabled: true, | ||
| 110 | continuationIdentity: { | ||
| 111 | driverKind, | ||
| 112 | continuationKey: `${driverKind}:instance:${instanceId}`, | ||
| 113 | }, | ||
| 114 | }); | ||
| 115 | }, | ||
| 101 | rollbackConversation: () => unsupported(), | 116 | rollbackConversation: () => unsupported(), |
| 102 | get streamEvents() { | 117 | get streamEvents() { |
| 103 | return Stream.fromPubSub(runtimeEventPubSub); | 118 | return Stream.fromPubSub(runtimeEventPubSub); |
| ... | @@ -232,7 +247,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -232,7 +247,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 232 | title: "Provider Project", | 247 | title: "Provider Project", |
| 233 | workspaceRoot, | 248 | workspaceRoot, |
| 234 | defaultModelSelection: { | 249 | defaultModelSelection: { |
| 235 | provider: "codex", | 250 | instanceId: ProviderInstanceId.make("codex"), |
| 236 | model: "gpt-5-codex", | 251 | model: "gpt-5-codex", |
| 237 | }, | 252 | }, |
| 238 | createdAt, | 253 | createdAt, |
| ... | @@ -246,7 +261,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -246,7 +261,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 246 | projectId: asProjectId("project-1"), | 261 | projectId: asProjectId("project-1"), |
| 247 | title: "Thread", | 262 | title: "Thread", |
| 248 | modelSelection: { | 263 | modelSelection: { |
| 249 | provider: "codex", | 264 | instanceId: ProviderInstanceId.make("codex"), |
| 250 | model: "gpt-5-codex", | 265 | model: "gpt-5-codex", |
| 251 | }, | 266 | }, |
| 252 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 267 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -274,7 +289,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -274,7 +289,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 274 | }), | 289 | }), |
| 275 | ); | 290 | ); |
| 276 | provider.setSession({ | 291 | provider.setSession({ |
| 277 | provider: "codex", | 292 | provider: ProviderDriverKind.make("codex"), |
| 278 | status: "ready", | 293 | status: "ready", |
| 279 | runtimeMode: "approval-required", | 294 | runtimeMode: "approval-required", |
| 280 | threadId: ThreadId.make("thread-1"), | 295 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -297,7 +312,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -297,7 +312,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 297 | harness.emit({ | 312 | harness.emit({ |
| 298 | type: "turn.started", | 313 | type: "turn.started", |
| 299 | eventId: asEventId("evt-turn-started"), | 314 | eventId: asEventId("evt-turn-started"), |
| 300 | provider: "codex", | 315 | provider: ProviderDriverKind.make("codex"), |
| 301 | threadId: asThreadId("thread-1"), | 316 | threadId: asThreadId("thread-1"), |
| 302 | createdAt: now, | 317 | createdAt: now, |
| 303 | turnId: asTurnId("turn-1"), | 318 | turnId: asTurnId("turn-1"), |
| ... | @@ -311,7 +326,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -311,7 +326,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 311 | harness.emit({ | 326 | harness.emit({ |
| 312 | type: "turn.completed", | 327 | type: "turn.completed", |
| 313 | eventId: asEventId("evt-turn-completed"), | 328 | eventId: asEventId("evt-turn-completed"), |
| 314 | provider: "codex", | 329 | provider: ProviderDriverKind.make("codex"), |
| 315 | threadId: asThreadId("thread-1"), | 330 | threadId: asThreadId("thread-1"), |
| 316 | createdAt: new Date().toISOString(), | 331 | createdAt: new Date().toISOString(), |
| 317 | turnId: asTurnId("turn-1"), | 332 | turnId: asTurnId("turn-1"), |
| ... | @@ -339,7 +354,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -339,7 +354,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 339 | harness.emit({ | 354 | harness.emit({ |
| 340 | type: "session.state.changed", | 355 | type: "session.state.changed", |
| 341 | eventId: asEventId("evt-session-state-waiting"), | 356 | eventId: asEventId("evt-session-state-waiting"), |
| 342 | provider: "codex", | 357 | provider: ProviderDriverKind.make("codex"), |
| 343 | threadId: asThreadId("thread-1"), | 358 | threadId: asThreadId("thread-1"), |
| 344 | createdAt: waitingAt, | 359 | createdAt: waitingAt, |
| 345 | payload: { | 360 | payload: { |
| ... | @@ -358,7 +373,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -358,7 +373,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 358 | harness.emit({ | 373 | harness.emit({ |
| 359 | type: "session.state.changed", | 374 | type: "session.state.changed", |
| 360 | eventId: asEventId("evt-session-state-error"), | 375 | eventId: asEventId("evt-session-state-error"), |
| 361 | provider: "codex", | 376 | provider: ProviderDriverKind.make("codex"), |
| 362 | threadId: asThreadId("thread-1"), | 377 | threadId: asThreadId("thread-1"), |
| 363 | createdAt: new Date().toISOString(), | 378 | createdAt: new Date().toISOString(), |
| 364 | payload: { | 379 | payload: { |
| ... | @@ -380,7 +395,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -380,7 +395,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 380 | harness.emit({ | 395 | harness.emit({ |
| 381 | type: "session.state.changed", | 396 | type: "session.state.changed", |
| 382 | eventId: asEventId("evt-session-state-stopped"), | 397 | eventId: asEventId("evt-session-state-stopped"), |
| 383 | provider: "codex", | 398 | provider: ProviderDriverKind.make("codex"), |
| 384 | threadId: asThreadId("thread-1"), | 399 | threadId: asThreadId("thread-1"), |
| 385 | createdAt: new Date().toISOString(), | 400 | createdAt: new Date().toISOString(), |
| 386 | payload: { | 401 | payload: { |
| ... | @@ -401,7 +416,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -401,7 +416,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 401 | harness.emit({ | 416 | harness.emit({ |
| 402 | type: "session.state.changed", | 417 | type: "session.state.changed", |
| 403 | eventId: asEventId("evt-session-state-ready"), | 418 | eventId: asEventId("evt-session-state-ready"), |
| 404 | provider: "codex", | 419 | provider: ProviderDriverKind.make("codex"), |
| 405 | threadId: asThreadId("thread-1"), | 420 | threadId: asThreadId("thread-1"), |
| 406 | createdAt: new Date().toISOString(), | 421 | createdAt: new Date().toISOString(), |
| 407 | payload: { | 422 | payload: { |
| ... | @@ -427,7 +442,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -427,7 +442,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 427 | harness.emit({ | 442 | harness.emit({ |
| 428 | type: "turn.started", | 443 | type: "turn.started", |
| 429 | eventId: asEventId("evt-turn-started-midturn-lifecycle"), | 444 | eventId: asEventId("evt-turn-started-midturn-lifecycle"), |
| 430 | provider: "codex", | 445 | provider: ProviderDriverKind.make("codex"), |
| 431 | createdAt: now, | 446 | createdAt: now, |
| 432 | threadId: asThreadId("thread-1"), | 447 | threadId: asThreadId("thread-1"), |
| 433 | turnId: asTurnId("turn-midturn-lifecycle"), | 448 | turnId: asTurnId("turn-midturn-lifecycle"), |
| ... | @@ -443,14 +458,14 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -443,14 +458,14 @@ describe("ProviderRuntimeIngestion", () => { |
| 443 | harness.emit({ | 458 | harness.emit({ |
| 444 | type: "thread.started", | 459 | type: "thread.started", |
| 445 | eventId: asEventId("evt-thread-started-midturn-lifecycle"), | 460 | eventId: asEventId("evt-thread-started-midturn-lifecycle"), |
| 446 | provider: "codex", | 461 | provider: ProviderDriverKind.make("codex"), |
| 447 | createdAt: new Date().toISOString(), | 462 | createdAt: new Date().toISOString(), |
| 448 | threadId: asThreadId("thread-1"), | 463 | threadId: asThreadId("thread-1"), |
| 449 | }); | 464 | }); |
| 450 | harness.emit({ | 465 | harness.emit({ |
| 451 | type: "session.started", | 466 | type: "session.started", |
| 452 | eventId: asEventId("evt-session-started-midturn-lifecycle"), | 467 | eventId: asEventId("evt-session-started-midturn-lifecycle"), |
| 453 | provider: "codex", | 468 | provider: ProviderDriverKind.make("codex"), |
| 454 | createdAt: new Date().toISOString(), | 469 | createdAt: new Date().toISOString(), |
| 455 | threadId: asThreadId("thread-1"), | 470 | threadId: asThreadId("thread-1"), |
| 456 | }); | 471 | }); |
| ... | @@ -464,7 +479,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -464,7 +479,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 464 | harness.emit({ | 479 | harness.emit({ |
| 465 | type: "turn.completed", | 480 | type: "turn.completed", |
| 466 | eventId: asEventId("evt-turn-completed-midturn-lifecycle"), | 481 | eventId: asEventId("evt-turn-completed-midturn-lifecycle"), |
| 467 | provider: "codex", | 482 | provider: ProviderDriverKind.make("codex"), |
| 468 | createdAt: new Date().toISOString(), | 483 | createdAt: new Date().toISOString(), |
| 469 | threadId: asThreadId("thread-1"), | 484 | threadId: asThreadId("thread-1"), |
| 470 | turnId: asTurnId("turn-midturn-lifecycle"), | 485 | turnId: asTurnId("turn-midturn-lifecycle"), |
| ... | @@ -502,7 +517,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -502,7 +517,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 502 | harness.emit({ | 517 | harness.emit({ |
| 503 | type: "turn.started", | 518 | type: "turn.started", |
| 504 | eventId: asEventId("evt-turn-started-claude-placeholder"), | 519 | eventId: asEventId("evt-turn-started-claude-placeholder"), |
| 505 | provider: "claudeAgent", | 520 | provider: ProviderDriverKind.make("claudeAgent"), |
| 506 | createdAt: new Date().toISOString(), | 521 | createdAt: new Date().toISOString(), |
| 507 | threadId: asThreadId("thread-1"), | 522 | threadId: asThreadId("thread-1"), |
| 508 | turnId: asTurnId("turn-claude-placeholder"), | 523 | turnId: asTurnId("turn-claude-placeholder"), |
| ... | @@ -518,7 +533,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -518,7 +533,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 518 | harness.emit({ | 533 | harness.emit({ |
| 519 | type: "turn.completed", | 534 | type: "turn.completed", |
| 520 | eventId: asEventId("evt-turn-completed-claude-placeholder"), | 535 | eventId: asEventId("evt-turn-completed-claude-placeholder"), |
| 521 | provider: "claudeAgent", | 536 | provider: ProviderDriverKind.make("claudeAgent"), |
| 522 | createdAt: new Date().toISOString(), | 537 | createdAt: new Date().toISOString(), |
| 523 | threadId: asThreadId("thread-1"), | 538 | threadId: asThreadId("thread-1"), |
| 524 | turnId: asTurnId("turn-claude-placeholder"), | 539 | turnId: asTurnId("turn-claude-placeholder"), |
| ... | @@ -538,7 +553,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -538,7 +553,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 538 | harness.emit({ | 553 | harness.emit({ |
| 539 | type: "turn.started", | 554 | type: "turn.started", |
| 540 | eventId: asEventId("evt-turn-started-primary"), | 555 | eventId: asEventId("evt-turn-started-primary"), |
| 541 | provider: "codex", | 556 | provider: ProviderDriverKind.make("codex"), |
| 542 | createdAt: now, | 557 | createdAt: now, |
| 543 | threadId: asThreadId("thread-1"), | 558 | threadId: asThreadId("thread-1"), |
| 544 | turnId: asTurnId("turn-primary"), | 559 | turnId: asTurnId("turn-primary"), |
| ... | @@ -553,7 +568,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -553,7 +568,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 553 | harness.emit({ | 568 | harness.emit({ |
| 554 | type: "turn.completed", | 569 | type: "turn.completed", |
| 555 | eventId: asEventId("evt-turn-completed-aux"), | 570 | eventId: asEventId("evt-turn-completed-aux"), |
| 556 | provider: "codex", | 571 | provider: ProviderDriverKind.make("codex"), |
| 557 | createdAt: new Date().toISOString(), | 572 | createdAt: new Date().toISOString(), |
| 558 | threadId: asThreadId("thread-1"), | 573 | threadId: asThreadId("thread-1"), |
| 559 | turnId: asTurnId("turn-aux"), | 574 | turnId: asTurnId("turn-aux"), |
| ... | @@ -569,7 +584,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -569,7 +584,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 569 | harness.emit({ | 584 | harness.emit({ |
| 570 | type: "turn.completed", | 585 | type: "turn.completed", |
| 571 | eventId: asEventId("evt-turn-completed-primary"), | 586 | eventId: asEventId("evt-turn-completed-primary"), |
| 572 | provider: "codex", | 587 | provider: ProviderDriverKind.make("codex"), |
| 573 | createdAt: new Date().toISOString(), | 588 | createdAt: new Date().toISOString(), |
| 574 | threadId: asThreadId("thread-1"), | 589 | threadId: asThreadId("thread-1"), |
| 575 | turnId: asTurnId("turn-primary"), | 590 | turnId: asTurnId("turn-primary"), |
| ... | @@ -589,7 +604,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -589,7 +604,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 589 | harness.emit({ | 604 | harness.emit({ |
| 590 | type: "turn.started", | 605 | type: "turn.started", |
| 591 | eventId: asEventId("evt-turn-started-guarded"), | 606 | eventId: asEventId("evt-turn-started-guarded"), |
| 592 | provider: "codex", | 607 | provider: ProviderDriverKind.make("codex"), |
| 593 | createdAt: now, | 608 | createdAt: now, |
| 594 | threadId: asThreadId("thread-1"), | 609 | threadId: asThreadId("thread-1"), |
| 595 | turnId: asTurnId("turn-guarded-main"), | 610 | turnId: asTurnId("turn-guarded-main"), |
| ... | @@ -605,7 +620,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -605,7 +620,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 605 | harness.emit({ | 620 | harness.emit({ |
| 606 | type: "turn.completed", | 621 | type: "turn.completed", |
| 607 | eventId: asEventId("evt-turn-completed-guarded-other"), | 622 | eventId: asEventId("evt-turn-completed-guarded-other"), |
| 608 | provider: "codex", | 623 | provider: ProviderDriverKind.make("codex"), |
| 609 | createdAt: new Date().toISOString(), | 624 | createdAt: new Date().toISOString(), |
| 610 | threadId: asThreadId("thread-1"), | 625 | threadId: asThreadId("thread-1"), |
| 611 | turnId: asTurnId("turn-guarded-other"), | 626 | turnId: asTurnId("turn-guarded-other"), |
| ... | @@ -621,7 +636,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -621,7 +636,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 621 | harness.emit({ | 636 | harness.emit({ |
| 622 | type: "turn.completed", | 637 | type: "turn.completed", |
| 623 | eventId: asEventId("evt-turn-completed-guarded-main"), | 638 | eventId: asEventId("evt-turn-completed-guarded-main"), |
| 624 | provider: "codex", | 639 | provider: ProviderDriverKind.make("codex"), |
| 625 | createdAt: new Date().toISOString(), | 640 | createdAt: new Date().toISOString(), |
| 626 | threadId: asThreadId("thread-1"), | 641 | threadId: asThreadId("thread-1"), |
| 627 | turnId: asTurnId("turn-guarded-main"), | 642 | turnId: asTurnId("turn-guarded-main"), |
| ... | @@ -641,7 +656,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -641,7 +656,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 641 | harness.emit({ | 656 | harness.emit({ |
| 642 | type: "content.delta", | 657 | type: "content.delta", |
| 643 | eventId: asEventId("evt-message-delta-1"), | 658 | eventId: asEventId("evt-message-delta-1"), |
| 644 | provider: "codex", | 659 | provider: ProviderDriverKind.make("codex"), |
| 645 | createdAt: now, | 660 | createdAt: now, |
| 646 | threadId: asThreadId("thread-1"), | 661 | threadId: asThreadId("thread-1"), |
| 647 | turnId: asTurnId("turn-2"), | 662 | turnId: asTurnId("turn-2"), |
| ... | @@ -654,7 +669,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -654,7 +669,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 654 | harness.emit({ | 669 | harness.emit({ |
| 655 | type: "content.delta", | 670 | type: "content.delta", |
| 656 | eventId: asEventId("evt-message-delta-2"), | 671 | eventId: asEventId("evt-message-delta-2"), |
| 657 | provider: "codex", | 672 | provider: ProviderDriverKind.make("codex"), |
| 658 | createdAt: now, | 673 | createdAt: now, |
| 659 | threadId: asThreadId("thread-1"), | 674 | threadId: asThreadId("thread-1"), |
| 660 | turnId: asTurnId("turn-2"), | 675 | turnId: asTurnId("turn-2"), |
| ... | @@ -667,7 +682,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -667,7 +682,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 667 | harness.emit({ | 682 | harness.emit({ |
| 668 | type: "item.completed", | 683 | type: "item.completed", |
| 669 | eventId: asEventId("evt-message-completed"), | 684 | eventId: asEventId("evt-message-completed"), |
| 670 | provider: "codex", | 685 | provider: ProviderDriverKind.make("codex"), |
| 671 | createdAt: now, | 686 | createdAt: now, |
| 672 | threadId: asThreadId("thread-1"), | 687 | threadId: asThreadId("thread-1"), |
| 673 | turnId: asTurnId("turn-2"), | 688 | turnId: asTurnId("turn-2"), |
| ... | @@ -698,7 +713,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -698,7 +713,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 698 | harness.emit({ | 713 | harness.emit({ |
| 699 | type: "item.completed", | 714 | type: "item.completed", |
| 700 | eventId: asEventId("evt-assistant-item-completed-no-delta"), | 715 | eventId: asEventId("evt-assistant-item-completed-no-delta"), |
| 701 | provider: "codex", | 716 | provider: ProviderDriverKind.make("codex"), |
| 702 | createdAt: now, | 717 | createdAt: now, |
| 703 | threadId: asThreadId("thread-1"), | 718 | threadId: asThreadId("thread-1"), |
| 704 | turnId: asTurnId("turn-no-delta"), | 719 | turnId: asTurnId("turn-no-delta"), |
| ... | @@ -730,7 +745,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -730,7 +745,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 730 | harness.emit({ | 745 | harness.emit({ |
| 731 | type: "item.completed", | 746 | type: "item.completed", |
| 732 | eventId: asEventId("evt-tool-completed-with-data"), | 747 | eventId: asEventId("evt-tool-completed-with-data"), |
| 733 | provider: "cursor", | 748 | provider: ProviderDriverKind.make("cursor"), |
| 734 | createdAt: now, | 749 | createdAt: now, |
| 735 | threadId: asThreadId("thread-1"), | 750 | threadId: asThreadId("thread-1"), |
| 736 | turnId: asTurnId("turn-tool-completed"), | 751 | turnId: asTurnId("turn-tool-completed"), |
| ... | @@ -786,7 +801,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -786,7 +801,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 786 | harness.emit({ | 801 | harness.emit({ |
| 787 | type: "item.completed", | 802 | type: "item.completed", |
| 788 | eventId: asEventId("evt-command-completed"), | 803 | eventId: asEventId("evt-command-completed"), |
| 789 | provider: "cursor", | 804 | provider: ProviderDriverKind.make("cursor"), |
| 790 | createdAt: now, | 805 | createdAt: now, |
| 791 | threadId: asThreadId("thread-1"), | 806 | threadId: asThreadId("thread-1"), |
| 792 | turnId: asTurnId("turn-command-completed"), | 807 | turnId: asTurnId("turn-command-completed"), |
| ... | @@ -828,7 +843,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -828,7 +843,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 828 | harness.emit({ | 843 | harness.emit({ |
| 829 | type: "item.completed", | 844 | type: "item.completed", |
| 830 | eventId: asEventId("evt-read-path-completed"), | 845 | eventId: asEventId("evt-read-path-completed"), |
| 831 | provider: "cursor", | 846 | provider: ProviderDriverKind.make("cursor"), |
| 832 | createdAt: now, | 847 | createdAt: now, |
| 833 | threadId: asThreadId("thread-1"), | 848 | threadId: asThreadId("thread-1"), |
| 834 | turnId: asTurnId("turn-read-path"), | 849 | turnId: asTurnId("turn-read-path"), |
| ... | @@ -870,7 +885,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -870,7 +885,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 870 | harness.emit({ | 885 | harness.emit({ |
| 871 | type: "turn.proposed.completed", | 886 | type: "turn.proposed.completed", |
| 872 | eventId: asEventId("evt-plan-item-completed"), | 887 | eventId: asEventId("evt-plan-item-completed"), |
| 873 | provider: "codex", | 888 | provider: ProviderDriverKind.make("codex"), |
| 874 | createdAt: now, | 889 | createdAt: now, |
| 875 | threadId: asThreadId("thread-1"), | 890 | threadId: asThreadId("thread-1"), |
| 876 | turnId: asTurnId("turn-plan-final"), | 891 | turnId: asTurnId("turn-plan-final"), |
| ... | @@ -909,7 +924,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -909,7 +924,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 909 | projectId: asProjectId("project-1"), | 924 | projectId: asProjectId("project-1"), |
| 910 | title: "Plan Source", | 925 | title: "Plan Source", |
| 911 | modelSelection: { | 926 | modelSelection: { |
| 912 | provider: "codex", | 927 | instanceId: ProviderInstanceId.make("codex"), |
| 913 | model: "gpt-5-codex", | 928 | model: "gpt-5-codex", |
| 914 | }, | 929 | }, |
| 915 | interactionMode: "plan", | 930 | interactionMode: "plan", |
| ... | @@ -944,7 +959,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -944,7 +959,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 944 | projectId: asProjectId("project-1"), | 959 | projectId: asProjectId("project-1"), |
| 945 | title: "Plan Target", | 960 | title: "Plan Target", |
| 946 | modelSelection: { | 961 | modelSelection: { |
| 947 | provider: "codex", | 962 | instanceId: ProviderInstanceId.make("codex"), |
| 948 | model: "gpt-5-codex", | 963 | model: "gpt-5-codex", |
| 949 | }, | 964 | }, |
| 950 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 965 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -972,7 +987,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -972,7 +987,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 972 | }), | 987 | }), |
| 973 | ); | 988 | ); |
| 974 | harness.setProviderSession({ | 989 | harness.setProviderSession({ |
| 975 | provider: "codex", | 990 | provider: ProviderDriverKind.make("codex"), |
| 976 | status: "ready", | 991 | status: "ready", |
| 977 | runtimeMode: "approval-required", | 992 | runtimeMode: "approval-required", |
| 978 | threadId: targetThreadId, | 993 | threadId: targetThreadId, |
| ... | @@ -984,7 +999,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -984,7 +999,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 984 | harness.emit({ | 999 | harness.emit({ |
| 985 | type: "turn.proposed.completed", | 1000 | type: "turn.proposed.completed", |
| 986 | eventId: asEventId("evt-plan-source-completed"), | 1001 | eventId: asEventId("evt-plan-source-completed"), |
| 987 | provider: "codex", | 1002 | provider: ProviderDriverKind.make("codex"), |
| 988 | createdAt, | 1003 | createdAt, |
| 989 | threadId: sourceThreadId, | 1004 | threadId: sourceThreadId, |
| 990 | turnId: sourceTurnId, | 1005 | turnId: sourceTurnId, |
| ... | @@ -1054,7 +1069,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1054,7 +1069,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1054 | harness.emit({ | 1069 | harness.emit({ |
| 1055 | type: "turn.started", | 1070 | type: "turn.started", |
| 1056 | eventId: asEventId("evt-plan-target-started"), | 1071 | eventId: asEventId("evt-plan-target-started"), |
| 1057 | provider: "codex", | 1072 | provider: ProviderDriverKind.make("codex"), |
| 1058 | createdAt: new Date().toISOString(), | 1073 | createdAt: new Date().toISOString(), |
| 1059 | threadId: targetThreadId, | 1074 | threadId: targetThreadId, |
| 1060 | turnId: targetTurnId, | 1075 | turnId: targetTurnId, |
| ... | @@ -1096,7 +1111,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1096,7 +1111,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1096 | projectId: asProjectId("project-1"), | 1111 | projectId: asProjectId("project-1"), |
| 1097 | title: "Plan Source", | 1112 | title: "Plan Source", |
| 1098 | modelSelection: { | 1113 | modelSelection: { |
| 1099 | provider: "codex", | 1114 | instanceId: ProviderInstanceId.make("codex"), |
| 1100 | model: "gpt-5-codex", | 1115 | model: "gpt-5-codex", |
| 1101 | }, | 1116 | }, |
| 1102 | interactionMode: "plan", | 1117 | interactionMode: "plan", |
| ... | @@ -1124,7 +1139,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1124,7 +1139,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1124 | }), | 1139 | }), |
| 1125 | ); | 1140 | ); |
| 1126 | harness.setProviderSession({ | 1141 | harness.setProviderSession({ |
| 1127 | provider: "codex", | 1142 | provider: ProviderDriverKind.make("codex"), |
| 1128 | status: "running", | 1143 | status: "running", |
| 1129 | runtimeMode: "approval-required", | 1144 | runtimeMode: "approval-required", |
| 1130 | threadId: targetThreadId, | 1145 | threadId: targetThreadId, |
| ... | @@ -1136,7 +1151,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1136,7 +1151,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1136 | harness.emit({ | 1151 | harness.emit({ |
| 1137 | type: "turn.started", | 1152 | type: "turn.started", |
| 1138 | eventId: asEventId("evt-turn-started-already-running"), | 1153 | eventId: asEventId("evt-turn-started-already-running"), |
| 1139 | provider: "codex", | 1154 | provider: ProviderDriverKind.make("codex"), |
| 1140 | createdAt, | 1155 | createdAt, |
| 1141 | threadId: targetThreadId, | 1156 | threadId: targetThreadId, |
| 1142 | turnId: activeTurnId, | 1157 | turnId: activeTurnId, |
| ... | @@ -1153,7 +1168,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1153,7 +1168,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1153 | harness.emit({ | 1168 | harness.emit({ |
| 1154 | type: "turn.proposed.completed", | 1169 | type: "turn.proposed.completed", |
| 1155 | eventId: asEventId("evt-plan-source-completed-guarded"), | 1170 | eventId: asEventId("evt-plan-source-completed-guarded"), |
| 1156 | provider: "codex", | 1171 | provider: ProviderDriverKind.make("codex"), |
| 1157 | createdAt, | 1172 | createdAt, |
| 1158 | threadId: sourceThreadId, | 1173 | threadId: sourceThreadId, |
| 1159 | turnId: sourceTurnId, | 1174 | turnId: sourceTurnId, |
| ... | @@ -1206,7 +1221,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1206,7 +1221,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1206 | harness.emit({ | 1221 | harness.emit({ |
| 1207 | type: "turn.started", | 1222 | type: "turn.started", |
| 1208 | eventId: asEventId("evt-turn-started-stale-plan-implementation"), | 1223 | eventId: asEventId("evt-turn-started-stale-plan-implementation"), |
| 1209 | provider: "codex", | 1224 | provider: ProviderDriverKind.make("codex"), |
| 1210 | createdAt: new Date().toISOString(), | 1225 | createdAt: new Date().toISOString(), |
| 1211 | threadId: targetThreadId, | 1226 | threadId: targetThreadId, |
| 1212 | turnId: staleTurnId, | 1227 | turnId: staleTurnId, |
| ... | @@ -1249,7 +1264,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1249,7 +1264,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1249 | projectId: asProjectId("project-1"), | 1264 | projectId: asProjectId("project-1"), |
| 1250 | title: "Plan Source", | 1265 | title: "Plan Source", |
| 1251 | modelSelection: { | 1266 | modelSelection: { |
| 1252 | provider: "codex", | 1267 | instanceId: ProviderInstanceId.make("codex"), |
| 1253 | model: "gpt-5-codex", | 1268 | model: "gpt-5-codex", |
| 1254 | }, | 1269 | }, |
| 1255 | interactionMode: "plan", | 1270 | interactionMode: "plan", |
| ... | @@ -1284,7 +1299,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1284,7 +1299,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1284 | projectId: asProjectId("project-1"), | 1299 | projectId: asProjectId("project-1"), |
| 1285 | title: "Plan Target", | 1300 | title: "Plan Target", |
| 1286 | modelSelection: { | 1301 | modelSelection: { |
| 1287 | provider: "codex", | 1302 | instanceId: ProviderInstanceId.make("codex"), |
| 1288 | model: "gpt-5-codex", | 1303 | model: "gpt-5-codex", |
| 1289 | }, | 1304 | }, |
| 1290 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1305 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -1315,7 +1330,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1315,7 +1330,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1315 | harness.emit({ | 1330 | harness.emit({ |
| 1316 | type: "turn.proposed.completed", | 1331 | type: "turn.proposed.completed", |
| 1317 | eventId: asEventId("evt-plan-source-completed-unrelated"), | 1332 | eventId: asEventId("evt-plan-source-completed-unrelated"), |
| 1318 | provider: "codex", | 1333 | provider: ProviderDriverKind.make("codex"), |
| 1319 | createdAt, | 1334 | createdAt, |
| 1320 | threadId: sourceThreadId, | 1335 | threadId: sourceThreadId, |
| 1321 | turnId: sourceTurnId, | 1336 | turnId: sourceTurnId, |
| ... | @@ -1366,7 +1381,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1366,7 +1381,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1366 | ); | 1381 | ); |
| 1367 | 1382 | ||
| 1368 | harness.setProviderSession({ | 1383 | harness.setProviderSession({ |
| 1369 | provider: "codex", | 1384 | provider: ProviderDriverKind.make("codex"), |
| 1370 | status: "running", | 1385 | status: "running", |
| 1371 | runtimeMode: "approval-required", | 1386 | runtimeMode: "approval-required", |
| 1372 | threadId: targetThreadId, | 1387 | threadId: targetThreadId, |
| ... | @@ -1378,7 +1393,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1378,7 +1393,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1378 | harness.emit({ | 1393 | harness.emit({ |
| 1379 | type: "turn.started", | 1394 | type: "turn.started", |
| 1380 | eventId: asEventId("evt-turn-started-unrelated-plan-implementation"), | 1395 | eventId: asEventId("evt-turn-started-unrelated-plan-implementation"), |
| 1381 | provider: "codex", | 1396 | provider: ProviderDriverKind.make("codex"), |
| 1382 | createdAt: new Date().toISOString(), | 1397 | createdAt: new Date().toISOString(), |
| 1383 | threadId: targetThreadId, | 1398 | threadId: targetThreadId, |
| 1384 | turnId: replayedTurnId, | 1399 | turnId: replayedTurnId, |
| ... | @@ -1405,7 +1420,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1405,7 +1420,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1405 | harness.emit({ | 1420 | harness.emit({ |
| 1406 | type: "turn.started", | 1421 | type: "turn.started", |
| 1407 | eventId: asEventId("evt-turn-started-plan-buffer"), | 1422 | eventId: asEventId("evt-turn-started-plan-buffer"), |
| 1408 | provider: "codex", | 1423 | provider: ProviderDriverKind.make("codex"), |
| 1409 | createdAt: now, | 1424 | createdAt: now, |
| 1410 | threadId: asThreadId("thread-1"), | 1425 | threadId: asThreadId("thread-1"), |
| 1411 | turnId: asTurnId("turn-plan-buffer"), | 1426 | turnId: asTurnId("turn-plan-buffer"), |
| ... | @@ -1420,7 +1435,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1420,7 +1435,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1420 | harness.emit({ | 1435 | harness.emit({ |
| 1421 | type: "turn.proposed.delta", | 1436 | type: "turn.proposed.delta", |
| 1422 | eventId: asEventId("evt-plan-delta-1"), | 1437 | eventId: asEventId("evt-plan-delta-1"), |
| 1423 | provider: "codex", | 1438 | provider: ProviderDriverKind.make("codex"), |
| 1424 | createdAt: now, | 1439 | createdAt: now, |
| 1425 | threadId: asThreadId("thread-1"), | 1440 | threadId: asThreadId("thread-1"), |
| 1426 | turnId: asTurnId("turn-plan-buffer"), | 1441 | turnId: asTurnId("turn-plan-buffer"), |
| ... | @@ -1431,7 +1446,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1431,7 +1446,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1431 | harness.emit({ | 1446 | harness.emit({ |
| 1432 | type: "turn.proposed.delta", | 1447 | type: "turn.proposed.delta", |
| 1433 | eventId: asEventId("evt-plan-delta-2"), | 1448 | eventId: asEventId("evt-plan-delta-2"), |
| 1434 | provider: "codex", | 1449 | provider: ProviderDriverKind.make("codex"), |
| 1435 | createdAt: now, | 1450 | createdAt: now, |
| 1436 | threadId: asThreadId("thread-1"), | 1451 | threadId: asThreadId("thread-1"), |
| 1437 | turnId: asTurnId("turn-plan-buffer"), | 1452 | turnId: asTurnId("turn-plan-buffer"), |
| ... | @@ -1442,7 +1457,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1442,7 +1457,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1442 | harness.emit({ | 1457 | harness.emit({ |
| 1443 | type: "turn.completed", | 1458 | type: "turn.completed", |
| 1444 | eventId: asEventId("evt-turn-completed-plan-buffer"), | 1459 | eventId: asEventId("evt-turn-completed-plan-buffer"), |
| 1445 | provider: "codex", | 1460 | provider: ProviderDriverKind.make("codex"), |
| 1446 | createdAt: now, | 1461 | createdAt: now, |
| 1447 | threadId: asThreadId("thread-1"), | 1462 | threadId: asThreadId("thread-1"), |
| 1448 | turnId: asTurnId("turn-plan-buffer"), | 1463 | turnId: asTurnId("turn-plan-buffer"), |
| ... | @@ -1471,7 +1486,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1471,7 +1486,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1471 | harness.emit({ | 1486 | harness.emit({ |
| 1472 | type: "turn.started", | 1487 | type: "turn.started", |
| 1473 | eventId: asEventId("evt-turn-started-buffered"), | 1488 | eventId: asEventId("evt-turn-started-buffered"), |
| 1474 | provider: "codex", | 1489 | provider: ProviderDriverKind.make("codex"), |
| 1475 | createdAt: now, | 1490 | createdAt: now, |
| 1476 | threadId: asThreadId("thread-1"), | 1491 | threadId: asThreadId("thread-1"), |
| 1477 | turnId: asTurnId("turn-buffered"), | 1492 | turnId: asTurnId("turn-buffered"), |
| ... | @@ -1485,7 +1500,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1485,7 +1500,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1485 | harness.emit({ | 1500 | harness.emit({ |
| 1486 | type: "content.delta", | 1501 | type: "content.delta", |
| 1487 | eventId: asEventId("evt-message-delta-buffered"), | 1502 | eventId: asEventId("evt-message-delta-buffered"), |
| 1488 | provider: "codex", | 1503 | provider: ProviderDriverKind.make("codex"), |
| 1489 | createdAt: now, | 1504 | createdAt: now, |
| 1490 | threadId: asThreadId("thread-1"), | 1505 | threadId: asThreadId("thread-1"), |
| 1491 | turnId: asTurnId("turn-buffered"), | 1506 | turnId: asTurnId("turn-buffered"), |
| ... | @@ -1508,7 +1523,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1508,7 +1523,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1508 | harness.emit({ | 1523 | harness.emit({ |
| 1509 | type: "item.completed", | 1524 | type: "item.completed", |
| 1510 | eventId: asEventId("evt-message-completed-buffered"), | 1525 | eventId: asEventId("evt-message-completed-buffered"), |
| 1511 | provider: "codex", | 1526 | provider: ProviderDriverKind.make("codex"), |
| 1512 | createdAt: now, | 1527 | createdAt: now, |
| 1513 | threadId: asThreadId("thread-1"), | 1528 | threadId: asThreadId("thread-1"), |
| 1514 | turnId: asTurnId("turn-buffered"), | 1529 | turnId: asTurnId("turn-buffered"), |
| ... | @@ -1539,7 +1554,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1539,7 +1554,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1539 | harness.emit({ | 1554 | harness.emit({ |
| 1540 | type: "turn.started", | 1555 | type: "turn.started", |
| 1541 | eventId: asEventId("evt-turn-started-buffered-request-flush"), | 1556 | eventId: asEventId("evt-turn-started-buffered-request-flush"), |
| 1542 | provider: "codex", | 1557 | provider: ProviderDriverKind.make("codex"), |
| 1543 | createdAt: now, | 1558 | createdAt: now, |
| 1544 | threadId: asThreadId("thread-1"), | 1559 | threadId: asThreadId("thread-1"), |
| 1545 | turnId: asTurnId("turn-buffered-request-flush"), | 1560 | turnId: asTurnId("turn-buffered-request-flush"), |
| ... | @@ -1554,7 +1569,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1554,7 +1569,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1554 | harness.emit({ | 1569 | harness.emit({ |
| 1555 | type: "content.delta", | 1570 | type: "content.delta", |
| 1556 | eventId: asEventId("evt-message-delta-buffered-request-flush"), | 1571 | eventId: asEventId("evt-message-delta-buffered-request-flush"), |
| 1557 | provider: "codex", | 1572 | provider: ProviderDriverKind.make("codex"), |
| 1558 | createdAt: now, | 1573 | createdAt: now, |
| 1559 | threadId: asThreadId("thread-1"), | 1574 | threadId: asThreadId("thread-1"), |
| 1560 | turnId: asTurnId("turn-buffered-request-flush"), | 1575 | turnId: asTurnId("turn-buffered-request-flush"), |
| ... | @@ -1567,7 +1582,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1567,7 +1582,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1567 | harness.emit({ | 1582 | harness.emit({ |
| 1568 | type: "request.opened", | 1583 | type: "request.opened", |
| 1569 | eventId: asEventId("evt-request-opened-buffered-request-flush"), | 1584 | eventId: asEventId("evt-request-opened-buffered-request-flush"), |
| 1570 | provider: "codex", | 1585 | provider: ProviderDriverKind.make("codex"), |
| 1571 | createdAt: now, | 1586 | createdAt: now, |
| 1572 | threadId: asThreadId("thread-1"), | 1587 | threadId: asThreadId("thread-1"), |
| 1573 | turnId: asTurnId("turn-buffered-request-flush"), | 1588 | turnId: asTurnId("turn-buffered-request-flush"), |
| ... | @@ -1599,7 +1614,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1599,7 +1614,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1599 | harness.emit({ | 1614 | harness.emit({ |
| 1600 | type: "turn.started", | 1615 | type: "turn.started", |
| 1601 | eventId: asEventId("evt-turn-started-buffered-user-input-flush"), | 1616 | eventId: asEventId("evt-turn-started-buffered-user-input-flush"), |
| 1602 | provider: "codex", | 1617 | provider: ProviderDriverKind.make("codex"), |
| 1603 | createdAt: now, | 1618 | createdAt: now, |
| 1604 | threadId: asThreadId("thread-1"), | 1619 | threadId: asThreadId("thread-1"), |
| 1605 | turnId: asTurnId("turn-buffered-user-input-flush"), | 1620 | turnId: asTurnId("turn-buffered-user-input-flush"), |
| ... | @@ -1614,7 +1629,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1614,7 +1629,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1614 | harness.emit({ | 1629 | harness.emit({ |
| 1615 | type: "content.delta", | 1630 | type: "content.delta", |
| 1616 | eventId: asEventId("evt-message-delta-buffered-user-input-flush"), | 1631 | eventId: asEventId("evt-message-delta-buffered-user-input-flush"), |
| 1617 | provider: "codex", | 1632 | provider: ProviderDriverKind.make("codex"), |
| 1618 | createdAt: now, | 1633 | createdAt: now, |
| 1619 | threadId: asThreadId("thread-1"), | 1634 | threadId: asThreadId("thread-1"), |
| 1620 | turnId: asTurnId("turn-buffered-user-input-flush"), | 1635 | turnId: asTurnId("turn-buffered-user-input-flush"), |
| ... | @@ -1627,7 +1642,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1627,7 +1642,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1627 | harness.emit({ | 1642 | harness.emit({ |
| 1628 | type: "user-input.requested", | 1643 | type: "user-input.requested", |
| 1629 | eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"), | 1644 | eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"), |
| 1630 | provider: "codex", | 1645 | provider: ProviderDriverKind.make("codex"), |
| 1631 | createdAt: now, | 1646 | createdAt: now, |
| 1632 | threadId: asThreadId("thread-1"), | 1647 | threadId: asThreadId("thread-1"), |
| 1633 | turnId: asTurnId("turn-buffered-user-input-flush"), | 1648 | turnId: asTurnId("turn-buffered-user-input-flush"), |
| ... | @@ -1667,7 +1682,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1667,7 +1682,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1667 | harness.emit({ | 1682 | harness.emit({ |
| 1668 | type: "turn.started", | 1683 | type: "turn.started", |
| 1669 | eventId: asEventId("evt-turn-started-buffered-whitespace-request"), | 1684 | eventId: asEventId("evt-turn-started-buffered-whitespace-request"), |
| 1670 | provider: "codex", | 1685 | provider: ProviderDriverKind.make("codex"), |
| 1671 | createdAt: startedAt, | 1686 | createdAt: startedAt, |
| 1672 | threadId: asThreadId("thread-1"), | 1687 | threadId: asThreadId("thread-1"), |
| 1673 | turnId: asTurnId("turn-buffered-whitespace-request"), | 1688 | turnId: asTurnId("turn-buffered-whitespace-request"), |
| ... | @@ -1682,7 +1697,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1682,7 +1697,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1682 | harness.emit({ | 1697 | harness.emit({ |
| 1683 | type: "content.delta", | 1698 | type: "content.delta", |
| 1684 | eventId: asEventId("evt-message-delta-buffered-whitespace-request"), | 1699 | eventId: asEventId("evt-message-delta-buffered-whitespace-request"), |
| 1685 | provider: "codex", | 1700 | provider: ProviderDriverKind.make("codex"), |
| 1686 | createdAt: startedAt, | 1701 | createdAt: startedAt, |
| 1687 | threadId: asThreadId("thread-1"), | 1702 | threadId: asThreadId("thread-1"), |
| 1688 | turnId: asTurnId("turn-buffered-whitespace-request"), | 1703 | turnId: asTurnId("turn-buffered-whitespace-request"), |
| ... | @@ -1695,7 +1710,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1695,7 +1710,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1695 | harness.emit({ | 1710 | harness.emit({ |
| 1696 | type: "request.opened", | 1711 | type: "request.opened", |
| 1697 | eventId: asEventId("evt-request-opened-buffered-whitespace-request"), | 1712 | eventId: asEventId("evt-request-opened-buffered-whitespace-request"), |
| 1698 | provider: "codex", | 1713 | provider: ProviderDriverKind.make("codex"), |
| 1699 | createdAt: pausedAt, | 1714 | createdAt: pausedAt, |
| 1700 | threadId: asThreadId("thread-1"), | 1715 | threadId: asThreadId("thread-1"), |
| 1701 | turnId: asTurnId("turn-buffered-whitespace-request"), | 1716 | turnId: asTurnId("turn-buffered-whitespace-request"), |
| ... | @@ -1729,7 +1744,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1729,7 +1744,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1729 | harness.emit({ | 1744 | harness.emit({ |
| 1730 | type: "turn.started", | 1745 | type: "turn.started", |
| 1731 | eventId: asEventId("evt-turn-started-buffered-request-append"), | 1746 | eventId: asEventId("evt-turn-started-buffered-request-append"), |
| 1732 | provider: "codex", | 1747 | provider: ProviderDriverKind.make("codex"), |
| 1733 | createdAt: startedAt, | 1748 | createdAt: startedAt, |
| 1734 | threadId: asThreadId("thread-1"), | 1749 | threadId: asThreadId("thread-1"), |
| 1735 | turnId: asTurnId("turn-buffered-request-append"), | 1750 | turnId: asTurnId("turn-buffered-request-append"), |
| ... | @@ -1744,7 +1759,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1744,7 +1759,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1744 | harness.emit({ | 1759 | harness.emit({ |
| 1745 | type: "content.delta", | 1760 | type: "content.delta", |
| 1746 | eventId: asEventId("evt-message-delta-buffered-request-append-initial"), | 1761 | eventId: asEventId("evt-message-delta-buffered-request-append-initial"), |
| 1747 | provider: "codex", | 1762 | provider: ProviderDriverKind.make("codex"), |
| 1748 | createdAt: startedAt, | 1763 | createdAt: startedAt, |
| 1749 | threadId: asThreadId("thread-1"), | 1764 | threadId: asThreadId("thread-1"), |
| 1750 | turnId: asTurnId("turn-buffered-request-append"), | 1765 | turnId: asTurnId("turn-buffered-request-append"), |
| ... | @@ -1757,7 +1772,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1757,7 +1772,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1757 | harness.emit({ | 1772 | harness.emit({ |
| 1758 | type: "request.opened", | 1773 | type: "request.opened", |
| 1759 | eventId: asEventId("evt-request-opened-buffered-request-append"), | 1774 | eventId: asEventId("evt-request-opened-buffered-request-append"), |
| 1760 | provider: "codex", | 1775 | provider: ProviderDriverKind.make("codex"), |
| 1761 | createdAt: pausedAt, | 1776 | createdAt: pausedAt, |
| 1762 | threadId: asThreadId("thread-1"), | 1777 | threadId: asThreadId("thread-1"), |
| 1763 | turnId: asTurnId("turn-buffered-request-append"), | 1778 | turnId: asTurnId("turn-buffered-request-append"), |
| ... | @@ -1780,7 +1795,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1780,7 +1795,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1780 | harness.emit({ | 1795 | harness.emit({ |
| 1781 | type: "content.delta", | 1796 | type: "content.delta", |
| 1782 | eventId: asEventId("evt-message-delta-buffered-request-append-followup"), | 1797 | eventId: asEventId("evt-message-delta-buffered-request-append-followup"), |
| 1783 | provider: "codex", | 1798 | provider: ProviderDriverKind.make("codex"), |
| 1784 | createdAt: resumedAt, | 1799 | createdAt: resumedAt, |
| 1785 | threadId: asThreadId("thread-1"), | 1800 | threadId: asThreadId("thread-1"), |
| 1786 | turnId: asTurnId("turn-buffered-request-append"), | 1801 | turnId: asTurnId("turn-buffered-request-append"), |
| ... | @@ -1793,7 +1808,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1793,7 +1808,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1793 | harness.emit({ | 1808 | harness.emit({ |
| 1794 | type: "item.completed", | 1809 | type: "item.completed", |
| 1795 | eventId: asEventId("evt-message-completed-buffered-request-append"), | 1810 | eventId: asEventId("evt-message-completed-buffered-request-append"), |
| 1796 | provider: "codex", | 1811 | provider: ProviderDriverKind.make("codex"), |
| 1797 | createdAt: completedAt, | 1812 | createdAt: completedAt, |
| 1798 | threadId: asThreadId("thread-1"), | 1813 | threadId: asThreadId("thread-1"), |
| 1799 | turnId: asTurnId("turn-buffered-request-append"), | 1814 | turnId: asTurnId("turn-buffered-request-append"), |
| ... | @@ -1861,7 +1876,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1861,7 +1876,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1861 | harness.emit({ | 1876 | harness.emit({ |
| 1862 | type: "turn.started", | 1877 | type: "turn.started", |
| 1863 | eventId: asEventId("evt-turn-started-streaming-request-segment"), | 1878 | eventId: asEventId("evt-turn-started-streaming-request-segment"), |
| 1864 | provider: "codex", | 1879 | provider: ProviderDriverKind.make("codex"), |
| 1865 | createdAt: startedAt, | 1880 | createdAt: startedAt, |
| 1866 | threadId: asThreadId("thread-1"), | 1881 | threadId: asThreadId("thread-1"), |
| 1867 | turnId: asTurnId("turn-streaming-request-segment"), | 1882 | turnId: asTurnId("turn-streaming-request-segment"), |
| ... | @@ -1876,7 +1891,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1876,7 +1891,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1876 | harness.emit({ | 1891 | harness.emit({ |
| 1877 | type: "content.delta", | 1892 | type: "content.delta", |
| 1878 | eventId: asEventId("evt-message-delta-streaming-request-segment-initial"), | 1893 | eventId: asEventId("evt-message-delta-streaming-request-segment-initial"), |
| 1879 | provider: "codex", | 1894 | provider: ProviderDriverKind.make("codex"), |
| 1880 | createdAt: startedAt, | 1895 | createdAt: startedAt, |
| 1881 | threadId: asThreadId("thread-1"), | 1896 | threadId: asThreadId("thread-1"), |
| 1882 | turnId: asTurnId("turn-streaming-request-segment"), | 1897 | turnId: asTurnId("turn-streaming-request-segment"), |
| ... | @@ -1889,7 +1904,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1889,7 +1904,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1889 | harness.emit({ | 1904 | harness.emit({ |
| 1890 | type: "request.opened", | 1905 | type: "request.opened", |
| 1891 | eventId: asEventId("evt-request-opened-streaming-request-segment"), | 1906 | eventId: asEventId("evt-request-opened-streaming-request-segment"), |
| 1892 | provider: "codex", | 1907 | provider: ProviderDriverKind.make("codex"), |
| 1893 | createdAt: pausedAt, | 1908 | createdAt: pausedAt, |
| 1894 | threadId: asThreadId("thread-1"), | 1909 | threadId: asThreadId("thread-1"), |
| 1895 | turnId: asTurnId("turn-streaming-request-segment"), | 1910 | turnId: asTurnId("turn-streaming-request-segment"), |
| ... | @@ -1912,7 +1927,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1912,7 +1927,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1912 | harness.emit({ | 1927 | harness.emit({ |
| 1913 | type: "content.delta", | 1928 | type: "content.delta", |
| 1914 | eventId: asEventId("evt-message-delta-streaming-request-segment-followup"), | 1929 | eventId: asEventId("evt-message-delta-streaming-request-segment-followup"), |
| 1915 | provider: "codex", | 1930 | provider: ProviderDriverKind.make("codex"), |
| 1916 | createdAt: resumedAt, | 1931 | createdAt: resumedAt, |
| 1917 | threadId: asThreadId("thread-1"), | 1932 | threadId: asThreadId("thread-1"), |
| 1918 | turnId: asTurnId("turn-streaming-request-segment"), | 1933 | turnId: asTurnId("turn-streaming-request-segment"), |
| ... | @@ -1925,7 +1940,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1925,7 +1940,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1925 | harness.emit({ | 1940 | harness.emit({ |
| 1926 | type: "item.completed", | 1941 | type: "item.completed", |
| 1927 | eventId: asEventId("evt-message-completed-streaming-request-segment"), | 1942 | eventId: asEventId("evt-message-completed-streaming-request-segment"), |
| 1928 | provider: "codex", | 1943 | provider: ProviderDriverKind.make("codex"), |
| 1929 | createdAt: completedAt, | 1944 | createdAt: completedAt, |
| 1930 | threadId: asThreadId("thread-1"), | 1945 | threadId: asThreadId("thread-1"), |
| 1931 | turnId: asTurnId("turn-streaming-request-segment"), | 1946 | turnId: asTurnId("turn-streaming-request-segment"), |
| ... | @@ -1983,7 +1998,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1983,7 +1998,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1983 | harness.emit({ | 1998 | harness.emit({ |
| 1984 | type: "turn.started", | 1999 | type: "turn.started", |
| 1985 | eventId: asEventId("evt-turn-started-streaming-mode"), | 2000 | eventId: asEventId("evt-turn-started-streaming-mode"), |
| 1986 | provider: "codex", | 2001 | provider: ProviderDriverKind.make("codex"), |
| 1987 | createdAt: now, | 2002 | createdAt: now, |
| 1988 | threadId: asThreadId("thread-1"), | 2003 | threadId: asThreadId("thread-1"), |
| 1989 | turnId: asTurnId("turn-streaming-mode"), | 2004 | turnId: asTurnId("turn-streaming-mode"), |
| ... | @@ -1998,7 +2013,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1998,7 +2013,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1998 | harness.emit({ | 2013 | harness.emit({ |
| 1999 | type: "content.delta", | 2014 | type: "content.delta", |
| 2000 | eventId: asEventId("evt-message-delta-streaming-mode"), | 2015 | eventId: asEventId("evt-message-delta-streaming-mode"), |
| 2001 | provider: "codex", | 2016 | provider: ProviderDriverKind.make("codex"), |
| 2002 | createdAt: now, | 2017 | createdAt: now, |
| 2003 | threadId: asThreadId("thread-1"), | 2018 | threadId: asThreadId("thread-1"), |
| 2004 | turnId: asTurnId("turn-streaming-mode"), | 2019 | turnId: asTurnId("turn-streaming-mode"), |
| ... | @@ -2025,7 +2040,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2025,7 +2040,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2025 | harness.emit({ | 2040 | harness.emit({ |
| 2026 | type: "item.completed", | 2041 | type: "item.completed", |
| 2027 | eventId: asEventId("evt-message-completed-streaming-mode"), | 2042 | eventId: asEventId("evt-message-completed-streaming-mode"), |
| 2028 | provider: "codex", | 2043 | provider: ProviderDriverKind.make("codex"), |
| 2029 | createdAt: now, | 2044 | createdAt: now, |
| 2030 | threadId: asThreadId("thread-1"), | 2045 | threadId: asThreadId("thread-1"), |
| 2031 | turnId: asTurnId("turn-streaming-mode"), | 2046 | turnId: asTurnId("turn-streaming-mode"), |
| ... | @@ -2058,7 +2073,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2058,7 +2073,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2058 | harness.emit({ | 2073 | harness.emit({ |
| 2059 | type: "turn.started", | 2074 | type: "turn.started", |
| 2060 | eventId: asEventId("evt-turn-started-buffer-spill"), | 2075 | eventId: asEventId("evt-turn-started-buffer-spill"), |
| 2061 | provider: "codex", | 2076 | provider: ProviderDriverKind.make("codex"), |
| 2062 | createdAt: now, | 2077 | createdAt: now, |
| 2063 | threadId: asThreadId("thread-1"), | 2078 | threadId: asThreadId("thread-1"), |
| 2064 | turnId: asTurnId("turn-buffer-spill"), | 2079 | turnId: asTurnId("turn-buffer-spill"), |
| ... | @@ -2073,7 +2088,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2073,7 +2088,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2073 | harness.emit({ | 2088 | harness.emit({ |
| 2074 | type: "content.delta", | 2089 | type: "content.delta", |
| 2075 | eventId: asEventId("evt-message-delta-buffer-spill"), | 2090 | eventId: asEventId("evt-message-delta-buffer-spill"), |
| 2076 | provider: "codex", | 2091 | provider: ProviderDriverKind.make("codex"), |
| 2077 | createdAt: now, | 2092 | createdAt: now, |
| 2078 | threadId: asThreadId("thread-1"), | 2093 | threadId: asThreadId("thread-1"), |
| 2079 | turnId: asTurnId("turn-buffer-spill"), | 2094 | turnId: asTurnId("turn-buffer-spill"), |
| ... | @@ -2086,7 +2101,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2086,7 +2101,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2086 | harness.emit({ | 2101 | harness.emit({ |
| 2087 | type: "item.completed", | 2102 | type: "item.completed", |
| 2088 | eventId: asEventId("evt-message-completed-buffer-spill"), | 2103 | eventId: asEventId("evt-message-completed-buffer-spill"), |
| 2089 | provider: "codex", | 2104 | provider: ProviderDriverKind.make("codex"), |
| 2090 | createdAt: now, | 2105 | createdAt: now, |
| 2091 | threadId: asThreadId("thread-1"), | 2106 | threadId: asThreadId("thread-1"), |
| 2092 | turnId: asTurnId("turn-buffer-spill"), | 2107 | turnId: asTurnId("turn-buffer-spill"), |
| ... | @@ -2118,7 +2133,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2118,7 +2133,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2118 | harness.emit({ | 2133 | harness.emit({ |
| 2119 | type: "turn.started", | 2134 | type: "turn.started", |
| 2120 | eventId: asEventId("evt-turn-started-for-complete-dedup"), | 2135 | eventId: asEventId("evt-turn-started-for-complete-dedup"), |
| 2121 | provider: "codex", | 2136 | provider: ProviderDriverKind.make("codex"), |
| 2122 | createdAt: now, | 2137 | createdAt: now, |
| 2123 | threadId: asThreadId("thread-1"), | 2138 | threadId: asThreadId("thread-1"), |
| 2124 | turnId: asTurnId("turn-complete-dedup"), | 2139 | turnId: asTurnId("turn-complete-dedup"), |
| ... | @@ -2134,7 +2149,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2134,7 +2149,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2134 | harness.emit({ | 2149 | harness.emit({ |
| 2135 | type: "content.delta", | 2150 | type: "content.delta", |
| 2136 | eventId: asEventId("evt-message-delta-for-complete-dedup"), | 2151 | eventId: asEventId("evt-message-delta-for-complete-dedup"), |
| 2137 | provider: "codex", | 2152 | provider: ProviderDriverKind.make("codex"), |
| 2138 | createdAt: now, | 2153 | createdAt: now, |
| 2139 | threadId: asThreadId("thread-1"), | 2154 | threadId: asThreadId("thread-1"), |
| 2140 | turnId: asTurnId("turn-complete-dedup"), | 2155 | turnId: asTurnId("turn-complete-dedup"), |
| ... | @@ -2147,7 +2162,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2147,7 +2162,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2147 | harness.emit({ | 2162 | harness.emit({ |
| 2148 | type: "item.completed", | 2163 | type: "item.completed", |
| 2149 | eventId: asEventId("evt-message-completed-for-complete-dedup"), | 2164 | eventId: asEventId("evt-message-completed-for-complete-dedup"), |
| 2150 | provider: "codex", | 2165 | provider: ProviderDriverKind.make("codex"), |
| 2151 | createdAt: now, | 2166 | createdAt: now, |
| 2152 | threadId: asThreadId("thread-1"), | 2167 | threadId: asThreadId("thread-1"), |
| 2153 | turnId: asTurnId("turn-complete-dedup"), | 2168 | turnId: asTurnId("turn-complete-dedup"), |
| ... | @@ -2160,7 +2175,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2160,7 +2175,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2160 | harness.emit({ | 2175 | harness.emit({ |
| 2161 | type: "turn.completed", | 2176 | type: "turn.completed", |
| 2162 | eventId: asEventId("evt-turn-completed-for-complete-dedup"), | 2177 | eventId: asEventId("evt-turn-completed-for-complete-dedup"), |
| 2163 | provider: "codex", | 2178 | provider: ProviderDriverKind.make("codex"), |
| 2164 | createdAt: now, | 2179 | createdAt: now, |
| 2165 | threadId: asThreadId("thread-1"), | 2180 | threadId: asThreadId("thread-1"), |
| 2166 | turnId: asTurnId("turn-complete-dedup"), | 2181 | turnId: asTurnId("turn-complete-dedup"), |
| ... | @@ -2204,7 +2219,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2204,7 +2219,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2204 | harness.emit({ | 2219 | harness.emit({ |
| 2205 | type: "request.opened", | 2220 | type: "request.opened", |
| 2206 | eventId: asEventId("evt-request-opened"), | 2221 | eventId: asEventId("evt-request-opened"), |
| 2207 | provider: "codex", | 2222 | provider: ProviderDriverKind.make("codex"), |
| 2208 | createdAt: now, | 2223 | createdAt: now, |
| 2209 | threadId: asThreadId("thread-1"), | 2224 | threadId: asThreadId("thread-1"), |
| 2210 | requestId: ApprovalRequestId.make("req-open"), | 2225 | requestId: ApprovalRequestId.make("req-open"), |
| ... | @@ -2217,7 +2232,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2217,7 +2232,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2217 | harness.emit({ | 2232 | harness.emit({ |
| 2218 | type: "request.resolved", | 2233 | type: "request.resolved", |
| 2219 | eventId: asEventId("evt-request-resolved"), | 2234 | eventId: asEventId("evt-request-resolved"), |
| 2220 | provider: "codex", | 2235 | provider: ProviderDriverKind.make("codex"), |
| 2221 | createdAt: now, | 2236 | createdAt: now, |
| 2222 | threadId: asThreadId("thread-1"), | 2237 | threadId: asThreadId("thread-1"), |
| 2223 | requestId: ApprovalRequestId.make("req-open"), | 2238 | requestId: ApprovalRequestId.make("req-open"), |
| ... | @@ -2270,7 +2285,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2270,7 +2285,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2270 | harness.emit({ | 2285 | harness.emit({ |
| 2271 | type: "runtime.error", | 2286 | type: "runtime.error", |
| 2272 | eventId: asEventId("evt-runtime-error"), | 2287 | eventId: asEventId("evt-runtime-error"), |
| 2273 | provider: "codex", | 2288 | provider: ProviderDriverKind.make("codex"), |
| 2274 | createdAt: now, | 2289 | createdAt: now, |
| 2275 | threadId: asThreadId("thread-1"), | 2290 | threadId: asThreadId("thread-1"), |
| 2276 | turnId: asTurnId("turn-3"), | 2291 | turnId: asTurnId("turn-3"), |
| ... | @@ -2297,7 +2312,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2297,7 +2312,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2297 | harness.emit({ | 2312 | harness.emit({ |
| 2298 | type: "runtime.error", | 2313 | type: "runtime.error", |
| 2299 | eventId: asEventId("evt-runtime-error-activity"), | 2314 | eventId: asEventId("evt-runtime-error-activity"), |
| 2300 | provider: "codex", | 2315 | provider: ProviderDriverKind.make("codex"), |
| 2301 | createdAt: now, | 2316 | createdAt: now, |
| 2302 | threadId: asThreadId("thread-1"), | 2317 | threadId: asThreadId("thread-1"), |
| 2303 | turnId: asTurnId("turn-runtime-error-activity"), | 2318 | turnId: asTurnId("turn-runtime-error-activity"), |
| ... | @@ -2328,7 +2343,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2328,7 +2343,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2328 | harness.emit({ | 2343 | harness.emit({ |
| 2329 | type: "turn.started", | 2344 | type: "turn.started", |
| 2330 | eventId: asEventId("evt-warning-turn-started"), | 2345 | eventId: asEventId("evt-warning-turn-started"), |
| 2331 | provider: "codex", | 2346 | provider: ProviderDriverKind.make("codex"), |
| 2332 | createdAt: now, | 2347 | createdAt: now, |
| 2333 | threadId: asThreadId("thread-1"), | 2348 | threadId: asThreadId("thread-1"), |
| 2334 | turnId: asTurnId("turn-warning"), | 2349 | turnId: asTurnId("turn-warning"), |
| ... | @@ -2338,7 +2353,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2338,7 +2353,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2338 | harness.emit({ | 2353 | harness.emit({ |
| 2339 | type: "runtime.warning", | 2354 | type: "runtime.warning", |
| 2340 | eventId: asEventId("evt-warning-runtime"), | 2355 | eventId: asEventId("evt-warning-runtime"), |
| 2341 | provider: "codex", | 2356 | provider: ProviderDriverKind.make("codex"), |
| 2342 | createdAt: now, | 2357 | createdAt: now, |
| 2343 | threadId: asThreadId("thread-1"), | 2358 | threadId: asThreadId("thread-1"), |
| 2344 | turnId: asTurnId("turn-warning"), | 2359 | turnId: asTurnId("turn-warning"), |
| ... | @@ -2372,7 +2387,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2372,7 +2387,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2372 | harness.emit({ | 2387 | harness.emit({ |
| 2373 | type: "session.started", | 2388 | type: "session.started", |
| 2374 | eventId: asEventId("evt-session-started"), | 2389 | eventId: asEventId("evt-session-started"), |
| 2375 | provider: "codex", | 2390 | provider: ProviderDriverKind.make("codex"), |
| 2376 | createdAt: now, | 2391 | createdAt: now, |
| 2377 | threadId: asThreadId("thread-1"), | 2392 | threadId: asThreadId("thread-1"), |
| 2378 | message: "session started", | 2393 | message: "session started", |
| ... | @@ -2380,14 +2395,14 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2380,14 +2395,14 @@ describe("ProviderRuntimeIngestion", () => { |
| 2380 | harness.emit({ | 2395 | harness.emit({ |
| 2381 | type: "thread.started", | 2396 | type: "thread.started", |
| 2382 | eventId: asEventId("evt-thread-started"), | 2397 | eventId: asEventId("evt-thread-started"), |
| 2383 | provider: "codex", | 2398 | provider: ProviderDriverKind.make("codex"), |
| 2384 | createdAt: now, | 2399 | createdAt: now, |
| 2385 | threadId: asThreadId("thread-1"), | 2400 | threadId: asThreadId("thread-1"), |
| 2386 | }); | 2401 | }); |
| 2387 | harness.emit({ | 2402 | harness.emit({ |
| 2388 | type: "item.started", | 2403 | type: "item.started", |
| 2389 | eventId: asEventId("evt-tool-started"), | 2404 | eventId: asEventId("evt-tool-started"), |
| 2390 | provider: "codex", | 2405 | provider: ProviderDriverKind.make("codex"), |
| 2391 | createdAt: now, | 2406 | createdAt: now, |
| 2392 | threadId: asThreadId("thread-1"), | 2407 | threadId: asThreadId("thread-1"), |
| 2393 | turnId: asTurnId("turn-9"), | 2408 | turnId: asTurnId("turn-9"), |
| ... | @@ -2424,7 +2439,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2424,7 +2439,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2424 | harness.emit({ | 2439 | harness.emit({ |
| 2425 | type: "thread.metadata.updated", | 2440 | type: "thread.metadata.updated", |
| 2426 | eventId: asEventId("evt-thread-metadata-updated"), | 2441 | eventId: asEventId("evt-thread-metadata-updated"), |
| 2427 | provider: "codex", | 2442 | provider: ProviderDriverKind.make("codex"), |
| 2428 | createdAt: now, | 2443 | createdAt: now, |
| 2429 | threadId: asThreadId("thread-1"), | 2444 | threadId: asThreadId("thread-1"), |
| 2430 | payload: { | 2445 | payload: { |
| ... | @@ -2436,7 +2451,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2436,7 +2451,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2436 | harness.emit({ | 2451 | harness.emit({ |
| 2437 | type: "turn.plan.updated", | 2452 | type: "turn.plan.updated", |
| 2438 | eventId: asEventId("evt-turn-plan-updated"), | 2453 | eventId: asEventId("evt-turn-plan-updated"), |
| 2439 | provider: "codex", | 2454 | provider: ProviderDriverKind.make("codex"), |
| 2440 | createdAt: now, | 2455 | createdAt: now, |
| 2441 | threadId: asThreadId("thread-1"), | 2456 | threadId: asThreadId("thread-1"), |
| 2442 | turnId: asTurnId("turn-p1"), | 2457 | turnId: asTurnId("turn-p1"), |
| ... | @@ -2452,7 +2467,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2452,7 +2467,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2452 | harness.emit({ | 2467 | harness.emit({ |
| 2453 | type: "item.updated", | 2468 | type: "item.updated", |
| 2454 | eventId: asEventId("evt-item-updated"), | 2469 | eventId: asEventId("evt-item-updated"), |
| 2455 | provider: "codex", | 2470 | provider: ProviderDriverKind.make("codex"), |
| 2456 | createdAt: now, | 2471 | createdAt: now, |
| 2457 | threadId: asThreadId("thread-1"), | 2472 | threadId: asThreadId("thread-1"), |
| 2458 | turnId: asTurnId("turn-p1"), | 2473 | turnId: asTurnId("turn-p1"), |
| ... | @@ -2469,7 +2484,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2469,7 +2484,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2469 | harness.emit({ | 2484 | harness.emit({ |
| 2470 | type: "runtime.warning", | 2485 | type: "runtime.warning", |
| 2471 | eventId: asEventId("evt-runtime-warning"), | 2486 | eventId: asEventId("evt-runtime-warning"), |
| 2472 | provider: "codex", | 2487 | provider: ProviderDriverKind.make("codex"), |
| 2473 | createdAt: now, | 2488 | createdAt: now, |
| 2474 | threadId: asThreadId("thread-1"), | 2489 | threadId: asThreadId("thread-1"), |
| 2475 | turnId: asTurnId("turn-p1"), | 2490 | turnId: asTurnId("turn-p1"), |
| ... | @@ -2482,7 +2497,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2482,7 +2497,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2482 | harness.emit({ | 2497 | harness.emit({ |
| 2483 | type: "turn.diff.updated", | 2498 | type: "turn.diff.updated", |
| 2484 | eventId: asEventId("evt-turn-diff-updated"), | 2499 | eventId: asEventId("evt-turn-diff-updated"), |
| 2485 | provider: "codex", | 2500 | provider: ProviderDriverKind.make("codex"), |
| 2486 | createdAt: now, | 2501 | createdAt: now, |
| 2487 | threadId: asThreadId("thread-1"), | 2502 | threadId: asThreadId("thread-1"), |
| 2488 | turnId: asTurnId("turn-p1"), | 2503 | turnId: asTurnId("turn-p1"), |
| ... | @@ -2558,7 +2573,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2558,7 +2573,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2558 | harness.emit({ | 2573 | harness.emit({ |
| 2559 | type: "thread.token-usage.updated", | 2574 | type: "thread.token-usage.updated", |
| 2560 | eventId: asEventId("evt-thread-token-usage-updated"), | 2575 | eventId: asEventId("evt-thread-token-usage-updated"), |
| 2561 | provider: "codex", | 2576 | provider: ProviderDriverKind.make("codex"), |
| 2562 | createdAt: now, | 2577 | createdAt: now, |
| 2563 | threadId: asThreadId("thread-1"), | 2578 | threadId: asThreadId("thread-1"), |
| 2564 | payload: { | 2579 | payload: { |
| ... | @@ -2610,7 +2625,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2610,7 +2625,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2610 | harness.emit({ | 2625 | harness.emit({ |
| 2611 | type: "thread.token-usage.updated", | 2626 | type: "thread.token-usage.updated", |
| 2612 | eventId: asEventId("evt-thread-token-usage-updated-camel"), | 2627 | eventId: asEventId("evt-thread-token-usage-updated-camel"), |
| 2613 | provider: "codex", | 2628 | provider: ProviderDriverKind.make("codex"), |
| 2614 | createdAt: now, | 2629 | createdAt: now, |
| 2615 | threadId: asThreadId("thread-1"), | 2630 | threadId: asThreadId("thread-1"), |
| 2616 | payload: { | 2631 | payload: { |
| ... | @@ -2663,7 +2678,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2663,7 +2678,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2663 | harness.emit({ | 2678 | harness.emit({ |
| 2664 | type: "thread.token-usage.updated", | 2679 | type: "thread.token-usage.updated", |
| 2665 | eventId: asEventId("evt-thread-token-usage-updated-claude-window"), | 2680 | eventId: asEventId("evt-thread-token-usage-updated-claude-window"), |
| 2666 | provider: "claudeAgent", | 2681 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2667 | createdAt: now, | 2682 | createdAt: now, |
| 2668 | threadId: asThreadId("thread-1"), | 2683 | threadId: asThreadId("thread-1"), |
| 2669 | payload: { | 2684 | payload: { |
| ... | @@ -2707,7 +2722,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2707,7 +2722,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2707 | harness.emit({ | 2722 | harness.emit({ |
| 2708 | type: "thread.state.changed", | 2723 | type: "thread.state.changed", |
| 2709 | eventId: asEventId("evt-thread-compacted"), | 2724 | eventId: asEventId("evt-thread-compacted"), |
| 2710 | provider: "codex", | 2725 | provider: ProviderDriverKind.make("codex"), |
| 2711 | createdAt: now, | 2726 | createdAt: now, |
| 2712 | threadId: asThreadId("thread-1"), | 2727 | threadId: asThreadId("thread-1"), |
| 2713 | turnId: asTurnId("turn-1"), | 2728 | turnId: asTurnId("turn-1"), |
| ... | @@ -2737,7 +2752,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2737,7 +2752,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2737 | harness.emit({ | 2752 | harness.emit({ |
| 2738 | type: "task.started", | 2753 | type: "task.started", |
| 2739 | eventId: asEventId("evt-task-started"), | 2754 | eventId: asEventId("evt-task-started"), |
| 2740 | provider: "codex", | 2755 | provider: ProviderDriverKind.make("codex"), |
| 2741 | createdAt: now, | 2756 | createdAt: now, |
| 2742 | threadId: asThreadId("thread-1"), | 2757 | threadId: asThreadId("thread-1"), |
| 2743 | turnId: asTurnId("turn-task-1"), | 2758 | turnId: asTurnId("turn-task-1"), |
| ... | @@ -2750,7 +2765,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2750,7 +2765,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2750 | harness.emit({ | 2765 | harness.emit({ |
| 2751 | type: "task.progress", | 2766 | type: "task.progress", |
| 2752 | eventId: asEventId("evt-task-progress"), | 2767 | eventId: asEventId("evt-task-progress"), |
| 2753 | provider: "codex", | 2768 | provider: ProviderDriverKind.make("codex"), |
| 2754 | createdAt: now, | 2769 | createdAt: now, |
| 2755 | threadId: asThreadId("thread-1"), | 2770 | threadId: asThreadId("thread-1"), |
| 2756 | turnId: asTurnId("turn-task-1"), | 2771 | turnId: asTurnId("turn-task-1"), |
| ... | @@ -2764,7 +2779,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2764,7 +2779,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2764 | harness.emit({ | 2779 | harness.emit({ |
| 2765 | type: "task.completed", | 2780 | type: "task.completed", |
| 2766 | eventId: asEventId("evt-task-completed"), | 2781 | eventId: asEventId("evt-task-completed"), |
| 2767 | provider: "codex", | 2782 | provider: ProviderDriverKind.make("codex"), |
| 2768 | createdAt: now, | 2783 | createdAt: now, |
| 2769 | threadId: asThreadId("thread-1"), | 2784 | threadId: asThreadId("thread-1"), |
| 2770 | turnId: asTurnId("turn-task-1"), | 2785 | turnId: asTurnId("turn-task-1"), |
| ... | @@ -2777,7 +2792,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2777,7 +2792,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2777 | harness.emit({ | 2792 | harness.emit({ |
| 2778 | type: "turn.proposed.completed", | 2793 | type: "turn.proposed.completed", |
| 2779 | eventId: asEventId("evt-task-proposed-plan-completed"), | 2794 | eventId: asEventId("evt-task-proposed-plan-completed"), |
| 2780 | provider: "codex", | 2795 | provider: ProviderDriverKind.make("codex"), |
| 2781 | createdAt: now, | 2796 | createdAt: now, |
| 2782 | threadId: asThreadId("thread-1"), | 2797 | threadId: asThreadId("thread-1"), |
| 2783 | turnId: asTurnId("turn-task-1"), | 2798 | turnId: asTurnId("turn-task-1"), |
| ... | @@ -2840,7 +2855,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2840,7 +2855,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2840 | harness.emit({ | 2855 | harness.emit({ |
| 2841 | type: "user-input.requested", | 2856 | type: "user-input.requested", |
| 2842 | eventId: asEventId("evt-user-input-requested"), | 2857 | eventId: asEventId("evt-user-input-requested"), |
| 2843 | provider: "codex", | 2858 | provider: ProviderDriverKind.make("codex"), |
| 2844 | createdAt: now, | 2859 | createdAt: now, |
| 2845 | threadId: asThreadId("thread-1"), | 2860 | threadId: asThreadId("thread-1"), |
| 2846 | turnId: asTurnId("turn-user-input"), | 2861 | turnId: asTurnId("turn-user-input"), |
| ... | @@ -2865,7 +2880,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2865,7 +2880,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2865 | harness.emit({ | 2880 | harness.emit({ |
| 2866 | type: "user-input.resolved", | 2881 | type: "user-input.resolved", |
| 2867 | eventId: asEventId("evt-user-input-resolved"), | 2882 | eventId: asEventId("evt-user-input-resolved"), |
| 2868 | provider: "codex", | 2883 | provider: ProviderDriverKind.make("codex"), |
| 2869 | createdAt: new Date().toISOString(), | 2884 | createdAt: new Date().toISOString(), |
| 2870 | threadId: asThreadId("thread-1"), | 2885 | threadId: asThreadId("thread-1"), |
| 2871 | turnId: asTurnId("turn-user-input"), | 2886 | turnId: asTurnId("turn-user-input"), |
| ... | @@ -2913,7 +2928,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2913,7 +2928,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2913 | harness.emit({ | 2928 | harness.emit({ |
| 2914 | type: "content.delta", | 2929 | type: "content.delta", |
| 2915 | eventId: asEventId("evt-invalid-delta"), | 2930 | eventId: asEventId("evt-invalid-delta"), |
| 2916 | provider: "codex", | 2931 | provider: ProviderDriverKind.make("codex"), |
| 2917 | createdAt: now, | 2932 | createdAt: now, |
| 2918 | threadId: asThreadId("thread-1"), | 2933 | threadId: asThreadId("thread-1"), |
| 2919 | turnId: asTurnId("turn-invalid"), | 2934 | turnId: asTurnId("turn-invalid"), |
| ... | @@ -2927,7 +2942,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2927,7 +2942,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2927 | harness.emit({ | 2942 | harness.emit({ |
| 2928 | type: "runtime.error", | 2943 | type: "runtime.error", |
| 2929 | eventId: asEventId("evt-runtime-error-after-failure"), | 2944 | eventId: asEventId("evt-runtime-error-after-failure"), |
| 2930 | provider: "codex", | 2945 | provider: ProviderDriverKind.make("codex"), |
| 2931 | createdAt: new Date().toISOString(), | 2946 | createdAt: new Date().toISOString(), |
| 2932 | threadId: asThreadId("thread-1"), | 2947 | threadId: asThreadId("thread-1"), |
| 2933 | turnId: asTurnId("turn-after-failure"), | 2948 | turnId: asTurnId("turn-after-failure"), |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6| ... | @@ -1211,6 +1211,9 @@ const make = Effect.gen(function* () { | ... | @@ -1211,6 +1211,9 @@ const make = Effect.gen(function* () { |
| 1211 | threadId: thread.id, | 1211 | threadId: thread.id, |
| 1212 | status, | 1212 | status, |
| 1213 | providerName: event.provider, | 1213 | providerName: event.provider, |
| 1214 | ...(event.providerInstanceId !== undefined | ||
| 1215 | ? { providerInstanceId: event.providerInstanceId } | ||
| 1216 | : {}), | ||
| 1214 | runtimeMode: thread.session?.runtimeMode ?? "full-access", | 1217 | runtimeMode: thread.session?.runtimeMode ?? "full-access", |
| 1215 | activeTurnId: nextActiveTurnId, | 1218 | activeTurnId: nextActiveTurnId, |
| 1216 | lastError, | 1219 | lastError, |
| ... | @@ -1456,6 +1459,9 @@ const make = Effect.gen(function* () { | ... | @@ -1456,6 +1459,9 @@ const make = Effect.gen(function* () { |
| 1456 | threadId: thread.id, | 1459 | threadId: thread.id, |
| 1457 | status: "error", | 1460 | status: "error", |
| 1458 | providerName: event.provider, | 1461 | providerName: event.provider, |
| 1462 | ...(event.providerInstanceId !== undefined | ||
| 1463 | ? { providerInstanceId: event.providerInstanceId } | ||
| 1464 | : {}), | ||
| 1459 | runtimeMode: thread.session?.runtimeMode ?? "full-access", | 1465 | runtimeMode: thread.session?.runtimeMode ?? "full-access", |
| 1460 | activeTurnId: eventTurnId ?? null, | 1466 | activeTurnId: eventTurnId ?? null, |
| 1461 | lastError: runtimeErrorMessage, | 1467 | lastError: runtimeErrorMessage, |
apps/server/src/orchestration/commandInvariants.test.ts+7-6| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | type OrchestrationCommand, | 8 | type OrchestrationCommand, |
| 9 | type OrchestrationReadModel, | 9 | type OrchestrationReadModel, |
| 10 | ProviderInstanceId, | ||
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | import { Effect } from "effect"; | 12 | import { Effect } from "effect"; |
| 12 | 13 | ||
| ... | @@ -29,7 +30,7 @@ const readModel: OrchestrationReadModel = { | ... | @@ -29,7 +30,7 @@ const readModel: OrchestrationReadModel = { |
| 29 | title: "Project A", | 30 | title: "Project A", |
| 30 | workspaceRoot: "/tmp/project-a", | 31 | workspaceRoot: "/tmp/project-a", |
| 31 | defaultModelSelection: { | 32 | defaultModelSelection: { |
| 32 | provider: "codex", | 33 | instanceId: ProviderInstanceId.make("codex"), |
| 33 | model: "gpt-5-codex", | 34 | model: "gpt-5-codex", |
| 34 | }, | 35 | }, |
| 35 | scripts: [], | 36 | scripts: [], |
| ... | @@ -42,7 +43,7 @@ const readModel: OrchestrationReadModel = { | ... | @@ -42,7 +43,7 @@ const readModel: OrchestrationReadModel = { |
| 42 | title: "Project B", | 43 | title: "Project B", |
| 43 | workspaceRoot: "/tmp/project-b", | 44 | workspaceRoot: "/tmp/project-b", |
| 44 | defaultModelSelection: { | 45 | defaultModelSelection: { |
| 45 | provider: "codex", | 46 | instanceId: ProviderInstanceId.make("codex"), |
| 46 | model: "gpt-5-codex", | 47 | model: "gpt-5-codex", |
| 47 | }, | 48 | }, |
| 48 | scripts: [], | 49 | scripts: [], |
| ... | @@ -57,7 +58,7 @@ const readModel: OrchestrationReadModel = { | ... | @@ -57,7 +58,7 @@ const readModel: OrchestrationReadModel = { |
| 57 | projectId: ProjectId.make("project-a"), | 58 | projectId: ProjectId.make("project-a"), |
| 58 | title: "Thread A", | 59 | title: "Thread A", |
| 59 | modelSelection: { | 60 | modelSelection: { |
| 60 | provider: "codex", | 61 | instanceId: ProviderInstanceId.make("codex"), |
| 61 | model: "gpt-5-codex", | 62 | model: "gpt-5-codex", |
| 62 | }, | 63 | }, |
| 63 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 64 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -80,7 +81,7 @@ const readModel: OrchestrationReadModel = { | ... | @@ -80,7 +81,7 @@ const readModel: OrchestrationReadModel = { |
| 80 | projectId: ProjectId.make("project-b"), | 81 | projectId: ProjectId.make("project-b"), |
| 81 | title: "Thread B", | 82 | title: "Thread B", |
| 82 | modelSelection: { | 83 | modelSelection: { |
| 83 | provider: "codex", | 84 | instanceId: ProviderInstanceId.make("codex"), |
| 84 | model: "gpt-5-codex", | 85 | model: "gpt-5-codex", |
| 85 | }, | 86 | }, |
| 86 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 87 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -157,7 +158,7 @@ describe("commandInvariants", () => { | ... | @@ -157,7 +158,7 @@ describe("commandInvariants", () => { |
| 157 | projectId: ProjectId.make("project-a"), | 158 | projectId: ProjectId.make("project-a"), |
| 158 | title: "new", | 159 | title: "new", |
| 159 | modelSelection: { | 160 | modelSelection: { |
| 160 | provider: "codex", | 161 | instanceId: ProviderInstanceId.make("codex"), |
| 161 | model: "gpt-5-codex", | 162 | model: "gpt-5-codex", |
| 162 | }, | 163 | }, |
| 163 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 164 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -181,7 +182,7 @@ describe("commandInvariants", () => { | ... | @@ -181,7 +182,7 @@ describe("commandInvariants", () => { |
| 181 | projectId: ProjectId.make("project-a"), | 182 | projectId: ProjectId.make("project-a"), |
| 182 | title: "dup", | 183 | title: "dup", |
| 183 | modelSelection: { | 184 | modelSelection: { |
| 184 | provider: "codex", | 185 | instanceId: ProviderInstanceId.make("codex"), |
| 185 | model: "gpt-5-codex", | 186 | model: "gpt-5-codex", |
| 186 | }, | 187 | }, |
| 187 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 188 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
apps/server/src/orchestration/decider.delete.test.ts+3-2| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | type OrchestrationCommand, | 7 | type OrchestrationCommand, |
| 8 | type OrchestrationEvent, | 8 | type OrchestrationEvent, |
| 9 | type OrchestrationReadModel, | 9 | type OrchestrationReadModel, |
| 10 | ProviderInstanceId, | ||
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | import { Effect } from "effect"; | 12 | import { Effect } from "effect"; |
| 12 | import { describe, expect, it } from "vitest"; | 13 | import { describe, expect, it } from "vitest"; |
| ... | @@ -63,7 +64,7 @@ async function seedReadModel(): Promise<OrchestrationReadModel> { | ... | @@ -63,7 +64,7 @@ async function seedReadModel(): Promise<OrchestrationReadModel> { |
| 63 | projectId: asProjectId("project-delete"), | 64 | projectId: asProjectId("project-delete"), |
| 64 | title: "Thread Delete 1", | 65 | title: "Thread Delete 1", |
| 65 | modelSelection: { | 66 | modelSelection: { |
| 66 | provider: "codex", | 67 | instanceId: ProviderInstanceId.make("codex"), |
| 67 | model: "gpt-5-codex", | 68 | model: "gpt-5-codex", |
| 68 | }, | 69 | }, |
| 69 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 70 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -93,7 +94,7 @@ async function seedReadModel(): Promise<OrchestrationReadModel> { | ... | @@ -93,7 +94,7 @@ async function seedReadModel(): Promise<OrchestrationReadModel> { |
| 93 | projectId: asProjectId("project-delete"), | 94 | projectId: asProjectId("project-delete"), |
| 94 | title: "Thread Delete 2", | 95 | title: "Thread Delete 2", |
| 95 | modelSelection: { | 96 | modelSelection: { |
| 96 | provider: "codex", | 97 | instanceId: ProviderInstanceId.make("codex"), |
| 97 | model: "gpt-5-codex", | 98 | model: "gpt-5-codex", |
| 98 | }, | 99 | }, |
| 99 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 100 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
apps/server/src/orchestration/decider.projectScripts.test.ts+6-5| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | MessageId, | 5 | MessageId, |
| 6 | ProjectId, | 6 | ProjectId, |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | ProviderInstanceId, | ||
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | 10 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | import { describe, expect, it } from "vitest"; | 11 | import { describe, expect, it } from "vitest"; |
| ... | @@ -138,7 +139,7 @@ describe("decider project scripts", () => { | ... | @@ -138,7 +139,7 @@ describe("decider project scripts", () => { |
| 138 | projectId: asProjectId("project-1"), | 139 | projectId: asProjectId("project-1"), |
| 139 | title: "Thread", | 140 | title: "Thread", |
| 140 | modelSelection: { | 141 | modelSelection: { |
| 141 | provider: "codex", | 142 | instanceId: ProviderInstanceId.make("codex"), |
| 142 | model: "gpt-5-codex", | 143 | model: "gpt-5-codex", |
| 143 | }, | 144 | }, |
| 144 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 145 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -163,7 +164,7 @@ describe("decider project scripts", () => { | ... | @@ -163,7 +164,7 @@ describe("decider project scripts", () => { |
| 163 | text: "hello", | 164 | text: "hello", |
| 164 | attachments: [], | 165 | attachments: [], |
| 165 | }, | 166 | }, |
| 166 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 167 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 167 | { id: "reasoningEffort", value: "high" }, | 168 | { id: "reasoningEffort", value: "high" }, |
| 168 | { id: "fastMode", value: true }, | 169 | { id: "fastMode", value: true }, |
| 169 | ]), | 170 | ]), |
| ... | @@ -188,7 +189,7 @@ describe("decider project scripts", () => { | ... | @@ -188,7 +189,7 @@ describe("decider project scripts", () => { |
| 188 | expect(turnStartEvent.payload).toMatchObject({ | 189 | expect(turnStartEvent.payload).toMatchObject({ |
| 189 | threadId: ThreadId.make("thread-1"), | 190 | threadId: ThreadId.make("thread-1"), |
| 190 | messageId: asMessageId("message-user-1"), | 191 | messageId: asMessageId("message-user-1"), |
| 191 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 192 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 192 | { id: "reasoningEffort", value: "high" }, | 193 | { id: "reasoningEffort", value: "high" }, |
| 193 | { id: "fastMode", value: true }, | 194 | { id: "fastMode", value: true }, |
| 194 | ]), | 195 | ]), |
| ... | @@ -239,7 +240,7 @@ describe("decider project scripts", () => { | ... | @@ -239,7 +240,7 @@ describe("decider project scripts", () => { |
| 239 | projectId: asProjectId("project-1"), | 240 | projectId: asProjectId("project-1"), |
| 240 | title: "Thread", | 241 | title: "Thread", |
| 241 | modelSelection: { | 242 | modelSelection: { |
| 242 | provider: "codex", | 243 | instanceId: ProviderInstanceId.make("codex"), |
| 243 | model: "gpt-5-codex", | 244 | model: "gpt-5-codex", |
| 244 | }, | 245 | }, |
| 245 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 246 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -321,7 +322,7 @@ describe("decider project scripts", () => { | ... | @@ -321,7 +322,7 @@ describe("decider project scripts", () => { |
| 321 | projectId: asProjectId("project-1"), | 322 | projectId: asProjectId("project-1"), |
| 322 | title: "Thread", | 323 | title: "Thread", |
| 323 | modelSelection: { | 324 | modelSelection: { |
| 324 | provider: "codex", | 325 | instanceId: ProviderInstanceId.make("codex"), |
| 325 | model: "gpt-5-codex", | 326 | model: "gpt-5-codex", |
| 326 | }, | 327 | }, |
| 327 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 328 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
apps/server/src/orchestration/projector.test.ts+11-10| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | EventId, | 3 | EventId, |
| 4 | ProjectId, | 4 | ProjectId, |
| 5 | ProviderDriverKind, | ||
| 5 | ThreadId, | 6 | ThreadId, |
| 6 | type OrchestrationEvent, | 7 | type OrchestrationEvent, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| ... | @@ -57,7 +58,7 @@ describe("orchestration projector", () => { | ... | @@ -57,7 +58,7 @@ describe("orchestration projector", () => { |
| 57 | projectId: "project-1", | 58 | projectId: "project-1", |
| 58 | title: "demo", | 59 | title: "demo", |
| 59 | modelSelection: { | 60 | modelSelection: { |
| 60 | provider: "codex", | 61 | provider: ProviderDriverKind.make("codex"), |
| 61 | model: "gpt-5-codex", | 62 | model: "gpt-5-codex", |
| 62 | }, | 63 | }, |
| 63 | runtimeMode: "full-access", | 64 | runtimeMode: "full-access", |
| ... | @@ -77,7 +78,7 @@ describe("orchestration projector", () => { | ... | @@ -77,7 +78,7 @@ describe("orchestration projector", () => { |
| 77 | projectId: "project-1", | 78 | projectId: "project-1", |
| 78 | title: "demo", | 79 | title: "demo", |
| 79 | modelSelection: { | 80 | modelSelection: { |
| 80 | provider: "codex", | 81 | instanceId: "codex", |
| 81 | model: "gpt-5-codex", | 82 | model: "gpt-5-codex", |
| 82 | }, | 83 | }, |
| 83 | runtimeMode: "full-access", | 84 | runtimeMode: "full-access", |
| ... | @@ -118,7 +119,7 @@ describe("orchestration projector", () => { | ... | @@ -118,7 +119,7 @@ describe("orchestration projector", () => { |
| 118 | projectId: "project-1", | 119 | projectId: "project-1", |
| 119 | title: "demo", | 120 | title: "demo", |
| 120 | modelSelection: { | 121 | modelSelection: { |
| 121 | provider: "codex", | 122 | provider: ProviderDriverKind.make("codex"), |
| 122 | model: "gpt-5-codex", | 123 | model: "gpt-5-codex", |
| 123 | }, | 124 | }, |
| 124 | branch: null, | 125 | branch: null, |
| ... | @@ -150,7 +151,7 @@ describe("orchestration projector", () => { | ... | @@ -150,7 +151,7 @@ describe("orchestration projector", () => { |
| 150 | projectId: "project-1", | 151 | projectId: "project-1", |
| 151 | title: "demo", | 152 | title: "demo", |
| 152 | modelSelection: { | 153 | modelSelection: { |
| 153 | provider: "codex", | 154 | provider: ProviderDriverKind.make("codex"), |
| 154 | model: "gpt-5-codex", | 155 | model: "gpt-5-codex", |
| 155 | }, | 156 | }, |
| 156 | runtimeMode: "full-access", | 157 | runtimeMode: "full-access", |
| ... | @@ -253,7 +254,7 @@ describe("orchestration projector", () => { | ... | @@ -253,7 +254,7 @@ describe("orchestration projector", () => { |
| 253 | projectId: "project-1", | 254 | projectId: "project-1", |
| 254 | title: "demo", | 255 | title: "demo", |
| 255 | modelSelection: { | 256 | modelSelection: { |
| 256 | provider: "codex", | 257 | provider: ProviderDriverKind.make("codex"), |
| 257 | model: "gpt-5.3-codex", | 258 | model: "gpt-5.3-codex", |
| 258 | }, | 259 | }, |
| 259 | runtimeMode: "full-access", | 260 | runtimeMode: "full-access", |
| ... | @@ -319,7 +320,7 @@ describe("orchestration projector", () => { | ... | @@ -319,7 +320,7 @@ describe("orchestration projector", () => { |
| 319 | projectId: "project-1", | 320 | projectId: "project-1", |
| 320 | title: "demo", | 321 | title: "demo", |
| 321 | modelSelection: { | 322 | modelSelection: { |
| 322 | provider: "codex", | 323 | provider: ProviderDriverKind.make("codex"), |
| 323 | model: "gpt-5.3-codex", | 324 | model: "gpt-5.3-codex", |
| 324 | }, | 325 | }, |
| 325 | runtimeMode: "full-access", | 326 | runtimeMode: "full-access", |
| ... | @@ -376,7 +377,7 @@ describe("orchestration projector", () => { | ... | @@ -376,7 +377,7 @@ describe("orchestration projector", () => { |
| 376 | projectId: "project-1", | 377 | projectId: "project-1", |
| 377 | title: "demo", | 378 | title: "demo", |
| 378 | modelSelection: { | 379 | modelSelection: { |
| 379 | provider: "codex", | 380 | provider: ProviderDriverKind.make("codex"), |
| 380 | model: "gpt-5.3-codex", | 381 | model: "gpt-5.3-codex", |
| 381 | }, | 382 | }, |
| 382 | runtimeMode: "full-access", | 383 | runtimeMode: "full-access", |
| ... | @@ -463,7 +464,7 @@ describe("orchestration projector", () => { | ... | @@ -463,7 +464,7 @@ describe("orchestration projector", () => { |
| 463 | projectId: "project-1", | 464 | projectId: "project-1", |
| 464 | title: "demo", | 465 | title: "demo", |
| 465 | modelSelection: { | 466 | modelSelection: { |
| 466 | provider: "codex", | 467 | provider: ProviderDriverKind.make("codex"), |
| 467 | model: "gpt-5.3-codex", | 468 | model: "gpt-5.3-codex", |
| 468 | }, | 469 | }, |
| 469 | runtimeMode: "full-access", | 470 | runtimeMode: "full-access", |
| ... | @@ -678,7 +679,7 @@ describe("orchestration projector", () => { | ... | @@ -678,7 +679,7 @@ describe("orchestration projector", () => { |
| 678 | projectId: "project-1", | 679 | projectId: "project-1", |
| 679 | title: "demo", | 680 | title: "demo", |
| 680 | modelSelection: { | 681 | modelSelection: { |
| 681 | provider: "codex", | 682 | provider: ProviderDriverKind.make("codex"), |
| 682 | model: "gpt-5.3-codex", | 683 | model: "gpt-5.3-codex", |
| 683 | }, | 684 | }, |
| 684 | runtimeMode: "full-access", | 685 | runtimeMode: "full-access", |
| ... | @@ -831,7 +832,7 @@ describe("orchestration projector", () => { | ... | @@ -831,7 +832,7 @@ describe("orchestration projector", () => { |
| 831 | projectId: "project-1", | 832 | projectId: "project-1", |
| 832 | title: "capped", | 833 | title: "capped", |
| 833 | modelSelection: { | 834 | modelSelection: { |
| 834 | provider: "codex", | 835 | provider: ProviderDriverKind.make("codex"), |
| 835 | model: "gpt-5-codex", | 836 | model: "gpt-5-codex", |
| 836 | }, | 837 | }, |
| 837 | runtimeMode: "full-access", | 838 | runtimeMode: "full-access", |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+7-7| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import { Effect, Layer, Option } from "effect"; | 3 | import { Effect, Layer, Option } from "effect"; |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | @@ -28,7 +28,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -28,7 +28,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 28 | title: "Null options project", | 28 | title: "Null options project", |
| 29 | workspaceRoot: "/tmp/project-null-options", | 29 | workspaceRoot: "/tmp/project-null-options", |
| 30 | defaultModelSelection: { | 30 | defaultModelSelection: { |
| 31 | provider: "codex", | 31 | instanceId: ProviderInstanceId.make("codex"), |
| 32 | model: "gpt-5.4", | 32 | model: "gpt-5.4", |
| 33 | }, | 33 | }, |
| 34 | scripts: [], | 34 | scripts: [], |
| ... | @@ -52,7 +52,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -52,7 +52,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 52 | assert.strictEqual( | 52 | assert.strictEqual( |
| 53 | row.defaultModelSelection, | 53 | row.defaultModelSelection, |
| 54 | JSON.stringify({ | 54 | JSON.stringify({ |
| 55 | provider: "codex", | 55 | instanceId: ProviderInstanceId.make("codex"), |
| 56 | model: "gpt-5.4", | 56 | model: "gpt-5.4", |
| 57 | }), | 57 | }), |
| 58 | ); | 58 | ); |
| ... | @@ -61,7 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -61,7 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 61 | projectId: ProjectId.make("project-null-options"), | 61 | projectId: ProjectId.make("project-null-options"), |
| 62 | }); | 62 | }); |
| 63 | assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { | 63 | assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { |
| 64 | provider: "codex", | 64 | instanceId: ProviderInstanceId.make("codex"), |
| 65 | model: "gpt-5.4", | 65 | model: "gpt-5.4", |
| 66 | }); | 66 | }); |
| 67 | }), | 67 | }), |
| ... | @@ -77,7 +77,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -77,7 +77,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 77 | projectId: ProjectId.make("project-null-options"), | 77 | projectId: ProjectId.make("project-null-options"), |
| 78 | title: "Null options thread", | 78 | title: "Null options thread", |
| 79 | modelSelection: { | 79 | modelSelection: { |
| 80 | provider: "claudeAgent", | 80 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 81 | model: "claude-opus-4-6", | 81 | model: "claude-opus-4-6", |
| 82 | }, | 82 | }, |
| 83 | runtimeMode: "full-access", | 83 | runtimeMode: "full-access", |
| ... | @@ -110,7 +110,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -110,7 +110,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 110 | assert.strictEqual( | 110 | assert.strictEqual( |
| 111 | row.modelSelection, | 111 | row.modelSelection, |
| 112 | JSON.stringify({ | 112 | JSON.stringify({ |
| 113 | provider: "claudeAgent", | 113 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 114 | model: "claude-opus-4-6", | 114 | model: "claude-opus-4-6", |
| 115 | }), | 115 | }), |
| 116 | ); | 116 | ); |
| ... | @@ -119,7 +119,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -119,7 +119,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 119 | threadId: ThreadId.make("thread-null-options"), | 119 | threadId: ThreadId.make("thread-null-options"), |
| 120 | }); | 120 | }); |
| 121 | assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { | 121 | assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { |
| 122 | provider: "claudeAgent", | 122 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 123 | model: "claude-opus-4-6", | 123 | model: "claude-opus-4-6", |
| 124 | }); | 124 | }); |
| 125 | }), | 125 | }), |
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts+4| ... | @@ -23,6 +23,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { | ... | @@ -23,6 +23,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 23 | thread_id, | 23 | thread_id, |
| 24 | status, | 24 | status, |
| 25 | provider_name, | 25 | provider_name, |
| 26 | provider_instance_id, | ||
| 26 | runtime_mode, | 27 | runtime_mode, |
| 27 | active_turn_id, | 28 | active_turn_id, |
| 28 | last_error, | 29 | last_error, |
| ... | @@ -32,6 +33,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { | ... | @@ -32,6 +33,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 32 | ${row.threadId}, | 33 | ${row.threadId}, |
| 33 | ${row.status}, | 34 | ${row.status}, |
| 34 | ${row.providerName}, | 35 | ${row.providerName}, |
| 36 | ${row.providerInstanceId}, | ||
| 35 | ${row.runtimeMode}, | 37 | ${row.runtimeMode}, |
| 36 | ${row.activeTurnId}, | 38 | ${row.activeTurnId}, |
| 37 | ${row.lastError}, | 39 | ${row.lastError}, |
| ... | @@ -41,6 +43,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { | ... | @@ -41,6 +43,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 41 | DO UPDATE SET | 43 | DO UPDATE SET |
| 42 | status = excluded.status, | 44 | status = excluded.status, |
| 43 | provider_name = excluded.provider_name, | 45 | provider_name = excluded.provider_name, |
| 46 | provider_instance_id = excluded.provider_instance_id, | ||
| 44 | runtime_mode = excluded.runtime_mode, | 47 | runtime_mode = excluded.runtime_mode, |
| 45 | active_turn_id = excluded.active_turn_id, | 48 | active_turn_id = excluded.active_turn_id, |
| 46 | last_error = excluded.last_error, | 49 | last_error = excluded.last_error, |
| ... | @@ -57,6 +60,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { | ... | @@ -57,6 +60,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 57 | thread_id AS "threadId", | 60 | thread_id AS "threadId", |
| 58 | status, | 61 | status, |
| 59 | provider_name AS "providerName", | 62 | provider_name AS "providerName", |
| 63 | provider_instance_id AS "providerInstanceId", | ||
| 60 | runtime_mode AS "runtimeMode", | 64 | runtime_mode AS "runtimeMode", |
| 61 | active_turn_id AS "activeTurnId", | 65 | active_turn_id AS "activeTurnId", |
| 62 | last_error AS "lastError", | 66 | last_error AS "lastError", |
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+5| ... | @@ -46,6 +46,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { | ... | @@ -46,6 +46,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 46 | INSERT INTO provider_session_runtime ( | 46 | INSERT INTO provider_session_runtime ( |
| 47 | thread_id, | 47 | thread_id, |
| 48 | provider_name, | 48 | provider_name, |
| 49 | provider_instance_id, | ||
| 49 | adapter_key, | 50 | adapter_key, |
| 50 | runtime_mode, | 51 | runtime_mode, |
| 51 | status, | 52 | status, |
| ... | @@ -56,6 +57,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { | ... | @@ -56,6 +57,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 56 | VALUES ( | 57 | VALUES ( |
| 57 | ${runtime.threadId}, | 58 | ${runtime.threadId}, |
| 58 | ${runtime.providerName}, | 59 | ${runtime.providerName}, |
| 60 | ${runtime.providerInstanceId}, | ||
| 59 | ${runtime.adapterKey}, | 61 | ${runtime.adapterKey}, |
| 60 | ${runtime.runtimeMode}, | 62 | ${runtime.runtimeMode}, |
| 61 | ${runtime.status}, | 63 | ${runtime.status}, |
| ... | @@ -66,6 +68,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { | ... | @@ -66,6 +68,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 66 | ON CONFLICT (thread_id) | 68 | ON CONFLICT (thread_id) |
| 67 | DO UPDATE SET | 69 | DO UPDATE SET |
| 68 | provider_name = excluded.provider_name, | 70 | provider_name = excluded.provider_name, |
| 71 | provider_instance_id = excluded.provider_instance_id, | ||
| 69 | adapter_key = excluded.adapter_key, | 72 | adapter_key = excluded.adapter_key, |
| 70 | runtime_mode = excluded.runtime_mode, | 73 | runtime_mode = excluded.runtime_mode, |
| 71 | status = excluded.status, | 74 | status = excluded.status, |
| ... | @@ -83,6 +86,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { | ... | @@ -83,6 +86,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 83 | SELECT | 86 | SELECT |
| 84 | thread_id AS "threadId", | 87 | thread_id AS "threadId", |
| 85 | provider_name AS "providerName", | 88 | provider_name AS "providerName", |
| 89 | provider_instance_id AS "providerInstanceId", | ||
| 86 | adapter_key AS "adapterKey", | 90 | adapter_key AS "adapterKey", |
| 87 | runtime_mode AS "runtimeMode", | 91 | runtime_mode AS "runtimeMode", |
| 88 | status, | 92 | status, |
| ... | @@ -102,6 +106,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { | ... | @@ -102,6 +106,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 102 | SELECT | 106 | SELECT |
| 103 | thread_id AS "threadId", | 107 | thread_id AS "threadId", |
| 104 | provider_name AS "providerName", | 108 | provider_name AS "providerName", |
| 109 | provider_instance_id AS "providerInstanceId", | ||
| 105 | adapter_key AS "adapterKey", | 110 | adapter_key AS "adapterKey", |
| 106 | runtime_mode AS "runtimeMode", | 111 | runtime_mode AS "runtimeMode", |
| 107 | status, | 112 | status, |
apps/server/src/persistence/Migrations.ts+4| ... | @@ -39,6 +39,8 @@ import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; | ... | @@ -39,6 +39,8 @@ import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; |
| 39 | import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; | 39 | import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; |
| 40 | import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; | 40 | import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; |
| 41 | import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; | 41 | import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; |
| 42 | import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; | ||
| 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; | ||
| 42 | 44 | ||
| 43 | /** | 45 | /** |
| 44 | * Migration loader with all migrations defined inline. | 46 | * Migration loader with all migrations defined inline. |
| ... | @@ -77,6 +79,8 @@ export const migrationEntries = [ | ... | @@ -77,6 +79,8 @@ export const migrationEntries = [ |
| 77 | [24, "BackfillProjectionThreadShellSummary", Migration0024], | 79 | [24, "BackfillProjectionThreadShellSummary", Migration0024], |
| 78 | [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], | 80 | [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], |
| 79 | [26, "CanonicalizeModelSelectionOptions", Migration0026], | 81 | [26, "CanonicalizeModelSelectionOptions", Migration0026], |
| 82 | [27, "ProviderSessionRuntimeInstanceId", Migration0027], | ||
| 83 | [28, "ProjectionThreadSessionInstanceId", Migration0028], | ||
| 80 | ] as const; | 84 | ] as const; |
| 81 | 85 | ||
| 82 | export const makeMigrationLoader = (throughId?: number) => | 86 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 4 | |||
| 5 | import { runMigrations } from "../Migrations.ts"; | ||
| 6 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | ||
| 7 | |||
| 8 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | ||
| 9 | |||
| 10 | layer("027_028_ProviderInstanceIdColumns", (it) => { | ||
| 11 | it.effect("continues when provider_session_runtime was partially migrated", () => | ||
| 12 | Effect.gen(function* () { | ||
| 13 | const sql = yield* SqlClient.SqlClient; | ||
| 14 | |||
| 15 | yield* runMigrations({ toMigrationInclusive: 26 }); | ||
| 16 | yield* sql` | ||
| 17 | ALTER TABLE provider_session_runtime | ||
| 18 | ADD COLUMN provider_instance_id TEXT | ||
| 19 | `; | ||
| 20 | |||
| 21 | yield* runMigrations({ toMigrationInclusive: 28 }); | ||
| 22 | |||
| 23 | const migrations = yield* sql<{ | ||
| 24 | readonly migration_id: number; | ||
| 25 | readonly name: string; | ||
| 26 | }>` | ||
| 27 | SELECT migration_id, name | ||
| 28 | FROM effect_sql_migrations | ||
| 29 | WHERE migration_id IN (27, 28) | ||
| 30 | ORDER BY migration_id | ||
| 31 | `; | ||
| 32 | assert.deepStrictEqual(migrations, [ | ||
| 33 | { | ||
| 34 | migration_id: 27, | ||
| 35 | name: "ProviderSessionRuntimeInstanceId", | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | migration_id: 28, | ||
| 39 | name: "ProjectionThreadSessionInstanceId", | ||
| 40 | }, | ||
| 41 | ]); | ||
| 42 | |||
| 43 | const providerSessionColumns = yield* sql<{ readonly name: string }>` | ||
| 44 | PRAGMA table_info(provider_session_runtime) | ||
| 45 | `; | ||
| 46 | assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); | ||
| 47 | |||
| 48 | const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` | ||
| 49 | PRAGMA table_info(projection_thread_sessions) | ||
| 50 | `; | ||
| 51 | assert.ok( | ||
| 52 | projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const providerSessionIndexes = yield* sql<{ readonly name: string }>` | ||
| 56 | PRAGMA index_list(provider_session_runtime) | ||
| 57 | `; | ||
| 58 | assert.ok( | ||
| 59 | providerSessionIndexes.some( | ||
| 60 | (index) => index.name === "idx_provider_session_runtime_instance", | ||
| 61 | ), | ||
| 62 | ); | ||
| 63 | |||
| 64 | const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` | ||
| 65 | PRAGMA index_list(projection_thread_sessions) | ||
| 66 | `; | ||
| 67 | assert.ok( | ||
| 68 | projectionThreadSessionIndexes.some( | ||
| 69 | (index) => index.name === "idx_projection_thread_sessions_instance", | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | }); | ||
apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | /** | ||
| 2 | * Adds the nullable `provider_instance_id` routing column to | ||
| 3 | * `provider_session_runtime`. | ||
| 4 | * | ||
| 5 | * Slice D of the provider-array refactor splits "driver kind" from | ||
| 6 | * "configured instance". Existing rows have only the driver name in | ||
| 7 | * `provider_name`; new rows additionally carry the user-defined instance | ||
| 8 | * routing key. The column remains nullable so legacy rows can still decode; | ||
| 9 | * the persistence boundary is responsible for materializing a concrete | ||
| 10 | * instance id before any hot routing path sees the binding. | ||
| 11 | * | ||
| 12 | * The column is nullable on purpose — backfilling it during the migration | ||
| 13 | * would require knowing which configured instance "owned" each historical | ||
| 14 | * session, and that mapping is ambiguous when the user later configures | ||
| 15 | * multiple instances of the same driver. Keeping that compatibility at the | ||
| 16 | * persistence boundary keeps the fallback out of active routing code. | ||
| 17 | */ | ||
| 18 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | |||
| 21 | export default Effect.gen(function* () { | ||
| 22 | const sql = yield* SqlClient.SqlClient; | ||
| 23 | |||
| 24 | const columns = yield* sql<{ readonly name: string }>` | ||
| 25 | PRAGMA table_info(provider_session_runtime) | ||
| 26 | `; | ||
| 27 | if (!columns.some((column) => column.name === "provider_instance_id")) { | ||
| 28 | yield* sql` | ||
| 29 | ALTER TABLE provider_session_runtime | ||
| 30 | ADD COLUMN provider_instance_id TEXT | ||
| 31 | `; | ||
| 32 | } | ||
| 33 | |||
| 34 | yield* sql` | ||
| 35 | CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_instance | ||
| 36 | ON provider_session_runtime(provider_instance_id) | ||
| 37 | `; | ||
| 38 | }); | ||
apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | const columns = yield* sql<{ readonly name: string }>` | ||
| 8 | PRAGMA table_info(projection_thread_sessions) | ||
| 9 | `; | ||
| 10 | if (!columns.some((column) => column.name === "provider_instance_id")) { | ||
| 11 | yield* sql` | ||
| 12 | ALTER TABLE projection_thread_sessions | ||
| 13 | ADD COLUMN provider_instance_id TEXT | ||
| 14 | `; | ||
| 15 | } | ||
| 16 | |||
| 17 | yield* sql` | ||
| 18 | CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_instance | ||
| 19 | ON projection_thread_sessions(provider_instance_id) | ||
| 20 | `; | ||
| 21 | }); | ||
apps/server/src/persistence/Services/ProjectionThreadSessions.ts+2| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | RuntimeMode, | 10 | RuntimeMode, |
| 11 | IsoDateTime, | 11 | IsoDateTime, |
| 12 | OrchestrationSessionStatus, | 12 | OrchestrationSessionStatus, |
| 13 | ProviderInstanceId, | ||
| 13 | ThreadId, | 14 | ThreadId, |
| 14 | TurnId, | 15 | TurnId, |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| ... | @@ -22,6 +23,7 @@ export const ProjectionThreadSession = Schema.Struct({ | ... | @@ -22,6 +23,7 @@ export const ProjectionThreadSession = Schema.Struct({ |
| 22 | threadId: ThreadId, | 23 | threadId: ThreadId, |
| 23 | status: OrchestrationSessionStatus, | 24 | status: OrchestrationSessionStatus, |
| 24 | providerName: Schema.NullOr(Schema.String), | 25 | providerName: Schema.NullOr(Schema.String), |
| 26 | providerInstanceId: Schema.NullOr(ProviderInstanceId), | ||
| 25 | runtimeMode: RuntimeMode, | 27 | runtimeMode: RuntimeMode, |
| 26 | activeTurnId: Schema.NullOr(TurnId), | 28 | activeTurnId: Schema.NullOr(TurnId), |
| 27 | lastError: Schema.NullOr(Schema.String), | 29 | lastError: Schema.NullOr(Schema.String), |
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+9| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | */ | 7 | */ |
| 8 | import { | 8 | import { |
| 9 | IsoDateTime, | 9 | IsoDateTime, |
| 10 | ProviderInstanceId, | ||
| 10 | ProviderSessionRuntimeStatus, | 11 | ProviderSessionRuntimeStatus, |
| 11 | RuntimeMode, | 12 | RuntimeMode, |
| 12 | ThreadId, | 13 | ThreadId, |
| ... | @@ -19,6 +20,14 @@ import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; | ... | @@ -19,6 +20,14 @@ import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; |
| 19 | export const ProviderSessionRuntime = Schema.Struct({ | 20 | export const ProviderSessionRuntime = Schema.Struct({ |
| 20 | threadId: ThreadId, | 21 | threadId: ThreadId, |
| 21 | providerName: Schema.String, | 22 | providerName: Schema.String, |
| 23 | /** | ||
| 24 | * User-defined routing key for the configured provider instance that | ||
| 25 | * owns this session. Nullable only at the storage/migration boundary: | ||
| 26 | * rows persisted before the driver/instance split carry only | ||
| 27 | * `providerName`. Repository consumers must materialize a concrete | ||
| 28 | * instance id before routing. | ||
| 29 | */ | ||
| 30 | providerInstanceId: Schema.NullOr(ProviderInstanceId), | ||
| 22 | adapterKey: Schema.String, | 31 | adapterKey: Schema.String, |
| 23 | runtimeMode: RuntimeMode, | 32 | runtimeMode: RuntimeMode, |
| 24 | status: ProviderSessionRuntimeStatus, | 33 | status: ProviderSessionRuntimeStatus, |
apps/server/src/provider/Drivers/ClaudeDriver.ts created+157| ... | @@ -0,0 +1,157 @@ | ||
| 1 | /** | ||
| 2 | * ClaudeDriver — `ProviderDriver` for the Claude Agent SDK runtime. | ||
| 3 | * | ||
| 4 | * Mirrors `CodexDriver`: a plain value whose `create()` returns one | ||
| 5 | * `ProviderInstance` bundling `snapshot` / `adapter` / `textGeneration` | ||
| 6 | * closures captured over the per-instance `ClaudeSettings`. | ||
| 7 | * | ||
| 8 | * Unlike Codex, the Claude snapshot probe may invoke a secondary probe | ||
| 9 | * (`probeClaudeCapabilities`) to read Anthropic account + slash-command | ||
| 10 | * metadata. That probe is per-instance and keyed by binary + resolved HOME so | ||
| 11 | * two concurrent Claude instances don't cross-contaminate account metadata. | ||
| 12 | * | ||
| 13 | * @module provider/Drivers/ClaudeDriver | ||
| 14 | */ | ||
| 15 | import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | ||
| 16 | import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 18 | |||
| 19 | import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts"; | ||
| 20 | import { ServerConfig } from "../../config.ts"; | ||
| 21 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 22 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; | ||
| 23 | import { | ||
| 24 | checkClaudeProviderStatus, | ||
| 25 | makePendingClaudeProvider, | ||
| 26 | probeClaudeCapabilities, | ||
| 27 | } from "../Layers/ClaudeProvider.ts"; | ||
| 28 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 29 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 30 | import { | ||
| 31 | defaultProviderContinuationIdentity, | ||
| 32 | type ProviderDriver, | ||
| 33 | type ProviderInstance, | ||
| 34 | } from "../ProviderDriver.ts"; | ||
| 35 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| 36 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 37 | import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; | ||
| 38 | |||
| 39 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | ||
| 40 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 41 | const CAPABILITIES_PROBE_TTL = Duration.minutes(5); | ||
| 42 | |||
| 43 | export type ClaudeDriverEnv = | ||
| 44 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 45 | | FileSystem.FileSystem | ||
| 46 | | Path.Path | ||
| 47 | | ProviderEventLoggers | ||
| 48 | | ServerConfig; | ||
| 49 | |||
| 50 | const withInstanceIdentity = | ||
| 51 | (input: { | ||
| 52 | readonly instanceId: ProviderInstance["instanceId"]; | ||
| 53 | readonly displayName: string | undefined; | ||
| 54 | readonly accentColor: string | undefined; | ||
| 55 | readonly continuationGroupKey: string; | ||
| 56 | }) => | ||
| 57 | (snapshot: ServerProviderDraft): ServerProvider => ({ | ||
| 58 | ...snapshot, | ||
| 59 | instanceId: input.instanceId, | ||
| 60 | driver: DRIVER_KIND, | ||
| 61 | ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| 62 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| 63 | continuation: { groupKey: input.continuationGroupKey }, | ||
| 64 | }); | ||
| 65 | |||
| 66 | export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ||
| 67 | driverKind: DRIVER_KIND, | ||
| 68 | metadata: { | ||
| 69 | displayName: "Claude", | ||
| 70 | supportsMultipleInstances: true, | ||
| 71 | }, | ||
| 72 | configSchema: ClaudeSettings, | ||
| 73 | defaultConfig: (): ClaudeSettings => Schema.decodeSync(ClaudeSettings)({}), | ||
| 74 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | ||
| 75 | Effect.gen(function* () { | ||
| 76 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 77 | const path = yield* Path.Path; | ||
| 78 | const eventLoggers = yield* ProviderEventLoggers; | ||
| 79 | const processEnv = mergeProviderInstanceEnvironment(environment); | ||
| 80 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ | ||
| 81 | driverKind: DRIVER_KIND, | ||
| 82 | instanceId, | ||
| 83 | }); | ||
| 84 | const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; | ||
| 85 | const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); | ||
| 86 | const stampIdentity = withInstanceIdentity({ | ||
| 87 | instanceId, | ||
| 88 | displayName, | ||
| 89 | accentColor, | ||
| 90 | continuationGroupKey, | ||
| 91 | }); | ||
| 92 | |||
| 93 | const adapterOptions = { | ||
| 94 | instanceId, | ||
| 95 | environment: processEnv, | ||
| 96 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 97 | }; | ||
| 98 | const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); | ||
| 99 | const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); | ||
| 100 | |||
| 101 | // Per-instance capabilities cache: keyed on binary + resolved HOME so | ||
| 102 | // account-specific probes never share auth metadata across instances. | ||
| 103 | const capabilitiesProbeCache = yield* Cache.make({ | ||
| 104 | capacity: 1, | ||
| 105 | timeToLive: CAPABILITIES_PROBE_TTL, | ||
| 106 | lookup: () => | ||
| 107 | probeClaudeCapabilities(effectiveConfig, processEnv).pipe( | ||
| 108 | Effect.provideService(Path.Path, path), | ||
| 109 | ), | ||
| 110 | }); | ||
| 111 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); | ||
| 112 | |||
| 113 | const checkProvider = checkClaudeProviderStatus( | ||
| 114 | effectiveConfig, | ||
| 115 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | ||
| 116 | processEnv, | ||
| 117 | ).pipe( | ||
| 118 | Effect.map(stampIdentity), | ||
| 119 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 120 | Effect.provideService(Path.Path, path), | ||
| 121 | ); | ||
| 122 | |||
| 123 | const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({ | ||
| 124 | getSettings: Effect.succeed(effectiveConfig), | ||
| 125 | streamSettings: Stream.never, | ||
| 126 | haveSettingsChanged: () => false, | ||
| 127 | initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), | ||
| 128 | checkProvider, | ||
| 129 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 130 | }).pipe( | ||
| 131 | Effect.mapError( | ||
| 132 | (cause) => | ||
| 133 | new ProviderDriverError({ | ||
| 134 | driver: DRIVER_KIND, | ||
| 135 | instanceId, | ||
| 136 | detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, | ||
| 137 | cause, | ||
| 138 | }), | ||
| 139 | ), | ||
| 140 | ); | ||
| 141 | |||
| 142 | return { | ||
| 143 | instanceId, | ||
| 144 | driverKind: DRIVER_KIND, | ||
| 145 | continuationIdentity: { | ||
| 146 | ...fallbackContinuationIdentity, | ||
| 147 | continuationKey: continuationGroupKey, | ||
| 148 | }, | ||
| 149 | displayName, | ||
| 150 | accentColor, | ||
| 151 | enabled, | ||
| 152 | snapshot, | ||
| 153 | adapter, | ||
| 154 | textGeneration, | ||
| 155 | } satisfies ProviderInstance; | ||
| 156 | }), | ||
| 157 | }; | ||
apps/server/src/provider/Drivers/ClaudeHome.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import { Effect, Path } from "effect"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | makeClaudeCapabilitiesCacheKey, | ||
| 9 | makeClaudeContinuationGroupKey, | ||
| 10 | makeClaudeEnvironment, | ||
| 11 | resolveClaudeHomePath, | ||
| 12 | } from "./ClaudeHome.ts"; | ||
| 13 | |||
| 14 | it.layer(NodeServices.layer)("ClaudeHome", (it) => { | ||
| 15 | describe("Claude home resolution", () => { | ||
| 16 | it.effect("uses the process home when no Claude home override is configured", () => | ||
| 17 | Effect.gen(function* () { | ||
| 18 | const path = yield* Path.Path; | ||
| 19 | const resolved = path.resolve(NodeOS.homedir()); | ||
| 20 | |||
| 21 | expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); | ||
| 22 | expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); | ||
| 23 | }), | ||
| 24 | ); | ||
| 25 | |||
| 26 | it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const path = yield* Path.Path; | ||
| 29 | const homePath = "~/.claude-work"; | ||
| 30 | const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); | ||
| 31 | |||
| 32 | expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); | ||
| 33 | expect((yield* makeClaudeEnvironment({ homePath })).HOME).toBe(resolved); | ||
| 34 | expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); | ||
| 35 | expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( | ||
| 36 | `claude\0${resolved}`, | ||
| 37 | ); | ||
| 38 | }), | ||
| 39 | ); | ||
| 40 | |||
| 41 | it.effect("keeps continuation compatible across instances with the same Claude HOME", () => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const path = yield* Path.Path; | ||
| 44 | const resolved = path.resolve(NodeOS.homedir()); | ||
| 45 | |||
| 46 | expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( | ||
| 47 | `claude:home:${resolved}`, | ||
| 48 | ); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/server/src/provider/Drivers/ClaudeHome.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 3 | import type { ClaudeSettings } from "@t3tools/contracts"; | ||
| 4 | import { Effect, Path } from "effect"; | ||
| 5 | |||
| 6 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 7 | |||
| 8 | export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( | ||
| 9 | config: Pick<ClaudeSettings, "homePath">, | ||
| 10 | ): Effect.fn.Return<string, never, Path.Path> { | ||
| 11 | const path = yield* Path.Path; | ||
| 12 | const homePath = config.homePath.trim(); | ||
| 13 | return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); | ||
| 14 | }); | ||
| 15 | |||
| 16 | export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( | ||
| 17 | config: Pick<ClaudeSettings, "homePath">, | ||
| 18 | baseEnv: NodeJS.ProcessEnv = process.env, | ||
| 19 | ): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> { | ||
| 20 | const homePath = config.homePath.trim(); | ||
| 21 | if (homePath.length === 0) return baseEnv; | ||
| 22 | const resolvedHomePath = yield* resolveClaudeHomePath(config); | ||
| 23 | return { | ||
| 24 | ...baseEnv, | ||
| 25 | HOME: resolvedHomePath, | ||
| 26 | }; | ||
| 27 | }); | ||
| 28 | |||
| 29 | export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( | ||
| 30 | function* (config: Pick<ClaudeSettings, "homePath">): Effect.fn.Return<string, never, Path.Path> { | ||
| 31 | const resolvedHomePath = yield* resolveClaudeHomePath(config); | ||
| 32 | return `claude:home:${resolvedHomePath}`; | ||
| 33 | }, | ||
| 34 | ); | ||
| 35 | |||
| 36 | export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( | ||
| 37 | function* ( | ||
| 38 | config: Pick<ClaudeSettings, "binaryPath" | "homePath">, | ||
| 39 | ): Effect.fn.Return<string, never, Path.Path> { | ||
| 40 | const resolvedHomePath = yield* resolveClaudeHomePath(config); | ||
| 41 | return `${config.binaryPath}\0${resolvedHomePath}`; | ||
| 42 | }, | ||
| 43 | ); | ||
apps/server/src/provider/Drivers/CodexDriver.ts created+171| ... | @@ -0,0 +1,171 @@ | ||
| 1 | /** | ||
| 2 | * CodexDriver — first concrete `ProviderDriver` in the new per-instance model. | ||
| 3 | * | ||
| 4 | * A driver is a plain value (not a Context.Service) whose `create()` returns | ||
| 5 | * one `ProviderInstance` bundling: | ||
| 6 | * - `snapshot` — the live `ServerProviderShape` for this instance; | ||
| 7 | * - `adapter` — the Codex session/turn/approval runtime; | ||
| 8 | * - `textGeneration` — commit/PR/branch/title generation via `codex exec`. | ||
| 9 | * | ||
| 10 | * Each call to `create()` captures the `codexConfig` argument in closures | ||
| 11 | * owned by the returned instance. Two instances created with different | ||
| 12 | * `homePath`s (e.g. `codex_personal` + `codex_work`) therefore run with | ||
| 13 | * fully independent Codex app-server processes and `CODEX_HOME` | ||
| 14 | * environments — no shared mutable state. | ||
| 15 | * | ||
| 16 | * Resource lifecycle: `create()` runs in a scope handed in by the registry. | ||
| 17 | * Closing that scope releases the adapter's child processes, the managed | ||
| 18 | * snapshot's refresh fibre, and the text-generation binaries' transient | ||
| 19 | * scratch files. The registry uses this to tear down an instance when its | ||
| 20 | * `providerInstances` entry disappears or its config changes. | ||
| 21 | * | ||
| 22 | * @module provider/Drivers/CodexDriver | ||
| 23 | */ | ||
| 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | ||
| 25 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | ||
| 26 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 27 | |||
| 28 | import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts"; | ||
| 29 | import { ServerConfig } from "../../config.ts"; | ||
| 30 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 31 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | ||
| 32 | import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; | ||
| 33 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 34 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 35 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; | ||
| 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 38 | import { | ||
| 39 | codexContinuationIdentity, | ||
| 40 | materializeCodexShadowHome, | ||
| 41 | resolveCodexHomeLayout, | ||
| 42 | } from "./CodexHomeLayout.ts"; | ||
| 43 | |||
| 44 | const DRIVER_KIND = ProviderDriverKind.make("codex"); | ||
| 45 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 46 | |||
| 47 | /** | ||
| 48 | * Services the driver needs to materialize an instance. Surfaced as the | ||
| 49 | * driver's `R` so the registry layer aggregates these across every | ||
| 50 | * registered driver and the runtime satisfies them once. | ||
| 51 | */ | ||
| 52 | export type CodexDriverEnv = | ||
| 53 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 54 | | FileSystem.FileSystem | ||
| 55 | | Path.Path | ||
| 56 | | ProviderEventLoggers | ||
| 57 | | ServerConfig; | ||
| 58 | |||
| 59 | /** | ||
| 60 | * Stamp instance identity onto a `ServerProvider` snapshot produced by the | ||
| 61 | * driver-kind-only codex helpers. Once `buildServerProvider` in | ||
| 62 | * `providerSnapshot.ts` is widened to accept `instanceId`/`driver`, this | ||
| 63 | * wrapper disappears. | ||
| 64 | */ | ||
| 65 | const withInstanceIdentity = | ||
| 66 | (input: { | ||
| 67 | readonly instanceId: ProviderInstance["instanceId"]; | ||
| 68 | readonly displayName: string | undefined; | ||
| 69 | readonly accentColor: string | undefined; | ||
| 70 | readonly continuationGroupKey: string; | ||
| 71 | }) => | ||
| 72 | (snapshot: ServerProviderDraft): ServerProvider => ({ | ||
| 73 | ...snapshot, | ||
| 74 | instanceId: input.instanceId, | ||
| 75 | driver: DRIVER_KIND, | ||
| 76 | ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| 77 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| 78 | continuation: { groupKey: input.continuationGroupKey }, | ||
| 79 | }); | ||
| 80 | |||
| 81 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ||
| 82 | driverKind: DRIVER_KIND, | ||
| 83 | metadata: { | ||
| 84 | displayName: "Codex", | ||
| 85 | supportsMultipleInstances: true, | ||
| 86 | }, | ||
| 87 | configSchema: CodexSettings, | ||
| 88 | defaultConfig: (): CodexSettings => Schema.decodeSync(CodexSettings)({}), | ||
| 89 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 92 | const eventLoggers = yield* ProviderEventLoggers; | ||
| 93 | const processEnv = mergeProviderInstanceEnvironment(environment); | ||
| 94 | const homeLayout = yield* resolveCodexHomeLayout(config); | ||
| 95 | const continuationIdentity = codexContinuationIdentity(homeLayout); | ||
| 96 | const stampIdentity = withInstanceIdentity({ | ||
| 97 | instanceId, | ||
| 98 | displayName, | ||
| 99 | accentColor, | ||
| 100 | continuationGroupKey: continuationIdentity.continuationKey, | ||
| 101 | }); | ||
| 102 | yield* materializeCodexShadowHome(homeLayout).pipe( | ||
| 103 | Effect.mapError( | ||
| 104 | (cause) => | ||
| 105 | new ProviderDriverError({ | ||
| 106 | driver: DRIVER_KIND, | ||
| 107 | instanceId, | ||
| 108 | detail: cause.message, | ||
| 109 | cause, | ||
| 110 | }), | ||
| 111 | ), | ||
| 112 | ); | ||
| 113 | const effectiveConfig = { | ||
| 114 | ...config, | ||
| 115 | enabled, | ||
| 116 | homePath: homeLayout.effectiveHomePath ?? "", | ||
| 117 | } satisfies CodexSettings; | ||
| 118 | |||
| 119 | // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error | ||
| 120 | // channels at construction time — their failure modes are all on the | ||
| 121 | // per-operation closures they return. No `mapError` wrapper is needed | ||
| 122 | // here; the registry only has to worry about snapshot-build and | ||
| 123 | // spawner-availability failures surfaced from `checkCodexProviderStatus` | ||
| 124 | // below. | ||
| 125 | const adapter = yield* makeCodexAdapter(effectiveConfig, { | ||
| 126 | instanceId, | ||
| 127 | environment: processEnv, | ||
| 128 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 129 | }); | ||
| 130 | const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); | ||
| 131 | |||
| 132 | // Build a managed snapshot whose settings never change — mutations come | ||
| 133 | // in as instance rebuilds from the registry rather than in-place | ||
| 134 | // updates. Pre-provide `ChildProcessSpawner` so the check fits | ||
| 135 | // `makeManagedServerProvider.checkProvider`'s `R = never`. | ||
| 136 | const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( | ||
| 137 | Effect.map(stampIdentity), | ||
| 138 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 139 | ); | ||
| 140 | const snapshot = yield* makeManagedServerProvider<CodexSettings>({ | ||
| 141 | getSettings: Effect.succeed(effectiveConfig), | ||
| 142 | streamSettings: Stream.never, | ||
| 143 | haveSettingsChanged: () => false, | ||
| 144 | initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), | ||
| 145 | checkProvider, | ||
| 146 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 147 | }).pipe( | ||
| 148 | Effect.mapError( | ||
| 149 | (cause) => | ||
| 150 | new ProviderDriverError({ | ||
| 151 | driver: DRIVER_KIND, | ||
| 152 | instanceId, | ||
| 153 | detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, | ||
| 154 | cause, | ||
| 155 | }), | ||
| 156 | ), | ||
| 157 | ); | ||
| 158 | |||
| 159 | return { | ||
| 160 | instanceId, | ||
| 161 | driverKind: DRIVER_KIND, | ||
| 162 | continuationIdentity, | ||
| 163 | displayName, | ||
| 164 | accentColor, | ||
| 165 | enabled, | ||
| 166 | snapshot, | ||
| 167 | adapter, | ||
| 168 | textGeneration, | ||
| 169 | } satisfies ProviderInstance; | ||
| 170 | }), | ||
| 171 | }; | ||
apps/server/src/provider/Drivers/CodexHomeLayout.test.ts created+209| ... | @@ -0,0 +1,209 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Path, Schema } from "effect"; | ||
| 4 | |||
| 5 | import { CodexSettings } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | CodexShadowHomeError, | ||
| 8 | materializeCodexShadowHome, | ||
| 9 | resolveCodexHomeLayout, | ||
| 10 | } from "./CodexHomeLayout.ts"; | ||
| 11 | |||
| 12 | const decodeCodexSettings = (input: { | ||
| 13 | readonly enabled?: boolean; | ||
| 14 | readonly homePath?: string; | ||
| 15 | readonly shadowHomePath?: string; | ||
| 16 | readonly customModels?: readonly string[]; | ||
| 17 | readonly binaryPath?: string; | ||
| 18 | }): CodexSettings => Schema.decodeSync(CodexSettings)(input); | ||
| 19 | |||
| 20 | const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) { | ||
| 21 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 22 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* ( | ||
| 26 | filePath: string, | ||
| 27 | contents: string, | ||
| 28 | ) { | ||
| 29 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 30 | const path = yield* Path.Path; | ||
| 31 | yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); | ||
| 32 | yield* fileSystem.writeFileString(filePath, contents); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { | ||
| 36 | describe("resolveCodexHomeLayout", () => { | ||
| 37 | it.effect("uses direct CODEX_HOME when no shadow home is configured", () => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const homePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 40 | |||
| 41 | const layout = yield* resolveCodexHomeLayout( | ||
| 42 | decodeCodexSettings({ | ||
| 43 | homePath, | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | expect(layout).toMatchObject({ | ||
| 48 | mode: "direct", | ||
| 49 | sharedHomePath: homePath, | ||
| 50 | effectiveHomePath: homePath, | ||
| 51 | continuationKey: `codex:home:${homePath}`, | ||
| 52 | }); | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | |||
| 56 | it.effect("uses the shared home for continuation and the shadow home for runtime", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const path = yield* Path.Path; | ||
| 59 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 60 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); | ||
| 61 | const shadowHome = path.join(shadowRoot, "shadow"); | ||
| 62 | |||
| 63 | const layout = yield* resolveCodexHomeLayout( | ||
| 64 | decodeCodexSettings({ | ||
| 65 | homePath: sharedHome, | ||
| 66 | shadowHomePath: shadowHome, | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | |||
| 70 | expect(layout).toMatchObject({ | ||
| 71 | mode: "authOverlay", | ||
| 72 | sharedHomePath: sharedHome, | ||
| 73 | effectiveHomePath: shadowHome, | ||
| 74 | continuationKey: `codex:home:${sharedHome}`, | ||
| 75 | }); | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | }); | ||
| 79 | |||
| 80 | describe("materializeCodexShadowHome", () => { | ||
| 81 | it.effect("materializes a shadow home with shared state links and private auth", () => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 84 | const path = yield* Path.Path; | ||
| 85 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 86 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); | ||
| 87 | const shadowHome = path.join(shadowRoot, "shadow"); | ||
| 88 | |||
| 89 | yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions")); | ||
| 90 | yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); | ||
| 91 | yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n'); | ||
| 92 | yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); | ||
| 93 | yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); | ||
| 94 | yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); | ||
| 95 | yield* fileSystem.symlink( | ||
| 96 | path.join(sharedHome, "models_cache.json"), | ||
| 97 | path.join(shadowHome, "models_cache.json"), | ||
| 98 | ); | ||
| 99 | |||
| 100 | const layout = yield* resolveCodexHomeLayout( | ||
| 101 | decodeCodexSettings({ | ||
| 102 | homePath: sharedHome, | ||
| 103 | shadowHomePath: shadowHome, | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | |||
| 107 | yield* materializeCodexShadowHome(layout); | ||
| 108 | |||
| 109 | const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); | ||
| 110 | const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); | ||
| 111 | const modelsCacheExists = yield* fileSystem.exists( | ||
| 112 | path.join(shadowHome, "models_cache.json"), | ||
| 113 | ); | ||
| 114 | const authLinkResult = yield* fileSystem | ||
| 115 | .readLink(path.join(shadowHome, "auth.json")) | ||
| 116 | .pipe(Effect.result); | ||
| 117 | const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); | ||
| 118 | |||
| 119 | expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); | ||
| 120 | expect(configTarget).toBe(path.join(sharedHome, "config.toml")); | ||
| 121 | expect(modelsCacheExists).toBe(false); | ||
| 122 | expect(authLinkResult._tag).toBe("Failure"); | ||
| 123 | expect(authContents).toContain("shadow"); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("accepts Codex-created shadow-local runtime directories", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 130 | const path = yield* Path.Path; | ||
| 131 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 132 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); | ||
| 133 | const shadowHome = path.join(shadowRoot, "shadow"); | ||
| 134 | |||
| 135 | yield* fileSystem.makeDirectory(path.join(sharedHome, "log")); | ||
| 136 | yield* fileSystem.makeDirectory(path.join(sharedHome, "memories")); | ||
| 137 | yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp")); | ||
| 138 | yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); | ||
| 139 | yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); | ||
| 140 | yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true }); | ||
| 141 | yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true }); | ||
| 142 | yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true }); | ||
| 143 | |||
| 144 | const layout = yield* resolveCodexHomeLayout( | ||
| 145 | decodeCodexSettings({ | ||
| 146 | homePath: sharedHome, | ||
| 147 | shadowHomePath: shadowHome, | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | |||
| 151 | yield* materializeCodexShadowHome(layout); | ||
| 152 | |||
| 153 | const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); | ||
| 154 | const logLinkResult = yield* fileSystem | ||
| 155 | .readLink(path.join(shadowHome, "log")) | ||
| 156 | .pipe(Effect.result); | ||
| 157 | const memoriesLinkResult = yield* fileSystem | ||
| 158 | .readLink(path.join(shadowHome, "memories")) | ||
| 159 | .pipe(Effect.result); | ||
| 160 | const tmpLinkResult = yield* fileSystem | ||
| 161 | .readLink(path.join(shadowHome, "tmp")) | ||
| 162 | .pipe(Effect.result); | ||
| 163 | |||
| 164 | expect(configTarget).toBe(path.join(sharedHome, "config.toml")); | ||
| 165 | expect(logLinkResult._tag).toBe("Failure"); | ||
| 166 | expect(memoriesLinkResult._tag).toBe("Failure"); | ||
| 167 | expect(tmpLinkResult._tag).toBe("Failure"); | ||
| 168 | }), | ||
| 169 | ); | ||
| 170 | |||
| 171 | it.effect("rejects shadow homes that point at the shared home", () => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 174 | const layout = yield* resolveCodexHomeLayout( | ||
| 175 | decodeCodexSettings({ | ||
| 176 | homePath: sharedHome, | ||
| 177 | shadowHomePath: sharedHome, | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | |||
| 181 | const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); | ||
| 182 | |||
| 183 | expect(error).toBeInstanceOf(CodexShadowHomeError); | ||
| 184 | }), | ||
| 185 | ); | ||
| 186 | |||
| 187 | it.effect("rejects shared entries that already exist in the shadow home as real files", () => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | const path = yield* Path.Path; | ||
| 190 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 191 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); | ||
| 192 | const shadowHome = path.join(shadowRoot, "shadow"); | ||
| 193 | yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); | ||
| 194 | yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); | ||
| 195 | |||
| 196 | const layout = yield* resolveCodexHomeLayout( | ||
| 197 | decodeCodexSettings({ | ||
| 198 | homePath: sharedHome, | ||
| 199 | shadowHomePath: shadowHome, | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | |||
| 203 | const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); | ||
| 204 | |||
| 205 | expect(error.detail).toContain("already exists and is not a symlink"); | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | }); | ||
| 209 | }); | ||
apps/server/src/provider/Drivers/CodexHomeLayout.ts created+263| ... | @@ -0,0 +1,263 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 3 | import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; | ||
| 4 | import { Effect, FileSystem, Path, Schema } from "effect"; | ||
| 5 | import * as PlatformError from "effect/PlatformError"; | ||
| 6 | |||
| 7 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 8 | |||
| 9 | export interface CodexHomeLayout { | ||
| 10 | readonly mode: "direct" | "authOverlay"; | ||
| 11 | readonly sharedHomePath: string; | ||
| 12 | readonly effectiveHomePath: string | undefined; | ||
| 13 | readonly continuationKey: string; | ||
| 14 | } | ||
| 15 | |||
| 16 | const KNOWN_SHARED_DIRECTORIES = [ | ||
| 17 | "sessions", | ||
| 18 | "archived_sessions", | ||
| 19 | "sqlite", | ||
| 20 | "shell_snapshots", | ||
| 21 | "worktrees", | ||
| 22 | "skills", | ||
| 23 | "plugins", | ||
| 24 | "cache", | ||
| 25 | "logs", | ||
| 26 | ] as const; | ||
| 27 | |||
| 28 | const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); | ||
| 29 | const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); | ||
| 30 | |||
| 31 | function resolveHomePath(path: Path.Path, value: string | undefined): string { | ||
| 32 | const expanded = | ||
| 33 | value && value.trim().length > 0 | ||
| 34 | ? expandHomePath(value) | ||
| 35 | : path.join(NodeOS.homedir(), ".codex"); | ||
| 36 | return path.resolve(expanded); | ||
| 37 | } | ||
| 38 | |||
| 39 | export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(function* ( | ||
| 40 | config: CodexSettings, | ||
| 41 | ): Effect.fn.Return<CodexHomeLayout, never, Path.Path> { | ||
| 42 | const path = yield* Path.Path; | ||
| 43 | const sharedHomePath = resolveHomePath(path, config.homePath); | ||
| 44 | const shadowHomePath = config.shadowHomePath.trim(); | ||
| 45 | if (shadowHomePath.length === 0) { | ||
| 46 | return { | ||
| 47 | mode: "direct", | ||
| 48 | sharedHomePath, | ||
| 49 | effectiveHomePath: config.homePath.trim().length > 0 ? sharedHomePath : undefined, | ||
| 50 | continuationKey: `codex:home:${sharedHomePath}`, | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath)); | ||
| 55 | return { | ||
| 56 | mode: "authOverlay", | ||
| 57 | sharedHomePath, | ||
| 58 | effectiveHomePath, | ||
| 59 | continuationKey: `codex:home:${sharedHomePath}`, | ||
| 60 | }; | ||
| 61 | }); | ||
| 62 | |||
| 63 | export class CodexShadowHomeError extends Schema.TaggedErrorClass<CodexShadowHomeError>()( | ||
| 64 | "CodexShadowHomeError", | ||
| 65 | { | ||
| 66 | detail: Schema.String, | ||
| 67 | cause: Schema.optional(Schema.Unknown), | ||
| 68 | }, | ||
| 69 | ) { | ||
| 70 | override get message(): string { | ||
| 71 | return this.detail; | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | type LinkState = | ||
| 76 | | { | ||
| 77 | readonly _tag: "Missing"; | ||
| 78 | } | ||
| 79 | | { | ||
| 80 | readonly _tag: "NotSymlink"; | ||
| 81 | } | ||
| 82 | | { | ||
| 83 | readonly _tag: "Symlink"; | ||
| 84 | readonly target: string; | ||
| 85 | }; | ||
| 86 | |||
| 87 | function toShadowHomeError(cause: unknown): CodexShadowHomeError { | ||
| 88 | return Schema.is(CodexShadowHomeError)(cause) | ||
| 89 | ? cause | ||
| 90 | : new CodexShadowHomeError({ | ||
| 91 | detail: "Failed to materialize Codex shadow home.", | ||
| 92 | cause, | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | function normalizeShadowHomeError<A, E, R>( | ||
| 97 | effect: Effect.Effect<A, E, R>, | ||
| 98 | ): Effect.Effect<A, CodexShadowHomeError, R> { | ||
| 99 | return effect.pipe(Effect.mapError(toShadowHomeError)); | ||
| 100 | } | ||
| 101 | |||
| 102 | function isNotSymlinkError(error: PlatformError.PlatformError): boolean { | ||
| 103 | const cause = error.reason.cause; | ||
| 104 | return ( | ||
| 105 | error.reason._tag === "Unknown" && | ||
| 106 | typeof cause === "object" && | ||
| 107 | cause !== null && | ||
| 108 | "code" in cause && | ||
| 109 | cause.code === "EINVAL" | ||
| 110 | ); | ||
| 111 | } | ||
| 112 | |||
| 113 | const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( | ||
| 114 | fileSystem: FileSystem.FileSystem, | ||
| 115 | linkPath: string, | ||
| 116 | ): Effect.fn.Return<LinkState, CodexShadowHomeError> { | ||
| 117 | return yield* fileSystem.readLink(linkPath).pipe( | ||
| 118 | Effect.map((target): LinkState => ({ _tag: "Symlink", target })), | ||
| 119 | Effect.catch((error) => { | ||
| 120 | if (error.reason._tag === "NotFound") { | ||
| 121 | return Effect.succeed<LinkState>({ _tag: "Missing" }); | ||
| 122 | } | ||
| 123 | if (isNotSymlinkError(error)) { | ||
| 124 | return Effect.succeed<LinkState>({ _tag: "NotSymlink" }); | ||
| 125 | } | ||
| 126 | return Effect.fail(toShadowHomeError(error)); | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | }); | ||
| 130 | |||
| 131 | const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { | ||
| 132 | readonly fileSystem: FileSystem.FileSystem; | ||
| 133 | readonly shadowPath: string; | ||
| 134 | readonly entryName: string; | ||
| 135 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { | ||
| 136 | const path = yield* Path.Path; | ||
| 137 | const privatePath = path.join(input.shadowPath, input.entryName); | ||
| 138 | const state = yield* readLinkState(input.fileSystem, privatePath); | ||
| 139 | if (state._tag === "Symlink") { | ||
| 140 | yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); | ||
| 141 | } | ||
| 142 | }); | ||
| 143 | |||
| 144 | const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { | ||
| 145 | readonly fileSystem: FileSystem.FileSystem; | ||
| 146 | readonly shadowPath: string; | ||
| 147 | readonly sharedPath: string; | ||
| 148 | readonly entryName: string; | ||
| 149 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { | ||
| 150 | const path = yield* Path.Path; | ||
| 151 | const target = path.join(input.sharedPath, input.entryName); | ||
| 152 | const link = path.join(input.shadowPath, input.entryName); | ||
| 153 | const state = yield* readLinkState(input.fileSystem, link); | ||
| 154 | |||
| 155 | if (state._tag === "NotSymlink") { | ||
| 156 | return yield* new CodexShadowHomeError({ | ||
| 157 | detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, | ||
| 158 | }); | ||
| 159 | } | ||
| 160 | |||
| 161 | if (state._tag === "Missing") { | ||
| 162 | return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); | ||
| 163 | } | ||
| 164 | |||
| 165 | const resolvedExisting = path.resolve(path.dirname(link), state.target); | ||
| 166 | if (resolvedExisting !== target) { | ||
| 167 | yield* normalizeShadowHomeError(input.fileSystem.remove(link)); | ||
| 168 | yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); | ||
| 169 | } | ||
| 170 | }); | ||
| 171 | |||
| 172 | const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( | ||
| 173 | fileSystem: FileSystem.FileSystem, | ||
| 174 | shadowPath: string, | ||
| 175 | ): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { | ||
| 176 | const path = yield* Path.Path; | ||
| 177 | const authPath = path.join(shadowPath, "auth.json"); | ||
| 178 | const state = yield* readLinkState(fileSystem, authPath); | ||
| 179 | if (state._tag === "Symlink") { | ||
| 180 | return yield* new CodexShadowHomeError({ | ||
| 181 | detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, | ||
| 182 | }); | ||
| 183 | } | ||
| 184 | }); | ||
| 185 | |||
| 186 | export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( | ||
| 187 | layout: CodexHomeLayout, | ||
| 188 | ) { | ||
| 189 | if (layout.mode !== "authOverlay") return; | ||
| 190 | const effectiveHomePath = layout.effectiveHomePath; | ||
| 191 | if (!effectiveHomePath) return; | ||
| 192 | if (layout.sharedHomePath === effectiveHomePath) { | ||
| 193 | return yield* new CodexShadowHomeError({ | ||
| 194 | detail: "Codex shadow home path must be different from the shared home path.", | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 199 | const path = yield* Path.Path; | ||
| 200 | |||
| 201 | yield* normalizeShadowHomeError( | ||
| 202 | Effect.all( | ||
| 203 | [ | ||
| 204 | fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), | ||
| 205 | fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), | ||
| 206 | ...KNOWN_SHARED_DIRECTORIES.map((directory) => | ||
| 207 | fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { | ||
| 208 | recursive: true, | ||
| 209 | }), | ||
| 210 | ), | ||
| 211 | ], | ||
| 212 | { concurrency: "unbounded" }, | ||
| 213 | ), | ||
| 214 | ); | ||
| 215 | |||
| 216 | const sharedEntryNames = yield* normalizeShadowHomeError( | ||
| 217 | fileSystem.readDirectory(layout.sharedHomePath), | ||
| 218 | ); | ||
| 219 | const entries = new Set<string>(KNOWN_SHARED_DIRECTORIES); | ||
| 220 | for (const entryName of sharedEntryNames) { | ||
| 221 | if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { | ||
| 222 | entries.add(entryName); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | yield* Effect.forEach( | ||
| 227 | PRIVATE_ENTRY_NAMES, | ||
| 228 | (entryName) => | ||
| 229 | entryName === "auth.json" | ||
| 230 | ? Effect.void | ||
| 231 | : removePrivateSymlink({ | ||
| 232 | fileSystem, | ||
| 233 | shadowPath: effectiveHomePath, | ||
| 234 | entryName, | ||
| 235 | }), | ||
| 236 | { discard: true }, | ||
| 237 | ); | ||
| 238 | |||
| 239 | yield* Effect.forEach( | ||
| 240 | entries, | ||
| 241 | (entryName) => { | ||
| 242 | if (PRIVATE_ENTRY_NAMES.has(entryName)) { | ||
| 243 | return Effect.void; | ||
| 244 | } | ||
| 245 | return ensureSymlink({ | ||
| 246 | fileSystem, | ||
| 247 | shadowPath: effectiveHomePath, | ||
| 248 | sharedPath: layout.sharedHomePath, | ||
| 249 | entryName, | ||
| 250 | }); | ||
| 251 | }, | ||
| 252 | { discard: true }, | ||
| 253 | ); | ||
| 254 | |||
| 255 | yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); | ||
| 256 | }); | ||
| 257 | |||
| 258 | export function codexContinuationIdentity(layout: CodexHomeLayout) { | ||
| 259 | return { | ||
| 260 | driverKind: ProviderDriverKind.make("codex"), | ||
| 261 | continuationKey: layout.continuationKey, | ||
| 262 | }; | ||
| 263 | } | ||
apps/server/src/provider/Drivers/CursorDriver.ts created+148| ... | @@ -0,0 +1,148 @@ | ||
| 1 | /** | ||
| 2 | * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime. | ||
| 3 | * | ||
| 4 | * Cursor exposes an ACP-based CLI. The driver is still a plain value, but | ||
| 5 | * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` | ||
| 6 | * hook to run the slow ACP model-capability probe in the background without | ||
| 7 | * blocking the initial `ready`-state publish. | ||
| 8 | * | ||
| 9 | * Text generation is supported via the ACP runtime — `makeCursorTextGeneration` | ||
| 10 | * drives `runtime.prompt` with a structured-output schema and collects the | ||
| 11 | * agent's `agent_message_chunk` stream into a single JSON blob. | ||
| 12 | * | ||
| 13 | * @module provider/Drivers/CursorDriver | ||
| 14 | */ | ||
| 15 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | ||
| 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 18 | |||
| 19 | import { ServerConfig } from "../../config.ts"; | ||
| 20 | import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts"; | ||
| 21 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 22 | import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; | ||
| 23 | import { | ||
| 24 | buildInitialCursorProviderSnapshot, | ||
| 25 | checkCursorProviderStatus, | ||
| 26 | enrichCursorSnapshot, | ||
| 27 | } from "../Layers/CursorProvider.ts"; | ||
| 28 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 29 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 30 | import { | ||
| 31 | defaultProviderContinuationIdentity, | ||
| 32 | type ProviderDriver, | ||
| 33 | type ProviderInstance, | ||
| 34 | } from "../ProviderDriver.ts"; | ||
| 35 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| 36 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 37 | |||
| 38 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); | ||
| 39 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 40 | |||
| 41 | export type CursorDriverEnv = | ||
| 42 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 43 | | FileSystem.FileSystem | ||
| 44 | | Path.Path | ||
| 45 | | ProviderEventLoggers | ||
| 46 | | ServerConfig; | ||
| 47 | |||
| 48 | const withInstanceIdentity = | ||
| 49 | (input: { | ||
| 50 | readonly instanceId: ProviderInstance["instanceId"]; | ||
| 51 | readonly displayName: string | undefined; | ||
| 52 | readonly accentColor: string | undefined; | ||
| 53 | readonly continuationGroupKey: string; | ||
| 54 | }) => | ||
| 55 | (snapshot: ServerProviderDraft): ServerProvider => ({ | ||
| 56 | ...snapshot, | ||
| 57 | instanceId: input.instanceId, | ||
| 58 | driver: DRIVER_KIND, | ||
| 59 | ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| 60 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| 61 | continuation: { groupKey: input.continuationGroupKey }, | ||
| 62 | }); | ||
| 63 | |||
| 64 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ||
| 65 | driverKind: DRIVER_KIND, | ||
| 66 | metadata: { | ||
| 67 | displayName: "Cursor", | ||
| 68 | supportsMultipleInstances: true, | ||
| 69 | }, | ||
| 70 | configSchema: CursorSettings, | ||
| 71 | defaultConfig: (): CursorSettings => Schema.decodeSync(CursorSettings)({}), | ||
| 72 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 75 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 76 | const path = yield* Path.Path; | ||
| 77 | const eventLoggers = yield* ProviderEventLoggers; | ||
| 78 | const processEnv = mergeProviderInstanceEnvironment(environment); | ||
| 79 | const continuationIdentity = defaultProviderContinuationIdentity({ | ||
| 80 | driverKind: DRIVER_KIND, | ||
| 81 | instanceId, | ||
| 82 | }); | ||
| 83 | const stampIdentity = withInstanceIdentity({ | ||
| 84 | instanceId, | ||
| 85 | displayName, | ||
| 86 | accentColor, | ||
| 87 | continuationGroupKey: continuationIdentity.continuationKey, | ||
| 88 | }); | ||
| 89 | const effectiveConfig = { ...config, enabled } satisfies CursorSettings; | ||
| 90 | |||
| 91 | const adapter = yield* makeCursorAdapter(effectiveConfig, { | ||
| 92 | environment: processEnv, | ||
| 93 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 94 | instanceId, | ||
| 95 | }); | ||
| 96 | const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); | ||
| 97 | |||
| 98 | const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( | ||
| 99 | Effect.map(stampIdentity), | ||
| 100 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 101 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 102 | Effect.provideService(Path.Path, path), | ||
| 103 | ); | ||
| 104 | |||
| 105 | const snapshot = yield* makeManagedServerProvider<CursorSettings>({ | ||
| 106 | getSettings: Effect.succeed(effectiveConfig), | ||
| 107 | streamSettings: Stream.never, | ||
| 108 | haveSettingsChanged: () => false, | ||
| 109 | initialSnapshot: (settings) => stampIdentity(buildInitialCursorProviderSnapshot(settings)), | ||
| 110 | checkProvider, | ||
| 111 | // Preserve the background ACP model-capability probe that used to | ||
| 112 | // live on `CursorProviderLive`. Only fires when the snapshot reports | ||
| 113 | // an authenticated, enabled provider with at least one non-custom | ||
| 114 | // model whose capabilities haven't been captured yet. | ||
| 115 | enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => | ||
| 116 | enrichCursorSnapshot({ | ||
| 117 | settings, | ||
| 118 | environment: processEnv, | ||
| 119 | snapshot: currentSnapshot, | ||
| 120 | publishSnapshot, | ||
| 121 | stampIdentity, | ||
| 122 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), | ||
| 123 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 124 | }).pipe( | ||
| 125 | Effect.mapError( | ||
| 126 | (cause) => | ||
| 127 | new ProviderDriverError({ | ||
| 128 | driver: DRIVER_KIND, | ||
| 129 | instanceId, | ||
| 130 | detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, | ||
| 131 | cause, | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | ); | ||
| 135 | |||
| 136 | return { | ||
| 137 | instanceId, | ||
| 138 | driverKind: DRIVER_KIND, | ||
| 139 | continuationIdentity, | ||
| 140 | displayName, | ||
| 141 | accentColor, | ||
| 142 | enabled, | ||
| 143 | snapshot, | ||
| 144 | adapter, | ||
| 145 | textGeneration, | ||
| 146 | } satisfies ProviderInstance; | ||
| 147 | }), | ||
| 148 | }; | ||
apps/server/src/provider/Drivers/OpenCodeDriver.ts created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | /** | ||
| 2 | * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. | ||
| 3 | * | ||
| 4 | * Mirrors the Codex / Claude drivers: a plain value whose `create()` | ||
| 5 | * bundles `snapshot` / `adapter` / `textGeneration` closures over the | ||
| 6 | * per-instance `OpenCodeSettings`. | ||
| 7 | * | ||
| 8 | * Two instances with different `serverUrl`s therefore talk to independent | ||
| 9 | * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation | ||
| 10 | * shares spin up their own scoped child processes, and those child | ||
| 11 | * processes are released when the registry scope closes. | ||
| 12 | * | ||
| 13 | * @module provider/Drivers/OpenCodeDriver | ||
| 14 | */ | ||
| 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | ||
| 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 18 | |||
| 19 | import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts"; | ||
| 20 | import { ServerConfig } from "../../config.ts"; | ||
| 21 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 22 | import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; | ||
| 23 | import { | ||
| 24 | checkOpenCodeProviderStatus, | ||
| 25 | makePendingOpenCodeProvider, | ||
| 26 | } from "../Layers/OpenCodeProvider.ts"; | ||
| 27 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 28 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 29 | import { OpenCodeRuntime } from "../opencodeRuntime.ts"; | ||
| 30 | import { | ||
| 31 | defaultProviderContinuationIdentity, | ||
| 32 | type ProviderDriver, | ||
| 33 | type ProviderInstance, | ||
| 34 | } from "../ProviderDriver.ts"; | ||
| 35 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| 36 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 37 | |||
| 38 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); | ||
| 39 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 40 | |||
| 41 | export type OpenCodeDriverEnv = | ||
| 42 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 43 | | FileSystem.FileSystem | ||
| 44 | | OpenCodeRuntime | ||
| 45 | | Path.Path | ||
| 46 | | ProviderEventLoggers | ||
| 47 | | ServerConfig; | ||
| 48 | |||
| 49 | const withInstanceIdentity = | ||
| 50 | (input: { | ||
| 51 | readonly instanceId: ProviderInstance["instanceId"]; | ||
| 52 | readonly displayName: string | undefined; | ||
| 53 | readonly accentColor: string | undefined; | ||
| 54 | readonly continuationGroupKey: string; | ||
| 55 | }) => | ||
| 56 | (snapshot: ServerProviderDraft): ServerProvider => ({ | ||
| 57 | ...snapshot, | ||
| 58 | instanceId: input.instanceId, | ||
| 59 | driver: DRIVER_KIND, | ||
| 60 | ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| 61 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| 62 | continuation: { groupKey: input.continuationGroupKey }, | ||
| 63 | }); | ||
| 64 | |||
| 65 | export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> = { | ||
| 66 | driverKind: DRIVER_KIND, | ||
| 67 | metadata: { | ||
| 68 | displayName: "OpenCode", | ||
| 69 | supportsMultipleInstances: true, | ||
| 70 | }, | ||
| 71 | configSchema: OpenCodeSettings, | ||
| 72 | defaultConfig: (): OpenCodeSettings => Schema.decodeSync(OpenCodeSettings)({}), | ||
| 73 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const openCodeRuntime = yield* OpenCodeRuntime; | ||
| 76 | const serverConfig = yield* ServerConfig; | ||
| 77 | const eventLoggers = yield* ProviderEventLoggers; | ||
| 78 | const processEnv = mergeProviderInstanceEnvironment(environment); | ||
| 79 | const continuationIdentity = defaultProviderContinuationIdentity({ | ||
| 80 | driverKind: DRIVER_KIND, | ||
| 81 | instanceId, | ||
| 82 | }); | ||
| 83 | const stampIdentity = withInstanceIdentity({ | ||
| 84 | instanceId, | ||
| 85 | displayName, | ||
| 86 | accentColor, | ||
| 87 | continuationGroupKey: continuationIdentity.continuationKey, | ||
| 88 | }); | ||
| 89 | const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; | ||
| 90 | |||
| 91 | const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { | ||
| 92 | instanceId, | ||
| 93 | environment: processEnv, | ||
| 94 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 95 | }); | ||
| 96 | const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); | ||
| 97 | |||
| 98 | const checkProvider = checkOpenCodeProviderStatus( | ||
| 99 | effectiveConfig, | ||
| 100 | serverConfig.cwd, | ||
| 101 | processEnv, | ||
| 102 | ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); | ||
| 103 | |||
| 104 | const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({ | ||
| 105 | getSettings: Effect.succeed(effectiveConfig), | ||
| 106 | streamSettings: Stream.never, | ||
| 107 | haveSettingsChanged: () => false, | ||
| 108 | initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), | ||
| 109 | checkProvider, | ||
| 110 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 111 | }).pipe( | ||
| 112 | Effect.mapError( | ||
| 113 | (cause) => | ||
| 114 | new ProviderDriverError({ | ||
| 115 | driver: DRIVER_KIND, | ||
| 116 | instanceId, | ||
| 117 | detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, | ||
| 118 | cause, | ||
| 119 | }), | ||
| 120 | ), | ||
| 121 | ); | ||
| 122 | |||
| 123 | return { | ||
| 124 | instanceId, | ||
| 125 | driverKind: DRIVER_KIND, | ||
| 126 | continuationIdentity, | ||
| 127 | displayName, | ||
| 128 | accentColor, | ||
| 129 | enabled, | ||
| 130 | snapshot, | ||
| 131 | adapter, | ||
| 132 | textGeneration, | ||
| 133 | } satisfies ProviderInstance; | ||
| 134 | }), | ||
| 135 | }; | ||
apps/server/src/provider/Errors.ts+41| ... | @@ -116,6 +116,46 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn | ... | @@ -116,6 +116,46 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn |
| 116 | } | 116 | } |
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | /** | ||
| 120 | * ProviderInstanceNotFoundError - Lookup against the instance registry failed. | ||
| 121 | * | ||
| 122 | * Distinct from `ProviderUnsupportedError`: the driver is registered, but no | ||
| 123 | * instance with the requested id has been bootstrapped — typically because | ||
| 124 | * the persisted instance id refers to an instance the user removed from | ||
| 125 | * settings, or because routing is asked for an instance before the registry | ||
| 126 | * has finished its first reload. | ||
| 127 | */ | ||
| 128 | export class ProviderInstanceNotFoundError extends Schema.TaggedErrorClass<ProviderInstanceNotFoundError>()( | ||
| 129 | "ProviderInstanceNotFoundError", | ||
| 130 | { | ||
| 131 | instanceId: Schema.String, | ||
| 132 | cause: Schema.optional(Schema.Defect), | ||
| 133 | }, | ||
| 134 | ) { | ||
| 135 | override get message(): string { | ||
| 136 | return `No provider instance bound to id '${this.instanceId}'`; | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | /** | ||
| 141 | * ProviderDriverError - A driver `create` call failed before producing an | ||
| 142 | * instance. Surfaced to the registry, which marks the offending entry as | ||
| 143 | * an "unavailable" shadow snapshot rather than crashing the server. | ||
| 144 | */ | ||
| 145 | export class ProviderDriverError extends Schema.TaggedErrorClass<ProviderDriverError>()( | ||
| 146 | "ProviderDriverError", | ||
| 147 | { | ||
| 148 | driver: Schema.String, | ||
| 149 | instanceId: Schema.String, | ||
| 150 | detail: Schema.String, | ||
| 151 | cause: Schema.optional(Schema.Defect), | ||
| 152 | }, | ||
| 153 | ) { | ||
| 154 | override get message(): string { | ||
| 155 | return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 119 | /** | 159 | /** |
| 120 | * ProviderSessionNotFoundError - Provider-facing session not found. | 160 | * ProviderSessionNotFoundError - Provider-facing session not found. |
| 121 | */ | 161 | */ |
| ... | @@ -157,6 +197,7 @@ export type ProviderAdapterError = | ... | @@ -157,6 +197,7 @@ export type ProviderAdapterError = |
| 157 | export type ProviderServiceError = | 197 | export type ProviderServiceError = |
| 158 | | ProviderValidationError | 198 | | ProviderValidationError |
| 159 | | ProviderUnsupportedError | 199 | | ProviderUnsupportedError |
| 200 | | ProviderInstanceNotFoundError | ||
| 160 | | ProviderSessionNotFoundError | 201 | | ProviderSessionNotFoundError |
| 161 | | ProviderSessionDirectoryPersistenceError | 202 | | ProviderSessionDirectoryPersistenceError |
| 162 | | ProviderAdapterError | 203 | | ProviderAdapterError |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+222-117| ... | @@ -12,21 +12,29 @@ import type { | ... | @@ -12,21 +12,29 @@ import type { |
| 12 | } from "@anthropic-ai/claude-agent-sdk"; | 12 | } from "@anthropic-ai/claude-agent-sdk"; |
| 13 | import { | 13 | import { |
| 14 | ApprovalRequestId, | 14 | ApprovalRequestId, |
| 15 | ClaudeSettings, | ||
| 16 | ProviderDriverKind, | ||
| 15 | ProviderItemId, | 17 | ProviderItemId, |
| 16 | ProviderRuntimeEvent, | 18 | ProviderRuntimeEvent, |
| 17 | type RuntimeMode, | 19 | type RuntimeMode, |
| 18 | ThreadId, | 20 | ThreadId, |
| 21 | ProviderInstanceId, | ||
| 19 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 20 | import { createModelSelection } from "@t3tools/shared/model"; | 23 | import { createModelSelection } from "@t3tools/shared/model"; |
| 21 | import { assert, describe, it } from "@effect/vitest"; | 24 | import { assert, describe, it } from "@effect/vitest"; |
| 22 | import { Effect, Fiber, Layer, Random, Stream } from "effect"; | 25 | import { Context, Effect, Fiber, Layer, Random, Schema, Stream } from "effect"; |
| 23 | 26 | ||
| 24 | import { attachmentRelativePath } from "../../attachmentStore.ts"; | 27 | import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| 25 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 26 | import { ServerSettingsService } from "../../serverSettings.ts"; | 29 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 27 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 30 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 28 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | 31 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 29 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; | 32 | import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; |
| 33 | |||
| 34 | // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. | ||
| 35 | class ClaudeAdapter extends Context.Service<ClaudeAdapter, ClaudeAdapterShape>()( | ||
| 36 | "test/ClaudeAdapter", | ||
| 37 | ) {} | ||
| 30 | 38 | ||
| 31 | class FakeClaudeQuery implements AsyncIterable<SDKMessage> { | 39 | class FakeClaudeQuery implements AsyncIterable<SDKMessage> { |
| 32 | private readonly queue: Array<SDKMessage> = []; | 40 | private readonly queue: Array<SDKMessage> = []; |
| ... | @@ -137,6 +145,8 @@ function makeHarness(config?: { | ... | @@ -137,6 +145,8 @@ function makeHarness(config?: { |
| 137 | readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; | 145 | readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; |
| 138 | readonly cwd?: string; | 146 | readonly cwd?: string; |
| 139 | readonly baseDir?: string; | 147 | readonly baseDir?: string; |
| 148 | readonly claudeConfig?: Partial<ClaudeSettings>; | ||
| 149 | readonly instanceId?: ProviderInstanceId; | ||
| 140 | }) { | 150 | }) { |
| 141 | const query = new FakeClaudeQuery(); | 151 | const query = new FakeClaudeQuery(); |
| 142 | let createInput: | 152 | let createInput: |
| ... | @@ -147,6 +157,7 @@ function makeHarness(config?: { | ... | @@ -147,6 +157,7 @@ function makeHarness(config?: { |
| 147 | | undefined; | 157 | | undefined; |
| 148 | 158 | ||
| 149 | const adapterOptions: ClaudeAdapterLiveOptions = { | 159 | const adapterOptions: ClaudeAdapterLiveOptions = { |
| 160 | ...(config?.instanceId ? { instanceId: config.instanceId } : {}), | ||
| 150 | createQuery: (input) => { | 161 | createQuery: (input) => { |
| 151 | createInput = input; | 162 | createInput = input; |
| 152 | return query; | 163 | return query; |
| ... | @@ -164,7 +175,13 @@ function makeHarness(config?: { | ... | @@ -164,7 +175,13 @@ function makeHarness(config?: { |
| 164 | }; | 175 | }; |
| 165 | 176 | ||
| 166 | return { | 177 | return { |
| 167 | layer: makeClaudeAdapterLive(adapterOptions).pipe( | 178 | layer: Layer.effect( |
| 179 | ClaudeAdapter, | ||
| 180 | Effect.gen(function* () { | ||
| 181 | const claudeConfig = Schema.decodeSync(ClaudeSettings)(config?.claudeConfig ?? {}); | ||
| 182 | return yield* makeClaudeAdapter(claudeConfig, adapterOptions); | ||
| 183 | }), | ||
| 184 | ).pipe( | ||
| 168 | Layer.provideMerge( | 185 | Layer.provideMerge( |
| 169 | ServerConfig.layerTest( | 186 | ServerConfig.layerTest( |
| 170 | config?.cwd ?? "/tmp/claude-adapter-test", | 187 | config?.cwd ?? "/tmp/claude-adapter-test", |
| ... | @@ -247,7 +264,11 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -247,7 +264,11 @@ describe("ClaudeAdapterLive", () => { |
| 247 | return Effect.gen(function* () { | 264 | return Effect.gen(function* () { |
| 248 | const adapter = yield* ClaudeAdapter; | 265 | const adapter = yield* ClaudeAdapter; |
| 249 | const result = yield* adapter | 266 | const result = yield* adapter |
| 250 | .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) | 267 | .startSession({ |
| 268 | threadId: THREAD_ID, | ||
| 269 | provider: ProviderDriverKind.make("codex"), | ||
| 270 | runtimeMode: "full-access", | ||
| 271 | }) | ||
| 251 | .pipe(Effect.result); | 272 | .pipe(Effect.result); |
| 252 | 273 | ||
| 253 | assert.equal(result._tag, "Failure"); | 274 | assert.equal(result._tag, "Failure"); |
| ... | @@ -257,7 +278,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -257,7 +278,7 @@ describe("ClaudeAdapterLive", () => { |
| 257 | assert.deepEqual( | 278 | assert.deepEqual( |
| 258 | result.failure, | 279 | result.failure, |
| 259 | new ProviderAdapterValidationError({ | 280 | new ProviderAdapterValidationError({ |
| 260 | provider: "claudeAgent", | 281 | provider: ProviderDriverKind.make("claudeAgent"), |
| 261 | operation: "startSession", | 282 | operation: "startSession", |
| 262 | issue: "Expected provider 'claudeAgent' but received 'codex'.", | 283 | issue: "Expected provider 'claudeAgent' but received 'codex'.", |
| 263 | }), | 284 | }), |
| ... | @@ -274,7 +295,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -274,7 +295,7 @@ describe("ClaudeAdapterLive", () => { |
| 274 | const adapter = yield* ClaudeAdapter; | 295 | const adapter = yield* ClaudeAdapter; |
| 275 | yield* adapter.startSession({ | 296 | yield* adapter.startSession({ |
| 276 | threadId: THREAD_ID, | 297 | threadId: THREAD_ID, |
| 277 | provider: "claudeAgent", | 298 | provider: ProviderDriverKind.make("claudeAgent"), |
| 278 | runtimeMode: "full-access", | 299 | runtimeMode: "full-access", |
| 279 | }); | 300 | }); |
| 280 | 301 | ||
| ... | @@ -294,7 +315,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -294,7 +315,7 @@ describe("ClaudeAdapterLive", () => { |
| 294 | const adapter = yield* ClaudeAdapter; | 315 | const adapter = yield* ClaudeAdapter; |
| 295 | yield* adapter.startSession({ | 316 | yield* adapter.startSession({ |
| 296 | threadId: THREAD_ID, | 317 | threadId: THREAD_ID, |
| 297 | provider: "claudeAgent", | 318 | provider: ProviderDriverKind.make("claudeAgent"), |
| 298 | runtimeMode: "approval-required", | 319 | runtimeMode: "approval-required", |
| 299 | }); | 320 | }); |
| 300 | 321 | ||
| ... | @@ -314,7 +335,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -314,7 +335,7 @@ describe("ClaudeAdapterLive", () => { |
| 314 | const adapter = yield* ClaudeAdapter; | 335 | const adapter = yield* ClaudeAdapter; |
| 315 | yield* adapter.startSession({ | 336 | yield* adapter.startSession({ |
| 316 | threadId: THREAD_ID, | 337 | threadId: THREAD_ID, |
| 317 | provider: "claudeAgent", | 338 | provider: ProviderDriverKind.make("claudeAgent"), |
| 318 | runtimeMode: "full-access", | 339 | runtimeMode: "full-access", |
| 319 | }); | 340 | }); |
| 320 | 341 | ||
| ... | @@ -333,10 +354,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -333,10 +354,12 @@ describe("ClaudeAdapterLive", () => { |
| 333 | const adapter = yield* ClaudeAdapter; | 354 | const adapter = yield* ClaudeAdapter; |
| 334 | yield* adapter.startSession({ | 355 | yield* adapter.startSession({ |
| 335 | threadId: THREAD_ID, | 356 | threadId: THREAD_ID, |
| 336 | provider: "claudeAgent", | 357 | provider: ProviderDriverKind.make("claudeAgent"), |
| 337 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 358 | modelSelection: createModelSelection( |
| 338 | { id: "effort", value: "max" }, | 359 | ProviderInstanceId.make("claudeAgent"), |
| 339 | ]), | 360 | "claude-opus-4-6", |
| 361 | [{ id: "effort", value: "max" }], | ||
| 362 | ), | ||
| 340 | runtimeMode: "full-access", | 363 | runtimeMode: "full-access", |
| 341 | }); | 364 | }); |
| 342 | 365 | ||
| ... | @@ -348,15 +371,37 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -348,15 +371,37 @@ describe("ClaudeAdapterLive", () => { |
| 348 | ); | 371 | ); |
| 349 | }); | 372 | }); |
| 350 | 373 | ||
| 374 | it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { | ||
| 375 | const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); | ||
| 376 | return Effect.gen(function* () { | ||
| 377 | const adapter = yield* ClaudeAdapter; | ||
| 378 | yield* adapter.startSession({ | ||
| 379 | threadId: THREAD_ID, | ||
| 380 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 381 | modelSelection: createModelSelection( | ||
| 382 | ProviderInstanceId.make("claudeAgent"), | ||
| 383 | "claude-opus-4-6", | ||
| 384 | ), | ||
| 385 | runtimeMode: "full-access", | ||
| 386 | }); | ||
| 387 | |||
| 388 | const createInput = harness.getLastCreateQueryInput(); | ||
| 389 | assert.equal(createInput?.options.env?.HOME, path.join(os.homedir(), ".claude-work")); | ||
| 390 | }).pipe( | ||
| 391 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 392 | Effect.provide(harness.layer), | ||
| 393 | ); | ||
| 394 | }); | ||
| 395 | |||
| 351 | it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { | 396 | it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { |
| 352 | const harness = makeHarness(); | 397 | const harness = makeHarness(); |
| 353 | return Effect.gen(function* () { | 398 | return Effect.gen(function* () { |
| 354 | const adapter = yield* ClaudeAdapter; | 399 | const adapter = yield* ClaudeAdapter; |
| 355 | yield* adapter.startSession({ | 400 | yield* adapter.startSession({ |
| 356 | threadId: THREAD_ID, | 401 | threadId: THREAD_ID, |
| 357 | provider: "claudeAgent", | 402 | provider: ProviderDriverKind.make("claudeAgent"), |
| 358 | modelSelection: { | 403 | modelSelection: { |
| 359 | provider: "claudeAgent", | 404 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 360 | model: "claude-opus-4-7", | 405 | model: "claude-opus-4-7", |
| 361 | }, | 406 | }, |
| 362 | runtimeMode: "full-access", | 407 | runtimeMode: "full-access", |
| ... | @@ -376,10 +421,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -376,10 +421,12 @@ describe("ClaudeAdapterLive", () => { |
| 376 | const adapter = yield* ClaudeAdapter; | 421 | const adapter = yield* ClaudeAdapter; |
| 377 | yield* adapter.startSession({ | 422 | yield* adapter.startSession({ |
| 378 | threadId: THREAD_ID, | 423 | threadId: THREAD_ID, |
| 379 | provider: "claudeAgent", | 424 | provider: ProviderDriverKind.make("claudeAgent"), |
| 380 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-7", [ | 425 | modelSelection: createModelSelection( |
| 381 | { id: "effort", value: "xhigh" }, | 426 | ProviderInstanceId.make("claudeAgent"), |
| 382 | ]), | 427 | "claude-opus-4-7", |
| 428 | [{ id: "effort", value: "xhigh" }], | ||
| 429 | ), | ||
| 383 | runtimeMode: "full-access", | 430 | runtimeMode: "full-access", |
| 384 | }); | 431 | }); |
| 385 | 432 | ||
| ... | @@ -397,10 +444,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -397,10 +444,12 @@ describe("ClaudeAdapterLive", () => { |
| 397 | const adapter = yield* ClaudeAdapter; | 444 | const adapter = yield* ClaudeAdapter; |
| 398 | yield* adapter.startSession({ | 445 | yield* adapter.startSession({ |
| 399 | threadId: THREAD_ID, | 446 | threadId: THREAD_ID, |
| 400 | provider: "claudeAgent", | 447 | provider: ProviderDriverKind.make("claudeAgent"), |
| 401 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 448 | modelSelection: createModelSelection( |
| 402 | { id: "effort", value: "max" }, | 449 | ProviderInstanceId.make("claudeAgent"), |
| 403 | ]), | 450 | "claude-sonnet-4-6", |
| 451 | [{ id: "effort", value: "max" }], | ||
| 452 | ), | ||
| 404 | runtimeMode: "full-access", | 453 | runtimeMode: "full-access", |
| 405 | }); | 454 | }); |
| 406 | 455 | ||
| ... | @@ -418,10 +467,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -418,10 +467,12 @@ describe("ClaudeAdapterLive", () => { |
| 418 | const adapter = yield* ClaudeAdapter; | 467 | const adapter = yield* ClaudeAdapter; |
| 419 | yield* adapter.startSession({ | 468 | yield* adapter.startSession({ |
| 420 | threadId: THREAD_ID, | 469 | threadId: THREAD_ID, |
| 421 | provider: "claudeAgent", | 470 | provider: ProviderDriverKind.make("claudeAgent"), |
| 422 | modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ | 471 | modelSelection: createModelSelection( |
| 423 | { id: "effort", value: "high" }, | 472 | ProviderInstanceId.make("claudeAgent"), |
| 424 | ]), | 473 | "claude-haiku-4-5", |
| 474 | [{ id: "effort", value: "high" }], | ||
| 475 | ), | ||
| 425 | runtimeMode: "full-access", | 476 | runtimeMode: "full-access", |
| 426 | }); | 477 | }); |
| 427 | 478 | ||
| ... | @@ -439,10 +490,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -439,10 +490,12 @@ describe("ClaudeAdapterLive", () => { |
| 439 | const adapter = yield* ClaudeAdapter; | 490 | const adapter = yield* ClaudeAdapter; |
| 440 | yield* adapter.startSession({ | 491 | yield* adapter.startSession({ |
| 441 | threadId: THREAD_ID, | 492 | threadId: THREAD_ID, |
| 442 | provider: "claudeAgent", | 493 | provider: ProviderDriverKind.make("claudeAgent"), |
| 443 | modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ | 494 | modelSelection: createModelSelection( |
| 444 | { id: "thinking", value: false }, | 495 | ProviderInstanceId.make("claudeAgent"), |
| 445 | ]), | 496 | "claude-haiku-4-5", |
| 497 | [{ id: "thinking", value: false }], | ||
| 498 | ), | ||
| 446 | runtimeMode: "full-access", | 499 | runtimeMode: "full-access", |
| 447 | }); | 500 | }); |
| 448 | 501 | ||
| ... | @@ -462,10 +515,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -462,10 +515,12 @@ describe("ClaudeAdapterLive", () => { |
| 462 | const adapter = yield* ClaudeAdapter; | 515 | const adapter = yield* ClaudeAdapter; |
| 463 | yield* adapter.startSession({ | 516 | yield* adapter.startSession({ |
| 464 | threadId: THREAD_ID, | 517 | threadId: THREAD_ID, |
| 465 | provider: "claudeAgent", | 518 | provider: ProviderDriverKind.make("claudeAgent"), |
| 466 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 519 | modelSelection: createModelSelection( |
| 467 | { id: "thinking", value: false }, | 520 | ProviderInstanceId.make("claudeAgent"), |
| 468 | ]), | 521 | "claude-sonnet-4-6", |
| 522 | [{ id: "thinking", value: false }], | ||
| 523 | ), | ||
| 469 | runtimeMode: "full-access", | 524 | runtimeMode: "full-access", |
| 470 | }); | 525 | }); |
| 471 | 526 | ||
| ... | @@ -483,10 +538,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -483,10 +538,12 @@ describe("ClaudeAdapterLive", () => { |
| 483 | const adapter = yield* ClaudeAdapter; | 538 | const adapter = yield* ClaudeAdapter; |
| 484 | yield* adapter.startSession({ | 539 | yield* adapter.startSession({ |
| 485 | threadId: THREAD_ID, | 540 | threadId: THREAD_ID, |
| 486 | provider: "claudeAgent", | 541 | provider: ProviderDriverKind.make("claudeAgent"), |
| 487 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 542 | modelSelection: createModelSelection( |
| 488 | { id: "fastMode", value: true }, | 543 | ProviderInstanceId.make("claudeAgent"), |
| 489 | ]), | 544 | "claude-opus-4-6", |
| 545 | [{ id: "fastMode", value: true }], | ||
| 546 | ), | ||
| 490 | runtimeMode: "full-access", | 547 | runtimeMode: "full-access", |
| 491 | }); | 548 | }); |
| 492 | 549 | ||
| ... | @@ -506,10 +563,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -506,10 +563,12 @@ describe("ClaudeAdapterLive", () => { |
| 506 | const adapter = yield* ClaudeAdapter; | 563 | const adapter = yield* ClaudeAdapter; |
| 507 | yield* adapter.startSession({ | 564 | yield* adapter.startSession({ |
| 508 | threadId: THREAD_ID, | 565 | threadId: THREAD_ID, |
| 509 | provider: "claudeAgent", | 566 | provider: ProviderDriverKind.make("claudeAgent"), |
| 510 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 567 | modelSelection: createModelSelection( |
| 511 | { id: "fastMode", value: true }, | 568 | ProviderInstanceId.make("claudeAgent"), |
| 512 | ]), | 569 | "claude-sonnet-4-6", |
| 570 | [{ id: "fastMode", value: true }], | ||
| 571 | ), | ||
| 513 | runtimeMode: "full-access", | 572 | runtimeMode: "full-access", |
| 514 | }); | 573 | }); |
| 515 | 574 | ||
| ... | @@ -527,10 +586,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -527,10 +586,12 @@ describe("ClaudeAdapterLive", () => { |
| 527 | const adapter = yield* ClaudeAdapter; | 586 | const adapter = yield* ClaudeAdapter; |
| 528 | const session = yield* adapter.startSession({ | 587 | const session = yield* adapter.startSession({ |
| 529 | threadId: THREAD_ID, | 588 | threadId: THREAD_ID, |
| 530 | provider: "claudeAgent", | 589 | provider: ProviderDriverKind.make("claudeAgent"), |
| 531 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 590 | modelSelection: createModelSelection( |
| 532 | { id: "effort", value: "ultrathink" }, | 591 | ProviderInstanceId.make("claudeAgent"), |
| 533 | ]), | 592 | "claude-sonnet-4-6", |
| 593 | [{ id: "effort", value: "ultrathink" }], | ||
| 594 | ), | ||
| 534 | runtimeMode: "full-access", | 595 | runtimeMode: "full-access", |
| 535 | }); | 596 | }); |
| 536 | 597 | ||
| ... | @@ -538,9 +599,11 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -538,9 +599,11 @@ describe("ClaudeAdapterLive", () => { |
| 538 | threadId: session.threadId, | 599 | threadId: session.threadId, |
| 539 | input: "Investigate the edge cases", | 600 | input: "Investigate the edge cases", |
| 540 | attachments: [], | 601 | attachments: [], |
| 541 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 602 | modelSelection: createModelSelection( |
| 542 | { id: "effort", value: "ultrathink" }, | 603 | ProviderInstanceId.make("claudeAgent"), |
| 543 | ]), | 604 | "claude-sonnet-4-6", |
| 605 | [{ id: "effort", value: "ultrathink" }], | ||
| 606 | ), | ||
| 544 | }); | 607 | }); |
| 545 | 608 | ||
| 546 | const createInput = harness.getLastCreateQueryInput(); | 609 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -585,7 +648,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -585,7 +648,7 @@ describe("ClaudeAdapterLive", () => { |
| 585 | 648 | ||
| 586 | const session = yield* adapter.startSession({ | 649 | const session = yield* adapter.startSession({ |
| 587 | threadId: THREAD_ID, | 650 | threadId: THREAD_ID, |
| 588 | provider: "claudeAgent", | 651 | provider: ProviderDriverKind.make("claudeAgent"), |
| 589 | runtimeMode: "full-access", | 652 | runtimeMode: "full-access", |
| 590 | }); | 653 | }); |
| 591 | 654 | ||
| ... | @@ -630,9 +693,9 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -630,9 +693,9 @@ describe("ClaudeAdapterLive", () => { |
| 630 | 693 | ||
| 631 | const session = yield* adapter.startSession({ | 694 | const session = yield* adapter.startSession({ |
| 632 | threadId: THREAD_ID, | 695 | threadId: THREAD_ID, |
| 633 | provider: "claudeAgent", | 696 | provider: ProviderDriverKind.make("claudeAgent"), |
| 634 | modelSelection: { | 697 | modelSelection: { |
| 635 | provider: "claudeAgent", | 698 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 636 | model: "claude-sonnet-4-5", | 699 | model: "claude-sonnet-4-5", |
| 637 | }, | 700 | }, |
| 638 | runtimeMode: "full-access", | 701 | runtimeMode: "full-access", |
| ... | @@ -807,7 +870,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -807,7 +870,7 @@ describe("ClaudeAdapterLive", () => { |
| 807 | 870 | ||
| 808 | const session = yield* adapter.startSession({ | 871 | const session = yield* adapter.startSession({ |
| 809 | threadId: THREAD_ID, | 872 | threadId: THREAD_ID, |
| 810 | provider: "claudeAgent", | 873 | provider: ProviderDriverKind.make("claudeAgent"), |
| 811 | runtimeMode: "full-access", | 874 | runtimeMode: "full-access", |
| 812 | }); | 875 | }); |
| 813 | 876 | ||
| ... | @@ -986,7 +1049,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -986,7 +1049,7 @@ describe("ClaudeAdapterLive", () => { |
| 986 | 1049 | ||
| 987 | const session = yield* adapter.startSession({ | 1050 | const session = yield* adapter.startSession({ |
| 988 | threadId: THREAD_ID, | 1051 | threadId: THREAD_ID, |
| 989 | provider: "claudeAgent", | 1052 | provider: ProviderDriverKind.make("claudeAgent"), |
| 990 | runtimeMode: "full-access", | 1053 | runtimeMode: "full-access", |
| 991 | }); | 1054 | }); |
| 992 | 1055 | ||
| ... | @@ -1077,7 +1140,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1077,7 +1140,7 @@ describe("ClaudeAdapterLive", () => { |
| 1077 | 1140 | ||
| 1078 | const session = yield* adapter.startSession({ | 1141 | const session = yield* adapter.startSession({ |
| 1079 | threadId: THREAD_ID, | 1142 | threadId: THREAD_ID, |
| 1080 | provider: "claudeAgent", | 1143 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1081 | runtimeMode: "full-access", | 1144 | runtimeMode: "full-access", |
| 1082 | }); | 1145 | }); |
| 1083 | 1146 | ||
| ... | @@ -1153,7 +1216,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1153,7 +1216,7 @@ describe("ClaudeAdapterLive", () => { |
| 1153 | 1216 | ||
| 1154 | const session = yield* adapter.startSession({ | 1217 | const session = yield* adapter.startSession({ |
| 1155 | threadId: THREAD_ID, | 1218 | threadId: THREAD_ID, |
| 1156 | provider: "claudeAgent", | 1219 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1157 | runtimeMode: "full-access", | 1220 | runtimeMode: "full-access", |
| 1158 | }); | 1221 | }); |
| 1159 | 1222 | ||
| ... | @@ -1219,7 +1282,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1219,7 +1282,7 @@ describe("ClaudeAdapterLive", () => { |
| 1219 | 1282 | ||
| 1220 | yield* adapter.startSession({ | 1283 | yield* adapter.startSession({ |
| 1221 | threadId: THREAD_ID, | 1284 | threadId: THREAD_ID, |
| 1222 | provider: "claudeAgent", | 1285 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1223 | runtimeMode: "full-access", | 1286 | runtimeMode: "full-access", |
| 1224 | }); | 1287 | }); |
| 1225 | 1288 | ||
| ... | @@ -1270,13 +1333,19 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1270,13 +1333,19 @@ describe("ClaudeAdapterLive", () => { |
| 1270 | 1333 | ||
| 1271 | it.effect("closes the previous session before replacing an existing thread session", () => { | 1334 | it.effect("closes the previous session before replacing an existing thread session", () => { |
| 1272 | const queries: FakeClaudeQuery[] = []; | 1335 | const queries: FakeClaudeQuery[] = []; |
| 1273 | const layer = makeClaudeAdapterLive({ | 1336 | const layer = Layer.effect( |
| 1274 | createQuery: () => { | 1337 | ClaudeAdapter, |
| 1275 | const query = new FakeClaudeQuery(); | 1338 | Effect.gen(function* () { |
| 1276 | queries.push(query); | 1339 | const claudeConfig = Schema.decodeSync(ClaudeSettings)({}); |
| 1277 | return query; | 1340 | return yield* makeClaudeAdapter(claudeConfig, { |
| 1278 | }, | 1341 | createQuery: () => { |
| 1279 | }).pipe( | 1342 | const query = new FakeClaudeQuery(); |
| 1343 | queries.push(query); | ||
| 1344 | return query; | ||
| 1345 | }, | ||
| 1346 | }); | ||
| 1347 | }), | ||
| 1348 | ).pipe( | ||
| 1280 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), | 1349 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), |
| 1281 | Layer.provideMerge(ServerSettingsService.layerTest()), | 1350 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 1282 | Layer.provideMerge(NodeServices.layer), | 1351 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -1292,13 +1361,13 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1292,13 +1361,13 @@ describe("ClaudeAdapterLive", () => { |
| 1292 | 1361 | ||
| 1293 | const firstSession = yield* adapter.startSession({ | 1362 | const firstSession = yield* adapter.startSession({ |
| 1294 | threadId: THREAD_ID, | 1363 | threadId: THREAD_ID, |
| 1295 | provider: "claudeAgent", | 1364 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1296 | runtimeMode: "full-access", | 1365 | runtimeMode: "full-access", |
| 1297 | }); | 1366 | }); |
| 1298 | 1367 | ||
| 1299 | const secondSession = yield* adapter.startSession({ | 1368 | const secondSession = yield* adapter.startSession({ |
| 1300 | threadId: THREAD_ID, | 1369 | threadId: THREAD_ID, |
| 1301 | provider: "claudeAgent", | 1370 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1302 | runtimeMode: "full-access", | 1371 | runtimeMode: "full-access", |
| 1303 | resumeCursor: firstSession.resumeCursor, | 1372 | resumeCursor: firstSession.resumeCursor, |
| 1304 | }); | 1373 | }); |
| ... | @@ -1347,21 +1416,27 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1347,21 +1416,27 @@ describe("ClaudeAdapterLive", () => { |
| 1347 | 1416 | ||
| 1348 | let promptConsumerError: unknown = undefined; | 1417 | let promptConsumerError: unknown = undefined; |
| 1349 | 1418 | ||
| 1350 | const layer = makeClaudeAdapterLive({ | 1419 | const layer = Layer.effect( |
| 1351 | createQuery: (input) => { | 1420 | ClaudeAdapter, |
| 1352 | // Simulate the SDK consuming the prompt iterable | 1421 | Effect.gen(function* () { |
| 1353 | (async () => { | 1422 | const claudeConfig = Schema.decodeSync(ClaudeSettings)({}); |
| 1354 | try { | 1423 | return yield* makeClaudeAdapter(claudeConfig, { |
| 1355 | for await (const _message of input.prompt) { | 1424 | createQuery: (input) => { |
| 1356 | /* SDK processes user messages */ | 1425 | // Simulate the SDK consuming the prompt iterable |
| 1357 | } | 1426 | (async () => { |
| 1358 | } catch (error) { | 1427 | try { |
| 1359 | promptConsumerError = error; | 1428 | for await (const _message of input.prompt) { |
| 1360 | } | 1429 | /* SDK processes user messages */ |
| 1361 | })(); | 1430 | } |
| 1362 | return query; | 1431 | } catch (error) { |
| 1363 | }, | 1432 | promptConsumerError = error; |
| 1364 | }).pipe( | 1433 | } |
| 1434 | })(); | ||
| 1435 | return query; | ||
| 1436 | }, | ||
| 1437 | }); | ||
| 1438 | }), | ||
| 1439 | ).pipe( | ||
| 1365 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), | 1440 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), |
| 1366 | Layer.provideMerge(ServerSettingsService.layerTest()), | 1441 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 1367 | Layer.provideMerge(NodeServices.layer), | 1442 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -1379,7 +1454,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1379,7 +1454,7 @@ describe("ClaudeAdapterLive", () => { |
| 1379 | 1454 | ||
| 1380 | yield* adapter.startSession({ | 1455 | yield* adapter.startSession({ |
| 1381 | threadId: THREAD_ID, | 1456 | threadId: THREAD_ID, |
| 1382 | provider: "claudeAgent", | 1457 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1383 | runtimeMode: "full-access", | 1458 | runtimeMode: "full-access", |
| 1384 | }); | 1459 | }); |
| 1385 | 1460 | ||
| ... | @@ -1416,7 +1491,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1416,7 +1491,7 @@ describe("ClaudeAdapterLive", () => { |
| 1416 | 1491 | ||
| 1417 | yield* adapter.startSession({ | 1492 | yield* adapter.startSession({ |
| 1418 | threadId: THREAD_ID, | 1493 | threadId: THREAD_ID, |
| 1419 | provider: "claudeAgent", | 1494 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1420 | runtimeMode: "full-access", | 1495 | runtimeMode: "full-access", |
| 1421 | }); | 1496 | }); |
| 1422 | 1497 | ||
| ... | @@ -1463,7 +1538,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1463,7 +1538,7 @@ describe("ClaudeAdapterLive", () => { |
| 1463 | 1538 | ||
| 1464 | yield* adapter.startSession({ | 1539 | yield* adapter.startSession({ |
| 1465 | threadId: THREAD_ID, | 1540 | threadId: THREAD_ID, |
| 1466 | provider: "claudeAgent", | 1541 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1467 | runtimeMode: "full-access", | 1542 | runtimeMode: "full-access", |
| 1468 | }); | 1543 | }); |
| 1469 | 1544 | ||
| ... | @@ -1517,7 +1592,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1517,7 +1592,7 @@ describe("ClaudeAdapterLive", () => { |
| 1517 | 1592 | ||
| 1518 | yield* adapter.startSession({ | 1593 | yield* adapter.startSession({ |
| 1519 | threadId: THREAD_ID, | 1594 | threadId: THREAD_ID, |
| 1520 | provider: "claudeAgent", | 1595 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1521 | runtimeMode: "full-access", | 1596 | runtimeMode: "full-access", |
| 1522 | }); | 1597 | }); |
| 1523 | 1598 | ||
| ... | @@ -1584,7 +1659,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1584,7 +1659,7 @@ describe("ClaudeAdapterLive", () => { |
| 1584 | 1659 | ||
| 1585 | yield* adapter.startSession({ | 1660 | yield* adapter.startSession({ |
| 1586 | threadId: THREAD_ID, | 1661 | threadId: THREAD_ID, |
| 1587 | provider: "claudeAgent", | 1662 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1588 | runtimeMode: "full-access", | 1663 | runtimeMode: "full-access", |
| 1589 | }); | 1664 | }); |
| 1590 | 1665 | ||
| ... | @@ -1649,7 +1724,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1649,7 +1724,7 @@ describe("ClaudeAdapterLive", () => { |
| 1649 | 1724 | ||
| 1650 | yield* adapter.startSession({ | 1725 | yield* adapter.startSession({ |
| 1651 | threadId: THREAD_ID, | 1726 | threadId: THREAD_ID, |
| 1652 | provider: "claudeAgent", | 1727 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1653 | runtimeMode: "full-access", | 1728 | runtimeMode: "full-access", |
| 1654 | }); | 1729 | }); |
| 1655 | 1730 | ||
| ... | @@ -1730,7 +1805,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1730,7 +1805,7 @@ describe("ClaudeAdapterLive", () => { |
| 1730 | 1805 | ||
| 1731 | const session = yield* adapter.startSession({ | 1806 | const session = yield* adapter.startSession({ |
| 1732 | threadId: THREAD_ID, | 1807 | threadId: THREAD_ID, |
| 1733 | provider: "claudeAgent", | 1808 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1734 | runtimeMode: "full-access", | 1809 | runtimeMode: "full-access", |
| 1735 | }); | 1810 | }); |
| 1736 | 1811 | ||
| ... | @@ -1821,7 +1896,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1821,7 +1896,7 @@ describe("ClaudeAdapterLive", () => { |
| 1821 | 1896 | ||
| 1822 | const session = yield* adapter.startSession({ | 1897 | const session = yield* adapter.startSession({ |
| 1823 | threadId: THREAD_ID, | 1898 | threadId: THREAD_ID, |
| 1824 | provider: "claudeAgent", | 1899 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1825 | runtimeMode: "full-access", | 1900 | runtimeMode: "full-access", |
| 1826 | }); | 1901 | }); |
| 1827 | 1902 | ||
| ... | @@ -1987,7 +2062,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1987,7 +2062,7 @@ describe("ClaudeAdapterLive", () => { |
| 1987 | 2062 | ||
| 1988 | const session = yield* adapter.startSession({ | 2063 | const session = yield* adapter.startSession({ |
| 1989 | threadId: THREAD_ID, | 2064 | threadId: THREAD_ID, |
| 1990 | provider: "claudeAgent", | 2065 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1991 | runtimeMode: "full-access", | 2066 | runtimeMode: "full-access", |
| 1992 | }); | 2067 | }); |
| 1993 | 2068 | ||
| ... | @@ -2056,7 +2131,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2056,7 +2131,7 @@ describe("ClaudeAdapterLive", () => { |
| 2056 | 2131 | ||
| 2057 | const session = yield* adapter.startSession({ | 2132 | const session = yield* adapter.startSession({ |
| 2058 | threadId: THREAD_ID, | 2133 | threadId: THREAD_ID, |
| 2059 | provider: "claudeAgent", | 2134 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2060 | runtimeMode: "full-access", | 2135 | runtimeMode: "full-access", |
| 2061 | }); | 2136 | }); |
| 2062 | 2137 | ||
| ... | @@ -2278,7 +2353,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2278,7 +2353,7 @@ describe("ClaudeAdapterLive", () => { |
| 2278 | 2353 | ||
| 2279 | const session = yield* adapter.startSession({ | 2354 | const session = yield* adapter.startSession({ |
| 2280 | threadId: THREAD_ID, | 2355 | threadId: THREAD_ID, |
| 2281 | provider: "claudeAgent", | 2356 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2282 | runtimeMode: "full-access", | 2357 | runtimeMode: "full-access", |
| 2283 | }); | 2358 | }); |
| 2284 | assert.equal(session.threadId, THREAD_ID); | 2359 | assert.equal(session.threadId, THREAD_ID); |
| ... | @@ -2351,7 +2426,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2351,7 +2426,7 @@ describe("ClaudeAdapterLive", () => { |
| 2351 | 2426 | ||
| 2352 | const session = yield* adapter.startSession({ | 2427 | const session = yield* adapter.startSession({ |
| 2353 | threadId: THREAD_ID, | 2428 | threadId: THREAD_ID, |
| 2354 | provider: "claudeAgent", | 2429 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2355 | runtimeMode: "approval-required", | 2430 | runtimeMode: "approval-required", |
| 2356 | }); | 2431 | }); |
| 2357 | 2432 | ||
| ... | @@ -2460,7 +2535,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2460,7 +2535,7 @@ describe("ClaudeAdapterLive", () => { |
| 2460 | 2535 | ||
| 2461 | const session = yield* adapter.startSession({ | 2536 | const session = yield* adapter.startSession({ |
| 2462 | threadId: THREAD_ID, | 2537 | threadId: THREAD_ID, |
| 2463 | provider: "claudeAgent", | 2538 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2464 | runtimeMode: "approval-required", | 2539 | runtimeMode: "approval-required", |
| 2465 | }); | 2540 | }); |
| 2466 | 2541 | ||
| ... | @@ -2533,7 +2608,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2533,7 +2608,7 @@ describe("ClaudeAdapterLive", () => { |
| 2533 | 2608 | ||
| 2534 | const session = yield* adapter.startSession({ | 2609 | const session = yield* adapter.startSession({ |
| 2535 | threadId: RESUME_THREAD_ID, | 2610 | threadId: RESUME_THREAD_ID, |
| 2536 | provider: "claudeAgent", | 2611 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2537 | resumeCursor: { | 2612 | resumeCursor: { |
| 2538 | threadId: "resume-thread-1", | 2613 | threadId: "resume-thread-1", |
| 2539 | resume: "550e8400-e29b-41d4-a716-446655440000", | 2614 | resume: "550e8400-e29b-41d4-a716-446655440000", |
| ... | @@ -2575,7 +2650,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2575,7 +2650,7 @@ describe("ClaudeAdapterLive", () => { |
| 2575 | 2650 | ||
| 2576 | yield* adapter.startSession({ | 2651 | yield* adapter.startSession({ |
| 2577 | threadId: RESUME_THREAD_ID, | 2652 | threadId: RESUME_THREAD_ID, |
| 2578 | provider: "claudeAgent", | 2653 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2579 | resumeCursor: { | 2654 | resumeCursor: { |
| 2580 | threadId: RESUME_THREAD_ID, | 2655 | threadId: RESUME_THREAD_ID, |
| 2581 | resume: durableSessionId, | 2656 | resume: durableSessionId, |
| ... | @@ -2658,7 +2733,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2658,7 +2733,7 @@ describe("ClaudeAdapterLive", () => { |
| 2658 | 2733 | ||
| 2659 | const session = yield* adapter.startSession({ | 2734 | const session = yield* adapter.startSession({ |
| 2660 | threadId: THREAD_ID, | 2735 | threadId: THREAD_ID, |
| 2661 | provider: "claudeAgent", | 2736 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2662 | runtimeMode: "full-access", | 2737 | runtimeMode: "full-access", |
| 2663 | }); | 2738 | }); |
| 2664 | 2739 | ||
| ... | @@ -2692,7 +2767,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2692,7 +2767,7 @@ describe("ClaudeAdapterLive", () => { |
| 2692 | 2767 | ||
| 2693 | const session = yield* adapter.startSession({ | 2768 | const session = yield* adapter.startSession({ |
| 2694 | threadId: THREAD_ID, | 2769 | threadId: THREAD_ID, |
| 2695 | provider: "claudeAgent", | 2770 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2696 | runtimeMode: "full-access", | 2771 | runtimeMode: "full-access", |
| 2697 | }); | 2772 | }); |
| 2698 | 2773 | ||
| ... | @@ -2772,14 +2847,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2772,14 +2847,14 @@ describe("ClaudeAdapterLive", () => { |
| 2772 | 2847 | ||
| 2773 | const session = yield* adapter.startSession({ | 2848 | const session = yield* adapter.startSession({ |
| 2774 | threadId: THREAD_ID, | 2849 | threadId: THREAD_ID, |
| 2775 | provider: "claudeAgent", | 2850 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2776 | runtimeMode: "full-access", | 2851 | runtimeMode: "full-access", |
| 2777 | }); | 2852 | }); |
| 2778 | yield* adapter.sendTurn({ | 2853 | yield* adapter.sendTurn({ |
| 2779 | threadId: session.threadId, | 2854 | threadId: session.threadId, |
| 2780 | input: "hello", | 2855 | input: "hello", |
| 2781 | modelSelection: { | 2856 | modelSelection: { |
| 2782 | provider: "claudeAgent", | 2857 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 2783 | model: "claude-opus-4-6", | 2858 | model: "claude-opus-4-6", |
| 2784 | }, | 2859 | }, |
| 2785 | attachments: [], | 2860 | attachments: [], |
| ... | @@ -2792,6 +2867,34 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2792,6 +2867,34 @@ describe("ClaudeAdapterLive", () => { |
| 2792 | ); | 2867 | ); |
| 2793 | }); | 2868 | }); |
| 2794 | 2869 | ||
| 2870 | it.effect("updates model on sendTurn for the adapter's bound custom instance id", () => { | ||
| 2871 | const customInstanceId = ProviderInstanceId.make("claude_openrouter"); | ||
| 2872 | const harness = makeHarness({ instanceId: customInstanceId }); | ||
| 2873 | return Effect.gen(function* () { | ||
| 2874 | const adapter = yield* ClaudeAdapter; | ||
| 2875 | |||
| 2876 | const session = yield* adapter.startSession({ | ||
| 2877 | threadId: THREAD_ID, | ||
| 2878 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 2879 | runtimeMode: "full-access", | ||
| 2880 | }); | ||
| 2881 | yield* adapter.sendTurn({ | ||
| 2882 | threadId: session.threadId, | ||
| 2883 | input: "hello", | ||
| 2884 | modelSelection: { | ||
| 2885 | instanceId: customInstanceId, | ||
| 2886 | model: "openai/gpt-5.5", | ||
| 2887 | }, | ||
| 2888 | attachments: [], | ||
| 2889 | }); | ||
| 2890 | |||
| 2891 | assert.deepEqual(harness.query.setModelCalls, ["openai/gpt-5.5"]); | ||
| 2892 | }).pipe( | ||
| 2893 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 2894 | Effect.provide(harness.layer), | ||
| 2895 | ); | ||
| 2896 | }); | ||
| 2897 | |||
| 2795 | it.effect( | 2898 | it.effect( |
| 2796 | "does not re-set the Claude model when the session already uses the same effective API model", | 2899 | "does not re-set the Claude model when the session already uses the same effective API model", |
| 2797 | () => { | 2900 | () => { |
| ... | @@ -2799,13 +2902,13 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2799,13 +2902,13 @@ describe("ClaudeAdapterLive", () => { |
| 2799 | return Effect.gen(function* () { | 2902 | return Effect.gen(function* () { |
| 2800 | const adapter = yield* ClaudeAdapter; | 2903 | const adapter = yield* ClaudeAdapter; |
| 2801 | const modelSelection = { | 2904 | const modelSelection = { |
| 2802 | provider: "claudeAgent" as const, | 2905 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 2803 | model: "claude-opus-4-6", | 2906 | model: "claude-opus-4-6", |
| 2804 | }; | 2907 | }; |
| 2805 | 2908 | ||
| 2806 | const session = yield* adapter.startSession({ | 2909 | const session = yield* adapter.startSession({ |
| 2807 | threadId: THREAD_ID, | 2910 | threadId: THREAD_ID, |
| 2808 | provider: "claudeAgent", | 2911 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2809 | modelSelection, | 2912 | modelSelection, |
| 2810 | runtimeMode: "full-access", | 2913 | runtimeMode: "full-access", |
| 2811 | }); | 2914 | }); |
| ... | @@ -2838,23 +2941,25 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2838,23 +2941,25 @@ describe("ClaudeAdapterLive", () => { |
| 2838 | 2941 | ||
| 2839 | const session = yield* adapter.startSession({ | 2942 | const session = yield* adapter.startSession({ |
| 2840 | threadId: THREAD_ID, | 2943 | threadId: THREAD_ID, |
| 2841 | provider: "claudeAgent", | 2944 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2842 | runtimeMode: "full-access", | 2945 | runtimeMode: "full-access", |
| 2843 | }); | 2946 | }); |
| 2844 | 2947 | ||
| 2845 | yield* adapter.sendTurn({ | 2948 | yield* adapter.sendTurn({ |
| 2846 | threadId: session.threadId, | 2949 | threadId: session.threadId, |
| 2847 | input: "hello", | 2950 | input: "hello", |
| 2848 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 2951 | modelSelection: createModelSelection( |
| 2849 | { id: "contextWindow", value: "1m" }, | 2952 | ProviderInstanceId.make("claudeAgent"), |
| 2850 | ]), | 2953 | "claude-opus-4-6", |
| 2954 | [{ id: "contextWindow", value: "1m" }], | ||
| 2955 | ), | ||
| 2851 | attachments: [], | 2956 | attachments: [], |
| 2852 | }); | 2957 | }); |
| 2853 | yield* adapter.sendTurn({ | 2958 | yield* adapter.sendTurn({ |
| 2854 | threadId: session.threadId, | 2959 | threadId: session.threadId, |
| 2855 | input: "hello again", | 2960 | input: "hello again", |
| 2856 | modelSelection: { | 2961 | modelSelection: { |
| 2857 | provider: "claudeAgent", | 2962 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 2858 | model: "claude-opus-4-6", | 2963 | model: "claude-opus-4-6", |
| 2859 | }, | 2964 | }, |
| 2860 | attachments: [], | 2965 | attachments: [], |
| ... | @@ -2874,7 +2979,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2874,7 +2979,7 @@ describe("ClaudeAdapterLive", () => { |
| 2874 | 2979 | ||
| 2875 | const session = yield* adapter.startSession({ | 2980 | const session = yield* adapter.startSession({ |
| 2876 | threadId: THREAD_ID, | 2981 | threadId: THREAD_ID, |
| 2877 | provider: "claudeAgent", | 2982 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2878 | runtimeMode: "full-access", | 2983 | runtimeMode: "full-access", |
| 2879 | }); | 2984 | }); |
| 2880 | yield* adapter.sendTurn({ | 2985 | yield* adapter.sendTurn({ |
| ... | @@ -2904,7 +3009,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2904,7 +3009,7 @@ describe("ClaudeAdapterLive", () => { |
| 2904 | 3009 | ||
| 2905 | const session = yield* adapter.startSession({ | 3010 | const session = yield* adapter.startSession({ |
| 2906 | threadId: THREAD_ID, | 3011 | threadId: THREAD_ID, |
| 2907 | provider: "claudeAgent", | 3012 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2908 | runtimeMode, | 3013 | runtimeMode, |
| 2909 | }); | 3014 | }); |
| 2910 | 3015 | ||
| ... | @@ -2956,7 +3061,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2956,7 +3061,7 @@ describe("ClaudeAdapterLive", () => { |
| 2956 | 3061 | ||
| 2957 | const session = yield* adapter.startSession({ | 3062 | const session = yield* adapter.startSession({ |
| 2958 | threadId: THREAD_ID, | 3063 | threadId: THREAD_ID, |
| 2959 | provider: "claudeAgent", | 3064 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2960 | runtimeMode: "full-access", | 3065 | runtimeMode: "full-access", |
| 2961 | }); | 3066 | }); |
| 2962 | yield* adapter.sendTurn({ | 3067 | yield* adapter.sendTurn({ |
| ... | @@ -2979,7 +3084,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2979,7 +3084,7 @@ describe("ClaudeAdapterLive", () => { |
| 2979 | 3084 | ||
| 2980 | const session = yield* adapter.startSession({ | 3085 | const session = yield* adapter.startSession({ |
| 2981 | threadId: THREAD_ID, | 3086 | threadId: THREAD_ID, |
| 2982 | provider: "claudeAgent", | 3087 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2983 | runtimeMode: "full-access", | 3088 | runtimeMode: "full-access", |
| 2984 | }); | 3089 | }); |
| 2985 | 3090 | ||
| ... | @@ -3045,7 +3150,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3045,7 +3150,7 @@ describe("ClaudeAdapterLive", () => { |
| 3045 | 3150 | ||
| 3046 | const session = yield* adapter.startSession({ | 3151 | const session = yield* adapter.startSession({ |
| 3047 | threadId: THREAD_ID, | 3152 | threadId: THREAD_ID, |
| 3048 | provider: "claudeAgent", | 3153 | provider: ProviderDriverKind.make("claudeAgent"), |
| 3049 | runtimeMode: "full-access", | 3154 | runtimeMode: "full-access", |
| 3050 | }); | 3155 | }); |
| 3051 | 3156 | ||
| ... | @@ -3117,7 +3222,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3117,7 +3222,7 @@ describe("ClaudeAdapterLive", () => { |
| 3117 | // Start session in approval-required mode so canUseTool fires. | 3222 | // Start session in approval-required mode so canUseTool fires. |
| 3118 | const session = yield* adapter.startSession({ | 3223 | const session = yield* adapter.startSession({ |
| 3119 | threadId: THREAD_ID, | 3224 | threadId: THREAD_ID, |
| 3120 | provider: "claudeAgent", | 3225 | provider: ProviderDriverKind.make("claudeAgent"), |
| 3121 | runtimeMode: "approval-required", | 3226 | runtimeMode: "approval-required", |
| 3122 | }); | 3227 | }); |
| 3123 | 3228 | ||
| ... | @@ -3240,7 +3345,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3240,7 +3345,7 @@ describe("ClaudeAdapterLive", () => { |
| 3240 | // AskUserQuestion should still go through the user-input flow. | 3345 | // AskUserQuestion should still go through the user-input flow. |
| 3241 | const session = yield* adapter.startSession({ | 3346 | const session = yield* adapter.startSession({ |
| 3242 | threadId: THREAD_ID, | 3347 | threadId: THREAD_ID, |
| 3243 | provider: "claudeAgent", | 3348 | provider: ProviderDriverKind.make("claudeAgent"), |
| 3244 | runtimeMode: "full-access", | 3349 | runtimeMode: "full-access", |
| 3245 | }); | 3350 | }); |
| 3246 | 3351 | ||
| ... | @@ -3306,7 +3411,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3306,7 +3411,7 @@ describe("ClaudeAdapterLive", () => { |
| 3306 | 3411 | ||
| 3307 | const session = yield* adapter.startSession({ | 3412 | const session = yield* adapter.startSession({ |
| 3308 | threadId: THREAD_ID, | 3413 | threadId: THREAD_ID, |
| 3309 | provider: "claudeAgent", | 3414 | provider: ProviderDriverKind.make("claudeAgent"), |
| 3310 | runtimeMode: "approval-required", | 3415 | runtimeMode: "approval-required", |
| 3311 | }); | 3416 | }); |
| 3312 | 3417 | ||
| ... | @@ -3393,7 +3498,7 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3393,7 +3498,7 @@ describe("ClaudeAdapterLive", () => { |
| 3393 | 3498 | ||
| 3394 | const session = yield* adapter.startSession({ | 3499 | const session = yield* adapter.startSession({ |
| 3395 | threadId: THREAD_ID, | 3500 | threadId: THREAD_ID, |
| 3396 | provider: "claudeAgent", | 3501 | provider: ProviderDriverKind.make("claudeAgent"), |
| 3397 | runtimeMode: "full-access", | 3502 | runtimeMode: "full-access", |
| 3398 | }); | 3503 | }); |
| 3399 | const turn = yield* adapter.sendTurn({ | 3504 | const turn = yield* adapter.sendTurn({ |
apps/server/src/provider/Layers/ClaudeAdapter.ts+31-32| ... | @@ -24,8 +24,11 @@ import { | ... | @@ -24,8 +24,11 @@ import { |
| 24 | ApprovalRequestId, | 24 | ApprovalRequestId, |
| 25 | type CanonicalItemType, | 25 | type CanonicalItemType, |
| 26 | type CanonicalRequestType, | 26 | type CanonicalRequestType, |
| 27 | type ClaudeSettings, | ||
| 27 | EventId, | 28 | EventId, |
| 28 | type ProviderApprovalDecision, | 29 | type ProviderApprovalDecision, |
| 30 | ProviderDriverKind, | ||
| 31 | ProviderInstanceId, | ||
| 29 | ProviderItemId, | 32 | ProviderItemId, |
| 30 | type ProviderRuntimeEvent, | 33 | type ProviderRuntimeEvent, |
| 31 | type ProviderRuntimeTurnStatus, | 34 | type ProviderRuntimeTurnStatus, |
| ... | @@ -56,7 +59,7 @@ import { | ... | @@ -56,7 +59,7 @@ import { |
| 56 | Exit, | 59 | Exit, |
| 57 | FileSystem, | 60 | FileSystem, |
| 58 | Fiber, | 61 | Fiber, |
| 59 | Layer, | 62 | Path, |
| 60 | Queue, | 63 | Queue, |
| 61 | Random, | 64 | Random, |
| 62 | Ref, | 65 | Ref, |
| ... | @@ -65,7 +68,7 @@ import { | ... | @@ -65,7 +68,7 @@ import { |
| 65 | 68 | ||
| 66 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 69 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 67 | import { ServerConfig } from "../../config.ts"; | 70 | import { ServerConfig } from "../../config.ts"; |
| 68 | import { ServerSettingsService } from "../../serverSettings.ts"; | 71 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 69 | import { | 72 | import { |
| 70 | getClaudeModelCapabilities, | 73 | getClaudeModelCapabilities, |
| 71 | normalizeClaudeCliEffort, | 74 | normalizeClaudeCliEffort, |
| ... | @@ -80,10 +83,10 @@ import { | ... | @@ -80,10 +83,10 @@ import { |
| 80 | ProviderAdapterValidationError, | 83 | ProviderAdapterValidationError, |
| 81 | type ProviderAdapterError, | 84 | type ProviderAdapterError, |
| 82 | } from "../Errors.ts"; | 85 | } from "../Errors.ts"; |
| 83 | import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; | 86 | import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 84 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 87 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 85 | 88 | ||
| 86 | const PROVIDER = "claudeAgent" as const; | 89 | const PROVIDER = ProviderDriverKind.make("claudeAgent"); |
| 87 | type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">; | 90 | type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">; |
| 88 | type ClaudeToolResultStreamKind = Extract< | 91 | type ClaudeToolResultStreamKind = Extract< |
| 89 | RuntimeContentStreamKind, | 92 | RuntimeContentStreamKind, |
| ... | @@ -182,6 +185,8 @@ interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { | ... | @@ -182,6 +185,8 @@ interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> { |
| 182 | } | 185 | } |
| 183 | 186 | ||
| 184 | export interface ClaudeAdapterLiveOptions { | 187 | export interface ClaudeAdapterLiveOptions { |
| 188 | readonly instanceId?: ProviderInstanceId; | ||
| 189 | readonly environment?: NodeJS.ProcessEnv; | ||
| 185 | readonly createQuery?: (input: { | 190 | readonly createQuery?: (input: { |
| 186 | readonly prompt: AsyncIterable<SDKUserMessage>; | 191 | readonly prompt: AsyncIterable<SDKUserMessage>; |
| 187 | readonly options: ClaudeQueryOptions; | 192 | readonly options: ClaudeQueryOptions; |
| ... | @@ -565,13 +570,16 @@ const CLAUDE_SETTING_SOURCES = [ | ... | @@ -565,13 +570,16 @@ const CLAUDE_SETTING_SOURCES = [ |
| 565 | "local", | 570 | "local", |
| 566 | ] as const satisfies ReadonlyArray<SettingSource>; | 571 | ] as const satisfies ReadonlyArray<SettingSource>; |
| 567 | 572 | ||
| 568 | function buildPromptText(input: ProviderSendTurnInput): string { | 573 | function buildPromptText( |
| 574 | input: ProviderSendTurnInput, | ||
| 575 | boundInstanceId: ProviderInstanceId, | ||
| 576 | ): string { | ||
| 569 | const rawEffort = | 577 | const rawEffort = |
| 570 | input.modelSelection?.provider === "claudeAgent" | 578 | input.modelSelection?.instanceId === boundInstanceId |
| 571 | ? getModelSelectionStringOptionValue(input.modelSelection, "effort") | 579 | ? getModelSelectionStringOptionValue(input.modelSelection, "effort") |
| 572 | : null; | 580 | : null; |
| 573 | const claudeModel = | 581 | const claudeModel = |
| 574 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; | 582 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; |
| 575 | const caps = getClaudeModelCapabilities(claudeModel); | 583 | const caps = getClaudeModelCapabilities(claudeModel); |
| 576 | 584 | ||
| 577 | const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); | 585 | const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); |
| ... | @@ -611,9 +619,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( | ... | @@ -611,9 +619,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( |
| 611 | dependencies: { | 619 | dependencies: { |
| 612 | readonly fileSystem: FileSystem.FileSystem; | 620 | readonly fileSystem: FileSystem.FileSystem; |
| 613 | readonly attachmentsDir: string; | 621 | readonly attachmentsDir: string; |
| 622 | readonly boundInstanceId: ProviderInstanceId; | ||
| 614 | }, | 623 | }, |
| 615 | ) { | 624 | ) { |
| 616 | const text = buildPromptText(input); | 625 | const text = buildPromptText(input, dependencies.boundInstanceId); |
| 617 | const sdkContent: Array<Record<string, unknown>> = []; | 626 | const sdkContent: Array<Record<string, unknown>> = []; |
| 618 | 627 | ||
| 619 | if (text.length > 0) { | 628 | if (text.length > 0) { |
| ... | @@ -963,11 +972,17 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { | ... | @@ -963,11 +972,17 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { |
| 963 | return undefined; | 972 | return undefined; |
| 964 | } | 973 | } |
| 965 | 974 | ||
| 966 | const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | 975 | export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 976 | claudeSettings: ClaudeSettings, | ||
| 967 | options?: ClaudeAdapterLiveOptions, | 977 | options?: ClaudeAdapterLiveOptions, |
| 968 | ) { | 978 | ) { |
| 979 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); | ||
| 969 | const fileSystem = yield* FileSystem.FileSystem; | 980 | const fileSystem = yield* FileSystem.FileSystem; |
| 981 | const path = yield* Path.Path; | ||
| 970 | const serverConfig = yield* ServerConfig; | 982 | const serverConfig = yield* ServerConfig; |
| 983 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( | ||
| 984 | Effect.provideService(Path.Path, path), | ||
| 985 | ); | ||
| 971 | const nativeEventLogger = | 986 | const nativeEventLogger = |
| 972 | options?.nativeEventLogger ?? | 987 | options?.nativeEventLogger ?? |
| 973 | (options?.nativeEventLogPath !== undefined | 988 | (options?.nativeEventLogPath !== undefined |
| ... | @@ -989,7 +1004,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -989,7 +1004,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 989 | 1004 | ||
| 990 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); | 1005 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); |
| 991 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 1006 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 992 | const serverSettingsService = yield* ServerSettingsService; | ||
| 993 | 1007 | ||
| 994 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 1008 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 995 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); | 1009 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); |
| ... | @@ -2820,22 +2834,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2820,22 +2834,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2820 | const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => | 2834 | const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => |
| 2821 | runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); | 2835 | runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); |
| 2822 | 2836 | ||
| 2823 | const claudeSettings = yield* serverSettingsService.getSettings.pipe( | ||
| 2824 | Effect.map((settings) => settings.providers.claudeAgent), | ||
| 2825 | Effect.mapError( | ||
| 2826 | (error) => | ||
| 2827 | new ProviderAdapterProcessError({ | ||
| 2828 | provider: PROVIDER, | ||
| 2829 | threadId: input.threadId, | ||
| 2830 | detail: error.message, | ||
| 2831 | cause: error, | ||
| 2832 | }), | ||
| 2833 | ), | ||
| 2834 | ); | ||
| 2835 | const claudeBinaryPath = claudeSettings.binaryPath; | 2837 | const claudeBinaryPath = claudeSettings.binaryPath; |
| 2836 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; | 2838 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; |
| 2837 | const modelSelection = | 2839 | const modelSelection = |
| 2838 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; | 2840 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; |
| 2839 | const caps = getClaudeModelCapabilities(modelSelection?.model); | 2841 | const caps = getClaudeModelCapabilities(modelSelection?.model); |
| 2840 | const descriptors = getProviderOptionDescriptors({ caps }); | 2842 | const descriptors = getProviderOptionDescriptors({ caps }); |
| 2841 | const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; | 2843 | const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; |
| ... | @@ -2863,7 +2865,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2863,7 +2865,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2863 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), | 2865 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 2864 | ...(fastMode ? { fastMode: true } : {}), | 2866 | ...(fastMode ? { fastMode: true } : {}), |
| 2865 | }; | 2867 | }; |
| 2866 | |||
| 2867 | const queryOptions: ClaudeQueryOptions = { | 2868 | const queryOptions: ClaudeQueryOptions = { |
| 2868 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2869 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2869 | ...(apiModelId ? { model: apiModelId } : {}), | 2870 | ...(apiModelId ? { model: apiModelId } : {}), |
| ... | @@ -2885,7 +2886,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2885,7 +2886,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2885 | ...(newSessionId ? { sessionId: newSessionId } : {}), | 2886 | ...(newSessionId ? { sessionId: newSessionId } : {}), |
| 2886 | includePartialMessages: true, | 2887 | includePartialMessages: true, |
| 2887 | canUseTool, | 2888 | canUseTool, |
| 2888 | env: process.env, | 2889 | env: claudeEnvironment, |
| 2889 | ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), | 2890 | ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), |
| 2890 | ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), | 2891 | ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), |
| 2891 | }; | 2892 | }; |
| ... | @@ -2933,6 +2934,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2933,6 +2934,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2933 | const session: ProviderSession = { | 2934 | const session: ProviderSession = { |
| 2934 | threadId, | 2935 | threadId, |
| 2935 | provider: PROVIDER, | 2936 | provider: PROVIDER, |
| 2937 | providerInstanceId: boundInstanceId, | ||
| 2936 | status: "ready", | 2938 | status: "ready", |
| 2937 | runtimeMode: input.runtimeMode, | 2939 | runtimeMode: input.runtimeMode, |
| 2938 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2940 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| ... | @@ -3044,7 +3046,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3044,7 +3046,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3044 | const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { | 3046 | const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { |
| 3045 | const context = yield* requireSession(input.threadId); | 3047 | const context = yield* requireSession(input.threadId); |
| 3046 | const modelSelection = | 3048 | const modelSelection = |
| 3047 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; | 3049 | input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId |
| 3050 | ? input.modelSelection | ||
| 3051 | : undefined; | ||
| 3048 | 3052 | ||
| 3049 | if (context.turnState) { | 3053 | if (context.turnState) { |
| 3050 | // Auto-close a stale synthetic turn (from background agent responses | 3054 | // Auto-close a stale synthetic turn (from background agent responses |
| ... | @@ -3118,6 +3122,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3118,6 +3122,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3118 | const message = yield* buildUserMessageEffect(input, { | 3122 | const message = yield* buildUserMessageEffect(input, { |
| 3119 | fileSystem, | 3123 | fileSystem, |
| 3120 | attachmentsDir: serverConfig.attachmentsDir, | 3124 | attachmentsDir: serverConfig.attachmentsDir, |
| 3125 | boundInstanceId, | ||
| 3121 | }); | 3126 | }); |
| 3122 | 3127 | ||
| 3123 | yield* Queue.offer(context.promptQueue, { | 3128 | yield* Queue.offer(context.promptQueue, { |
| ... | @@ -3255,9 +3260,3 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3255,9 +3260,3 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3255 | }, | 3260 | }, |
| 3256 | } satisfies ClaudeAdapterShape; | 3261 | } satisfies ClaudeAdapterShape; |
| 3257 | }); | 3262 | }); |
| 3258 | |||
| 3259 | export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); | ||
| 3260 | |||
| 3261 | export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { | ||
| 3262 | return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options)); | ||
| 3263 | } |
apps/server/src/provider/Layers/ClaudeProvider.ts+131-353| ... | @@ -1,16 +1,13 @@ | ... | @@ -1,16 +1,13 @@ |
| 1 | import type { | 1 | import { |
| 2 | ClaudeSettings, | 2 | type ClaudeSettings, |
| 3 | ClaudeModelSelection, | 3 | type ModelCapabilities, |
| 4 | ModelCapabilities, | 4 | type ModelSelection, |
| 5 | ServerProvider, | 5 | ProviderDriverKind, |
| 6 | ServerProviderModel, | 6 | type ServerProviderModel, |
| 7 | ServerProviderAuth, | 7 | type ServerProviderSlashCommand, |
| 8 | ServerProviderSlashCommand, | ||
| 9 | ServerProviderState, | ||
| 10 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 11 | import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; | 9 | import { Effect, Option, Path, Result } from "effect"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 14 | import { | 11 | import { |
| 15 | createModelCapabilities, | 12 | createModelCapabilities, |
| 16 | getModelSelectionStringOptionValue, | 13 | getModelSelectionStringOptionValue, |
| ... | @@ -27,27 +24,22 @@ import { | ... | @@ -27,27 +24,22 @@ import { |
| 27 | buildBooleanOptionDescriptor, | 24 | buildBooleanOptionDescriptor, |
| 28 | buildSelectOptionDescriptor, | 25 | buildSelectOptionDescriptor, |
| 29 | buildServerProvider, | 26 | buildServerProvider, |
| 30 | AUTH_PROBE_TIMEOUT_MS, | ||
| 31 | DEFAULT_TIMEOUT_MS, | 27 | DEFAULT_TIMEOUT_MS, |
| 32 | detailFromResult, | 28 | detailFromResult, |
| 33 | extractAuthBoolean, | ||
| 34 | isCommandMissingCause, | 29 | isCommandMissingCause, |
| 35 | parseGenericCliVersion, | 30 | parseGenericCliVersion, |
| 36 | providerModelsFromSettings, | 31 | providerModelsFromSettings, |
| 37 | spawnAndCollect, | 32 | spawnAndCollect, |
| 38 | type CommandResult, | 33 | type ServerProviderDraft, |
| 39 | } from "../providerSnapshot.ts"; | 34 | } from "../providerSnapshot.ts"; |
| 40 | import { compareCliVersions } from "../cliVersion.ts"; | 35 | import { compareCliVersions } from "../cliVersion.ts"; |
| 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 36 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 42 | import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; | ||
| 43 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 44 | import { ServerSettingsError } from "@t3tools/contracts"; | ||
| 45 | 37 | ||
| 46 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | 38 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 47 | optionDescriptors: [], | 39 | optionDescriptors: [], |
| 48 | }); | 40 | }); |
| 49 | 41 | ||
| 50 | const PROVIDER = "claudeAgent" as const; | 42 | const PROVIDER = ProviderDriverKind.make("claudeAgent"); |
| 51 | const CLAUDE_PRESENTATION = { | 43 | const CLAUDE_PRESENTATION = { |
| 52 | displayName: "Claude", | 44 | displayName: "Claude", |
| 53 | showInteractionModeToggle: true, | 45 | showInteractionModeToggle: true, |
| ... | @@ -242,7 +234,7 @@ export function normalizeClaudeCliEffort(effort: string | null | undefined): str | ... | @@ -242,7 +234,7 @@ export function normalizeClaudeCliEffort(effort: string | null | undefined): str |
| 242 | return effort; | 234 | return effort; |
| 243 | } | 235 | } |
| 244 | 236 | ||
| 245 | export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { | 237 | export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { |
| 246 | switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { | 238 | switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { |
| 247 | case "1m": | 239 | case "1m": |
| 248 | return `${modelSelection.model}[1m]`; | 240 | return `${modelSelection.model}[1m]`; |
| ... | @@ -250,181 +242,6 @@ export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): s | ... | @@ -250,181 +242,6 @@ export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): s |
| 250 | return modelSelection.model; | 242 | return modelSelection.model; |
| 251 | } | 243 | } |
| 252 | } | 244 | } |
| 253 | export function parseClaudeAuthStatusFromOutput(result: CommandResult): { | ||
| 254 | readonly status: Exclude<ServerProviderState, "disabled">; | ||
| 255 | readonly auth: Pick<ServerProviderAuth, "status">; | ||
| 256 | readonly message?: string; | ||
| 257 | } { | ||
| 258 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); | ||
| 259 | |||
| 260 | if ( | ||
| 261 | lowerOutput.includes("unknown command") || | ||
| 262 | lowerOutput.includes("unrecognized command") || | ||
| 263 | lowerOutput.includes("unexpected argument") | ||
| 264 | ) { | ||
| 265 | return { | ||
| 266 | status: "warning", | ||
| 267 | auth: { status: "unknown" }, | ||
| 268 | message: | ||
| 269 | "Claude Agent authentication status command is unavailable in this version of Claude.", | ||
| 270 | }; | ||
| 271 | } | ||
| 272 | |||
| 273 | if ( | ||
| 274 | lowerOutput.includes("not logged in") || | ||
| 275 | lowerOutput.includes("login required") || | ||
| 276 | lowerOutput.includes("authentication required") || | ||
| 277 | lowerOutput.includes("run `claude login`") || | ||
| 278 | lowerOutput.includes("run claude login") | ||
| 279 | ) { | ||
| 280 | return { | ||
| 281 | status: "error", | ||
| 282 | auth: { status: "unauthenticated" }, | ||
| 283 | message: "Claude is not authenticated. Run `claude auth login` and try again.", | ||
| 284 | }; | ||
| 285 | } | ||
| 286 | |||
| 287 | const parsedAuth = (() => { | ||
| 288 | const trimmed = result.stdout.trim(); | ||
| 289 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { | ||
| 290 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | ||
| 291 | } | ||
| 292 | try { | ||
| 293 | return { | ||
| 294 | attemptedJsonParse: true as const, | ||
| 295 | auth: extractAuthBoolean(JSON.parse(trimmed)), | ||
| 296 | }; | ||
| 297 | } catch { | ||
| 298 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; | ||
| 299 | } | ||
| 300 | })(); | ||
| 301 | |||
| 302 | if (parsedAuth.auth === true) { | ||
| 303 | return { status: "ready", auth: { status: "authenticated" } }; | ||
| 304 | } | ||
| 305 | if (parsedAuth.auth === false) { | ||
| 306 | return { | ||
| 307 | status: "error", | ||
| 308 | auth: { status: "unauthenticated" }, | ||
| 309 | message: "Claude is not authenticated. Run `claude auth login` and try again.", | ||
| 310 | }; | ||
| 311 | } | ||
| 312 | if (parsedAuth.attemptedJsonParse) { | ||
| 313 | return { | ||
| 314 | status: "warning", | ||
| 315 | auth: { status: "unknown" }, | ||
| 316 | message: | ||
| 317 | "Could not verify Claude authentication status from JSON output (missing auth marker).", | ||
| 318 | }; | ||
| 319 | } | ||
| 320 | if (result.code === 0) { | ||
| 321 | return { status: "ready", auth: { status: "authenticated" } }; | ||
| 322 | } | ||
| 323 | |||
| 324 | const detail = detailFromResult(result); | ||
| 325 | return { | ||
| 326 | status: "warning", | ||
| 327 | auth: { status: "unknown" }, | ||
| 328 | message: detail | ||
| 329 | ? `Could not verify Claude authentication status. ${detail}` | ||
| 330 | : "Could not verify Claude authentication status.", | ||
| 331 | }; | ||
| 332 | } | ||
| 333 | |||
| 334 | // ── Subscription type detection ───────────────────────────────────── | ||
| 335 | // | ||
| 336 | // The SDK probe returns typed `AccountInfo.subscriptionType` directly. | ||
| 337 | // This walker is a best-effort fallback for the `claude auth status` | ||
| 338 | // JSON output whose shape is not guaranteed. | ||
| 339 | |||
| 340 | /** Keys that directly hold a subscription/plan identifier. */ | ||
| 341 | const SUBSCRIPTION_TYPE_KEYS = [ | ||
| 342 | "subscriptionType", | ||
| 343 | "subscription_type", | ||
| 344 | "plan", | ||
| 345 | "tier", | ||
| 346 | "planType", | ||
| 347 | "plan_type", | ||
| 348 | ] as const; | ||
| 349 | |||
| 350 | /** Keys whose value may be a nested object containing subscription info. */ | ||
| 351 | const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const; | ||
| 352 | const AUTH_METHOD_KEYS = ["authMethod", "auth_method"] as const; | ||
| 353 | const AUTH_METHOD_CONTAINER_KEYS = ["auth", "account", "session"] as const; | ||
| 354 | |||
| 355 | /** Lift an unknown value into `Option<string>` if it is a non-empty string. */ | ||
| 356 | const asNonEmptyString = (v: unknown): Option.Option<string> => | ||
| 357 | typeof v === "string" && v.length > 0 ? Option.some(v) : Option.none(); | ||
| 358 | |||
| 359 | /** Lift an unknown value into `Option<Record>` if it is a plain object. */ | ||
| 360 | const asRecord = (v: unknown): Option.Option<Record<string, unknown>> => | ||
| 361 | typeof v === "object" && v !== null && !globalThis.Array.isArray(v) | ||
| 362 | ? Option.some(v as Record<string, unknown>) | ||
| 363 | : Option.none(); | ||
| 364 | |||
| 365 | /** | ||
| 366 | * Walk an unknown parsed JSON value looking for a subscription/plan | ||
| 367 | * identifier, returning the first match as an `Option`. | ||
| 368 | */ | ||
| 369 | function findSubscriptionType(value: unknown): Option.Option<string> { | ||
| 370 | if (globalThis.Array.isArray(value)) { | ||
| 371 | return Option.firstSomeOf(value.map(findSubscriptionType)); | ||
| 372 | } | ||
| 373 | |||
| 374 | return asRecord(value).pipe( | ||
| 375 | Option.flatMap((record) => { | ||
| 376 | const direct = Option.firstSomeOf( | ||
| 377 | SUBSCRIPTION_TYPE_KEYS.map((key) => asNonEmptyString(record[key])), | ||
| 378 | ); | ||
| 379 | if (Option.isSome(direct)) return direct; | ||
| 380 | |||
| 381 | return Option.firstSomeOf( | ||
| 382 | SUBSCRIPTION_CONTAINER_KEYS.map((key) => | ||
| 383 | asRecord(record[key]).pipe(Option.flatMap(findSubscriptionType)), | ||
| 384 | ), | ||
| 385 | ); | ||
| 386 | }), | ||
| 387 | ); | ||
| 388 | } | ||
| 389 | |||
| 390 | function findAuthMethod(value: unknown): Option.Option<string> { | ||
| 391 | if (globalThis.Array.isArray(value)) { | ||
| 392 | return Option.firstSomeOf(value.map(findAuthMethod)); | ||
| 393 | } | ||
| 394 | |||
| 395 | return asRecord(value).pipe( | ||
| 396 | Option.flatMap((record) => { | ||
| 397 | const direct = Option.firstSomeOf( | ||
| 398 | AUTH_METHOD_KEYS.map((key) => asNonEmptyString(record[key])), | ||
| 399 | ); | ||
| 400 | if (Option.isSome(direct)) return direct; | ||
| 401 | |||
| 402 | return Option.firstSomeOf( | ||
| 403 | AUTH_METHOD_CONTAINER_KEYS.map((key) => | ||
| 404 | asRecord(record[key]).pipe(Option.flatMap(findAuthMethod)), | ||
| 405 | ), | ||
| 406 | ); | ||
| 407 | }), | ||
| 408 | ); | ||
| 409 | } | ||
| 410 | |||
| 411 | /** | ||
| 412 | * Try to extract a subscription type from the `claude auth status` JSON | ||
| 413 | * output. This is a zero-cost operation on data we already have. | ||
| 414 | */ | ||
| 415 | const decodeUnknownJson = decodeJsonResult(Schema.Unknown); | ||
| 416 | |||
| 417 | function extractSubscriptionTypeFromOutput(result: CommandResult): string | undefined { | ||
| 418 | const parsed = decodeUnknownJson(result.stdout.trim()); | ||
| 419 | if (Result.isFailure(parsed)) return undefined; | ||
| 420 | return Option.getOrUndefined(findSubscriptionType(parsed.success)); | ||
| 421 | } | ||
| 422 | |||
| 423 | function extractClaudeAuthMethodFromOutput(result: CommandResult): string | undefined { | ||
| 424 | const parsed = decodeUnknownJson(result.stdout.trim()); | ||
| 425 | if (Result.isFailure(parsed)) return undefined; | ||
| 426 | return Option.getOrUndefined(findAuthMethod(parsed.success)); | ||
| 427 | } | ||
| 428 | 245 | ||
| 429 | function toTitleCaseWords(value: string): string { | 246 | function toTitleCaseWords(value: string): string { |
| 430 | return value | 247 | return value |
| ... | @@ -439,11 +256,27 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | | ... | @@ -439,11 +256,27 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | |
| 439 | if (!normalized) return undefined; | 256 | if (!normalized) return undefined; |
| 440 | 257 | ||
| 441 | switch (normalized) { | 258 | switch (normalized) { |
| 259 | case "claudemaxsubscription": | ||
| 260 | return "Max"; | ||
| 261 | case "claudemax5xsubscription": | ||
| 262 | return "Max 5x"; | ||
| 263 | case "claudemax20xsubscription": | ||
| 264 | return "Max 20x"; | ||
| 265 | case "claudeenterprisesubscription": | ||
| 266 | return "Enterprise"; | ||
| 267 | case "claudeteamsubscription": | ||
| 268 | return "Team"; | ||
| 269 | case "claudeprosubscription": | ||
| 270 | return "Pro"; | ||
| 271 | case "claudefreesubscription": | ||
| 272 | return "Free"; | ||
| 442 | case "max": | 273 | case "max": |
| 443 | case "maxplan": | 274 | case "maxplan": |
| 275 | return "Max"; | ||
| 444 | case "max5": | 276 | case "max5": |
| 277 | return "Max 5x"; | ||
| 445 | case "max20": | 278 | case "max20": |
| 446 | return "Max"; | 279 | return "Max 20x"; |
| 447 | case "enterprise": | 280 | case "enterprise": |
| 448 | return "Enterprise"; | 281 | return "Enterprise"; |
| 449 | case "team": | 282 | case "team": |
| ... | @@ -460,10 +293,33 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | | ... | @@ -460,10 +293,33 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | |
| 460 | function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { | 293 | function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { |
| 461 | const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); | 294 | const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); |
| 462 | if (!normalized) return undefined; | 295 | if (!normalized) return undefined; |
| 463 | if (normalized === "apikey") return "apiKey"; | 296 | if ( |
| 297 | normalized === "apikey" || | ||
| 298 | normalized === "anthropicapikey" || | ||
| 299 | normalized === "anthropicauthtoken" | ||
| 300 | ) { | ||
| 301 | return "apiKey"; | ||
| 302 | } | ||
| 464 | return undefined; | 303 | return undefined; |
| 465 | } | 304 | } |
| 466 | 305 | ||
| 306 | function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { | ||
| 307 | const subscriptionLabel = | ||
| 308 | claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); | ||
| 309 | const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 310 | |||
| 311 | if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { | ||
| 312 | return subscriptionLabel; | ||
| 313 | } | ||
| 314 | if (normalized.startsWith("claude")) { | ||
| 315 | return `${subscriptionLabel} Subscription`; | ||
| 316 | } | ||
| 317 | if (normalized.endsWith("subscription")) { | ||
| 318 | return `Claude ${subscriptionLabel}`; | ||
| 319 | } | ||
| 320 | return `Claude ${subscriptionLabel} Subscription`; | ||
| 321 | } | ||
| 322 | |||
| 467 | function claudeAuthMetadata(input: { | 323 | function claudeAuthMetadata(input: { |
| 468 | readonly subscriptionType: string | undefined; | 324 | readonly subscriptionType: string | undefined; |
| 469 | readonly authMethod: string | undefined; | 325 | readonly authMethod: string | undefined; |
| ... | @@ -476,10 +332,9 @@ function claudeAuthMetadata(input: { | ... | @@ -476,10 +332,9 @@ function claudeAuthMetadata(input: { |
| 476 | } | 332 | } |
| 477 | 333 | ||
| 478 | if (input.subscriptionType) { | 334 | if (input.subscriptionType) { |
| 479 | const subscriptionLabel = claudeSubscriptionLabel(input.subscriptionType); | ||
| 480 | return { | 335 | return { |
| 481 | type: input.subscriptionType, | 336 | type: input.subscriptionType, |
| 482 | label: `Claude ${subscriptionLabel ?? toTitleCaseWords(input.subscriptionType)} Subscription`, | 337 | label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), |
| 483 | }; | 338 | }; |
| 484 | } | 339 | } |
| 485 | 340 | ||
| ... | @@ -495,6 +350,13 @@ function nonEmptyProbeString(value: string): string | undefined { | ... | @@ -495,6 +350,13 @@ function nonEmptyProbeString(value: string): string | undefined { |
| 495 | return candidate ? candidate : undefined; | 350 | return candidate ? candidate : undefined; |
| 496 | } | 351 | } |
| 497 | 352 | ||
| 353 | type ClaudeCapabilitiesProbe = { | ||
| 354 | readonly email: string | undefined; | ||
| 355 | readonly subscriptionType: string | undefined; | ||
| 356 | readonly tokenSource: string | undefined; | ||
| 357 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | ||
| 358 | }; | ||
| 359 | |||
| 498 | function parseClaudeInitializationCommands( | 360 | function parseClaudeInitializationCommands( |
| 499 | commands: ReadonlyArray<ClaudeSlashCommand> | undefined, | 361 | commands: ReadonlyArray<ClaudeSlashCommand> | undefined, |
| 500 | ): ReadonlyArray<ServerProviderSlashCommand> { | 362 | ): ReadonlyArray<ServerProviderSlashCommand> { |
| ... | @@ -580,30 +442,46 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> { | ... | @@ -580,30 +442,46 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> { |
| 580 | * This is used as a fallback when `claude auth status` does not include | 442 | * This is used as a fallback when `claude auth status` does not include |
| 581 | * subscription type information. | 443 | * subscription type information. |
| 582 | */ | 444 | */ |
| 583 | const probeClaudeCapabilities = (binaryPath: string) => { | 445 | const probeClaudeCapabilities = ( |
| 446 | claudeSettings: ClaudeSettings, | ||
| 447 | environment: NodeJS.ProcessEnv = process.env, | ||
| 448 | ) => { | ||
| 584 | const abort = new AbortController(); | 449 | const abort = new AbortController(); |
| 585 | return Effect.tryPromise(async () => { | 450 | return Effect.gen(function* () { |
| 586 | const q = claudeQuery({ | 451 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 587 | // Never yield — we only need initialization data, not a conversation. | 452 | return yield* Effect.tryPromise(async () => { |
| 588 | // This prevents any prompt from reaching the Anthropic API. | 453 | const q = claudeQuery({ |
| 589 | // oxlint-disable-next-line require-yield | 454 | // Never yield — we only need initialization data, not a conversation. |
| 590 | prompt: (async function* (): AsyncGenerator<SDKUserMessage> { | 455 | // This prevents any prompt from reaching the Anthropic API. |
| 591 | await waitForAbortSignal(abort.signal); | 456 | // oxlint-disable-next-line require-yield |
| 592 | })(), | 457 | prompt: (async function* (): AsyncGenerator<SDKUserMessage> { |
| 593 | options: { | 458 | await waitForAbortSignal(abort.signal); |
| 594 | persistSession: false, | 459 | })(), |
| 595 | pathToClaudeCodeExecutable: binaryPath, | 460 | options: { |
| 596 | abortController: abort, | 461 | persistSession: false, |
| 597 | settingSources: ["user", "project", "local"], | 462 | pathToClaudeCodeExecutable: claudeSettings.binaryPath, |
| 598 | allowedTools: [], | 463 | abortController: abort, |
| 599 | stderr: () => {}, | 464 | settingSources: ["user", "project", "local"], |
| 600 | }, | 465 | allowedTools: [], |
| 466 | env: claudeEnvironment, | ||
| 467 | stderr: () => {}, | ||
| 468 | }, | ||
| 469 | }); | ||
| 470 | const init = await q.initializationResult(); | ||
| 471 | const account = init.account as | ||
| 472 | | { | ||
| 473 | readonly email?: string; | ||
| 474 | readonly subscriptionType?: string; | ||
| 475 | readonly tokenSource?: string; | ||
| 476 | } | ||
| 477 | | undefined; | ||
| 478 | return { | ||
| 479 | email: account?.email, | ||
| 480 | subscriptionType: account?.subscriptionType, | ||
| 481 | tokenSource: account?.tokenSource, | ||
| 482 | slashCommands: parseClaudeInitializationCommands(init.commands), | ||
| 483 | } satisfies ClaudeCapabilitiesProbe; | ||
| 601 | }); | 484 | }); |
| 602 | const init = await q.initializationResult(); | ||
| 603 | return { | ||
| 604 | subscriptionType: init.account?.subscriptionType, | ||
| 605 | slashCommands: parseClaudeInitializationCommands(init.commands), | ||
| 606 | }; | ||
| 607 | }).pipe( | 485 | }).pipe( |
| 608 | Effect.ensuring( | 486 | Effect.ensuring( |
| 609 | Effect.sync(() => { | 487 | Effect.sync(() => { |
| ... | @@ -619,31 +497,30 @@ const probeClaudeCapabilities = (binaryPath: string) => { | ... | @@ -619,31 +497,30 @@ const probeClaudeCapabilities = (binaryPath: string) => { |
| 619 | ); | 497 | ); |
| 620 | }; | 498 | }; |
| 621 | 499 | ||
| 622 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: ReadonlyArray<string>) { | 500 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( |
| 623 | const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( | 501 | claudeSettings: ClaudeSettings, |
| 624 | Effect.flatMap((service) => service.getSettings), | 502 | args: ReadonlyArray<string>, |
| 625 | Effect.map((settings) => settings.providers.claudeAgent), | 503 | environment: NodeJS.ProcessEnv = process.env, |
| 626 | ); | 504 | ) { |
| 505 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 627 | const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { | 506 | const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { |
| 507 | env: claudeEnvironment, | ||
| 628 | shell: process.platform === "win32", | 508 | shell: process.platform === "win32", |
| 629 | }); | 509 | }); |
| 630 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); | 510 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); |
| 631 | }); | 511 | }); |
| 632 | 512 | ||
| 633 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( | 513 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( |
| 634 | resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>, | 514 | claudeSettings: ClaudeSettings, |
| 635 | resolveSlashCommands?: ( | 515 | resolveCapabilities?: ( |
| 636 | binaryPath: string, | 516 | claudeSettings: ClaudeSettings, |
| 637 | ) => Effect.Effect<ReadonlyArray<ServerProviderSlashCommand> | undefined>, | 517 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, |
| 518 | environment: NodeJS.ProcessEnv = process.env, | ||
| 638 | ): Effect.fn.Return< | 519 | ): Effect.fn.Return< |
| 639 | ServerProvider, | 520 | ServerProviderDraft, |
| 640 | ServerSettingsError, | 521 | never, |
| 641 | ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService | 522 | ChildProcessSpawner.ChildProcessSpawner | Path.Path |
| 642 | > { | 523 | > { |
| 643 | const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( | ||
| 644 | Effect.flatMap((service) => service.getSettings), | ||
| 645 | Effect.map((settings) => settings.providers.claudeAgent), | ||
| 646 | ); | ||
| 647 | const checkedAt = new Date().toISOString(); | 524 | const checkedAt = new Date().toISOString(); |
| 648 | const allModels = providerModelsFromSettings( | 525 | const allModels = providerModelsFromSettings( |
| 649 | BUILT_IN_MODELS, | 526 | BUILT_IN_MODELS, |
| ... | @@ -654,7 +531,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -654,7 +531,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 654 | 531 | ||
| 655 | if (!claudeSettings.enabled) { | 532 | if (!claudeSettings.enabled) { |
| 656 | return buildServerProvider({ | 533 | return buildServerProvider({ |
| 657 | provider: PROVIDER, | ||
| 658 | presentation: CLAUDE_PRESENTATION, | 534 | presentation: CLAUDE_PRESENTATION, |
| 659 | enabled: false, | 535 | enabled: false, |
| 660 | checkedAt, | 536 | checkedAt, |
| ... | @@ -669,7 +545,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -669,7 +545,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 669 | }); | 545 | }); |
| 670 | } | 546 | } |
| 671 | 547 | ||
| 672 | const versionProbe = yield* runClaudeCommand(["--version"]).pipe( | 548 | const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( |
| 673 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | 549 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), |
| 674 | Effect.result, | 550 | Effect.result, |
| 675 | ); | 551 | ); |
| ... | @@ -677,7 +553,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -677,7 +553,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 677 | if (Result.isFailure(versionProbe)) { | 553 | if (Result.isFailure(versionProbe)) { |
| 678 | const error = versionProbe.failure; | 554 | const error = versionProbe.failure; |
| 679 | return buildServerProvider({ | 555 | return buildServerProvider({ |
| 680 | provider: PROVIDER, | ||
| 681 | presentation: CLAUDE_PRESENTATION, | 556 | presentation: CLAUDE_PRESENTATION, |
| 682 | enabled: claudeSettings.enabled, | 557 | enabled: claudeSettings.enabled, |
| 683 | checkedAt, | 558 | checkedAt, |
| ... | @@ -696,7 +571,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -696,7 +571,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 696 | 571 | ||
| 697 | if (Option.isNone(versionProbe.success)) { | 572 | if (Option.isNone(versionProbe.success)) { |
| 698 | return buildServerProvider({ | 573 | return buildServerProvider({ |
| 699 | provider: PROVIDER, | ||
| 700 | presentation: CLAUDE_PRESENTATION, | 574 | presentation: CLAUDE_PRESENTATION, |
| 701 | enabled: claudeSettings.enabled, | 575 | enabled: claudeSettings.enabled, |
| 702 | checkedAt, | 576 | checkedAt, |
| ... | @@ -717,7 +591,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -717,7 +591,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 717 | if (version.code !== 0) { | 591 | if (version.code !== 0) { |
| 718 | const detail = detailFromResult(version); | 592 | const detail = detailFromResult(version); |
| 719 | return buildServerProvider({ | 593 | return buildServerProvider({ |
| 720 | provider: PROVIDER, | ||
| 721 | presentation: CLAUDE_PRESENTATION, | 594 | presentation: CLAUDE_PRESENTATION, |
| 722 | enabled: claudeSettings.enabled, | 595 | enabled: claudeSettings.enabled, |
| 723 | checkedAt, | 596 | checkedAt, |
| ... | @@ -744,66 +617,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -744,66 +617,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 744 | ? undefined | 617 | ? undefined |
| 745 | : formatClaudeOpus47UpgradeMessage(parsedVersion); | 618 | : formatClaudeOpus47UpgradeMessage(parsedVersion); |
| 746 | 619 | ||
| 747 | const slashCommands = | 620 | const capabilities = resolveCapabilities |
| 748 | (resolveSlashCommands | 621 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) |
| 749 | ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( | 622 | : undefined; |
| 750 | Effect.orElseSucceed(() => undefined), | 623 | const slashCommands = capabilities?.slashCommands ?? []; |
| 751 | ) | ||
| 752 | : undefined) ?? []; | ||
| 753 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); | 624 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); |
| 754 | 625 | ||
| 755 | // ── Auth check + subscription detection ──────────────────────────── | 626 | if (!capabilities) { |
| 756 | |||
| 757 | const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( | ||
| 758 | Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), | ||
| 759 | Effect.result, | ||
| 760 | ); | ||
| 761 | |||
| 762 | // Determine subscription type from multiple sources (cheapest first): | ||
| 763 | // 1. `claude auth status` JSON output (may or may not contain it) | ||
| 764 | // 2. Cached SDK probe (spawns a Claude process on miss, reads | ||
| 765 | // `initializationResult()` for account metadata, then aborts | ||
| 766 | // immediately — no API tokens are consumed) | ||
| 767 | |||
| 768 | let subscriptionType: string | undefined; | ||
| 769 | let authMethod: string | undefined; | ||
| 770 | |||
| 771 | if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { | ||
| 772 | subscriptionType = extractSubscriptionTypeFromOutput(authProbe.success.value); | ||
| 773 | authMethod = extractClaudeAuthMethodFromOutput(authProbe.success.value); | ||
| 774 | } | ||
| 775 | |||
| 776 | if (!subscriptionType && resolveSubscriptionType) { | ||
| 777 | subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); | ||
| 778 | } | ||
| 779 | |||
| 780 | // ── Handle auth results (same logic as before, adjusted models) ── | ||
| 781 | |||
| 782 | if (Result.isFailure(authProbe)) { | ||
| 783 | const error = authProbe.failure; | ||
| 784 | return buildServerProvider({ | ||
| 785 | provider: PROVIDER, | ||
| 786 | presentation: CLAUDE_PRESENTATION, | ||
| 787 | enabled: claudeSettings.enabled, | ||
| 788 | checkedAt, | ||
| 789 | models, | ||
| 790 | slashCommands: dedupedSlashCommands, | ||
| 791 | probe: { | ||
| 792 | installed: true, | ||
| 793 | version: parsedVersion, | ||
| 794 | status: "warning", | ||
| 795 | auth: { status: "unknown" }, | ||
| 796 | message: | ||
| 797 | error instanceof Error | ||
| 798 | ? `Could not verify Claude authentication status: ${error.message}.` | ||
| 799 | : "Could not verify Claude authentication status.", | ||
| 800 | }, | ||
| 801 | }); | ||
| 802 | } | ||
| 803 | |||
| 804 | if (Option.isNone(authProbe.success)) { | ||
| 805 | return buildServerProvider({ | 627 | return buildServerProvider({ |
| 806 | provider: PROVIDER, | ||
| 807 | presentation: CLAUDE_PRESENTATION, | 628 | presentation: CLAUDE_PRESENTATION, |
| 808 | enabled: claudeSettings.enabled, | 629 | enabled: claudeSettings.enabled, |
| 809 | checkedAt, | 630 | checkedAt, |
| ... | @@ -814,15 +635,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -814,15 +635,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 814 | version: parsedVersion, | 635 | version: parsedVersion, |
| 815 | status: "warning", | 636 | status: "warning", |
| 816 | auth: { status: "unknown" }, | 637 | auth: { status: "unknown" }, |
| 817 | message: "Could not verify Claude authentication status. Timed out while running command.", | 638 | message: "Could not verify Claude authentication status from initialization result.", |
| 818 | }, | 639 | }, |
| 819 | }); | 640 | }); |
| 820 | } | 641 | } |
| 821 | 642 | ||
| 822 | const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); | 643 | const authMetadata = claudeAuthMetadata({ |
| 823 | const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); | 644 | subscriptionType: capabilities.subscriptionType, |
| 645 | authMethod: capabilities.tokenSource, | ||
| 646 | }); | ||
| 824 | return buildServerProvider({ | 647 | return buildServerProvider({ |
| 825 | provider: PROVIDER, | ||
| 826 | presentation: CLAUDE_PRESENTATION, | 648 | presentation: CLAUDE_PRESENTATION, |
| 827 | enabled: claudeSettings.enabled, | 649 | enabled: claudeSettings.enabled, |
| 828 | checkedAt, | 650 | checkedAt, |
| ... | @@ -831,21 +653,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -831,21 +653,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 831 | probe: { | 653 | probe: { |
| 832 | installed: true, | 654 | installed: true, |
| 833 | version: parsedVersion, | 655 | version: parsedVersion, |
| 834 | status: parsed.status, | 656 | status: "ready", |
| 835 | auth: { | 657 | auth: { |
| 836 | ...parsed.auth, | 658 | status: "authenticated", |
| 659 | ...(capabilities.email ? { email: capabilities.email } : {}), | ||
| 837 | ...(authMetadata ? authMetadata : {}), | 660 | ...(authMetadata ? authMetadata : {}), |
| 838 | }, | 661 | }, |
| 839 | ...(parsed.message | 662 | ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}), |
| 840 | ? { message: parsed.message } | ||
| 841 | : opus47UpgradeMessage | ||
| 842 | ? { message: opus47UpgradeMessage } | ||
| 843 | : {}), | ||
| 844 | }, | 663 | }, |
| 845 | }); | 664 | }); |
| 846 | }); | 665 | }); |
| 847 | 666 | ||
| 848 | const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => { | 667 | export const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProviderDraft => { |
| 849 | const checkedAt = new Date().toISOString(); | 668 | const checkedAt = new Date().toISOString(); |
| 850 | const models = providerModelsFromSettings( | 669 | const models = providerModelsFromSettings( |
| 851 | BUILT_IN_MODELS, | 670 | BUILT_IN_MODELS, |
| ... | @@ -856,7 +675,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid | ... | @@ -856,7 +675,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid |
| 856 | 675 | ||
| 857 | if (!claudeSettings.enabled) { | 676 | if (!claudeSettings.enabled) { |
| 858 | return buildServerProvider({ | 677 | return buildServerProvider({ |
| 859 | provider: PROVIDER, | ||
| 860 | presentation: CLAUDE_PRESENTATION, | 678 | presentation: CLAUDE_PRESENTATION, |
| 861 | enabled: false, | 679 | enabled: false, |
| 862 | checkedAt, | 680 | checkedAt, |
| ... | @@ -872,7 +690,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid | ... | @@ -872,7 +690,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid |
| 872 | } | 690 | } |
| 873 | 691 | ||
| 874 | return buildServerProvider({ | 692 | return buildServerProvider({ |
| 875 | provider: PROVIDER, | ||
| 876 | presentation: CLAUDE_PRESENTATION, | 693 | presentation: CLAUDE_PRESENTATION, |
| 877 | enabled: true, | 694 | enabled: true, |
| 878 | checkedAt, | 695 | checkedAt, |
| ... | @@ -887,43 +704,4 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid | ... | @@ -887,43 +704,4 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid |
| 887 | }); | 704 | }); |
| 888 | }; | 705 | }; |
| 889 | 706 | ||
| 890 | export const ClaudeProviderLive = Layer.effect( | 707 | export { probeClaudeCapabilities }; |
| 891 | ClaudeProvider, | ||
| 892 | Effect.gen(function* () { | ||
| 893 | const serverSettings = yield* ServerSettingsService; | ||
| 894 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 895 | |||
| 896 | const subscriptionProbeCache = yield* Cache.make({ | ||
| 897 | capacity: 1, | ||
| 898 | timeToLive: Duration.minutes(5), | ||
| 899 | lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), | ||
| 900 | }); | ||
| 901 | |||
| 902 | const checkProvider = checkClaudeProviderStatus( | ||
| 903 | (binaryPath) => | ||
| 904 | Cache.get(subscriptionProbeCache, binaryPath).pipe( | ||
| 905 | Effect.map((probe) => probe?.subscriptionType), | ||
| 906 | ), | ||
| 907 | (binaryPath) => | ||
| 908 | Cache.get(subscriptionProbeCache, binaryPath).pipe( | ||
| 909 | Effect.map((probe) => probe?.slashCommands), | ||
| 910 | ), | ||
| 911 | ).pipe( | ||
| 912 | Effect.provideService(ServerSettingsService, serverSettings), | ||
| 913 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 914 | ); | ||
| 915 | |||
| 916 | return yield* makeManagedServerProvider<ClaudeSettings>({ | ||
| 917 | getSettings: serverSettings.getSettings.pipe( | ||
| 918 | Effect.map((settings) => settings.providers.claudeAgent), | ||
| 919 | Effect.orDie, | ||
| 920 | ), | ||
| 921 | streamSettings: serverSettings.streamChanges.pipe( | ||
| 922 | Stream.map((settings) => settings.providers.claudeAgent), | ||
| 923 | ), | ||
| 924 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | ||
| 925 | initialSnapshot: makePendingClaudeProvider, | ||
| 926 | checkProvider, | ||
| 927 | }); | ||
| 928 | }), | ||
| 929 | ); |
apps/server/src/provider/Layers/CodexAdapter.test.ts+148-38| ... | @@ -4,7 +4,10 @@ import os from "node:os"; | ... | @@ -4,7 +4,10 @@ import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | import { | 5 | import { |
| 6 | ApprovalRequestId, | 6 | ApprovalRequestId, |
| 7 | CodexSettings, | ||
| 7 | EventId, | 8 | EventId, |
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 8 | ProviderItemId, | 11 | ProviderItemId, |
| 9 | type ProviderApprovalDecision, | 12 | type ProviderApprovalDecision, |
| 10 | type ProviderEvent, | 13 | type ProviderEvent, |
| ... | @@ -18,13 +21,13 @@ import { createModelSelection } from "@t3tools/shared/model"; | ... | @@ -18,13 +21,13 @@ import { createModelSelection } from "@t3tools/shared/model"; |
| 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 21 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 19 | import { it, vi } from "@effect/vitest"; | 22 | import { it, vi } from "@effect/vitest"; |
| 20 | 23 | ||
| 21 | import { Effect, Exit, Fiber, Layer, Option, Queue, Scope, Stream } from "effect"; | 24 | import { Context, Effect, Exit, Fiber, Layer, Option, Queue, Schema, Scope, Stream } from "effect"; |
| 22 | import * as CodexErrors from "effect-codex-app-server/errors"; | 25 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 23 | 26 | ||
| 24 | import { ServerConfig } from "../../config.ts"; | 27 | import { ServerConfig } from "../../config.ts"; |
| 25 | import { ServerSettingsService } from "../../serverSettings.ts"; | 28 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 26 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 29 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 27 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | 30 | import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 28 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 31 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 29 | import { | 32 | import { |
| 30 | type CodexSessionRuntimeOptions, | 33 | type CodexSessionRuntimeOptions, |
| ... | @@ -32,7 +35,12 @@ import { | ... | @@ -32,7 +35,12 @@ import { |
| 32 | type CodexSessionRuntimeShape, | 35 | type CodexSessionRuntimeShape, |
| 33 | type CodexThreadSnapshot, | 36 | type CodexThreadSnapshot, |
| 34 | } from "./CodexSessionRuntime.ts"; | 37 | } from "./CodexSessionRuntime.ts"; |
| 35 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; | 38 | import { makeCodexAdapter } from "./CodexAdapter.ts"; |
| 39 | |||
| 40 | // Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`. | ||
| 41 | class CodexAdapter extends Context.Service<CodexAdapter, CodexAdapterShape>()( | ||
| 42 | "test/CodexAdapter", | ||
| 43 | ) {} | ||
| 36 | 44 | ||
| 37 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); | 45 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); |
| 38 | const asTurnId = (value: string): TurnId => TurnId.make(value); | 46 | const asTurnId = (value: string): TurnId => TurnId.make(value); |
| ... | @@ -45,7 +53,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { | ... | @@ -45,7 +53,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { |
| 45 | 53 | ||
| 46 | public readonly startImpl = vi.fn(() => | 54 | public readonly startImpl = vi.fn(() => |
| 47 | Promise.resolve({ | 55 | Promise.resolve({ |
| 48 | provider: "codex" as const, | 56 | provider: ProviderDriverKind.make("codex"), |
| 49 | status: "ready" as const, | 57 | status: "ready" as const, |
| 50 | runtimeMode: this.options.runtimeMode, | 58 | runtimeMode: this.options.runtimeMode, |
| 51 | threadId: this.options.threadId, | 59 | threadId: this.options.threadId, |
| ... | @@ -203,7 +211,15 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory | ... | @@ -203,7 +211,15 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory |
| 203 | 211 | ||
| 204 | const validationRuntimeFactory = makeRuntimeFactory(); | 212 | const validationRuntimeFactory = makeRuntimeFactory(); |
| 205 | const validationLayer = it.layer( | 213 | const validationLayer = it.layer( |
| 206 | makeCodexAdapterLive({ makeRuntime: validationRuntimeFactory.factory }).pipe( | 214 | Layer.effect( |
| 215 | CodexAdapter, | ||
| 216 | Effect.gen(function* () { | ||
| 217 | const codexConfig = Schema.decodeSync(CodexSettings)({}); | ||
| 218 | return yield* makeCodexAdapter(codexConfig, { | ||
| 219 | makeRuntime: validationRuntimeFactory.factory, | ||
| 220 | }); | ||
| 221 | }), | ||
| 222 | ).pipe( | ||
| 207 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 223 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 208 | Layer.provideMerge(ServerSettingsService.layerTest()), | 224 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 209 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 225 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| ... | @@ -217,7 +233,7 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -217,7 +233,7 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 217 | const adapter = yield* CodexAdapter; | 233 | const adapter = yield* CodexAdapter; |
| 218 | const result = yield* adapter | 234 | const result = yield* adapter |
| 219 | .startSession({ | 235 | .startSession({ |
| 220 | provider: "claudeAgent", | 236 | provider: ProviderDriverKind.make("claudeAgent"), |
| 221 | threadId: asThreadId("thread-1"), | 237 | threadId: asThreadId("thread-1"), |
| 222 | runtimeMode: "full-access", | 238 | runtimeMode: "full-access", |
| 223 | }) | 239 | }) |
| ... | @@ -227,7 +243,7 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -227,7 +243,7 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 227 | assert.deepStrictEqual( | 243 | assert.deepStrictEqual( |
| 228 | result.failure, | 244 | result.failure, |
| 229 | new ProviderAdapterValidationError({ | 245 | new ProviderAdapterValidationError({ |
| 230 | provider: "codex", | 246 | provider: ProviderDriverKind.make("codex"), |
| 231 | operation: "startSession", | 247 | operation: "startSession", |
| 232 | issue: "Expected provider 'codex' but received 'claudeAgent'.", | 248 | issue: "Expected provider 'codex' but received 'claudeAgent'.", |
| 233 | }), | 249 | }), |
| ... | @@ -241,9 +257,9 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -241,9 +257,9 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 241 | const adapter = yield* CodexAdapter; | 257 | const adapter = yield* CodexAdapter; |
| 242 | 258 | ||
| 243 | yield* adapter.startSession({ | 259 | yield* adapter.startSession({ |
| 244 | provider: "codex", | 260 | provider: ProviderDriverKind.make("codex"), |
| 245 | threadId: asThreadId("thread-1"), | 261 | threadId: asThreadId("thread-1"), |
| 246 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 262 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 247 | { id: "fastMode", value: true }, | 263 | { id: "fastMode", value: true }, |
| 248 | ]), | 264 | ]), |
| 249 | runtimeMode: "full-access", | 265 | runtimeMode: "full-access", |
| ... | @@ -253,6 +269,7 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -253,6 +269,7 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 253 | binaryPath: "codex", | 269 | binaryPath: "codex", |
| 254 | cwd: process.cwd(), | 270 | cwd: process.cwd(), |
| 255 | model: "gpt-5.3-codex", | 271 | model: "gpt-5.3-codex", |
| 272 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 256 | serviceTier: "fast", | 273 | serviceTier: "fast", |
| 257 | threadId: asThreadId("thread-1"), | 274 | threadId: asThreadId("thread-1"), |
| 258 | runtimeMode: "full-access", | 275 | runtimeMode: "full-access", |
| ... | @@ -263,7 +280,15 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -263,7 +280,15 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 263 | 280 | ||
| 264 | const sessionRuntimeFactory = makeRuntimeFactory(); | 281 | const sessionRuntimeFactory = makeRuntimeFactory(); |
| 265 | const sessionErrorLayer = it.layer( | 282 | const sessionErrorLayer = it.layer( |
| 266 | makeCodexAdapterLive({ makeRuntime: sessionRuntimeFactory.factory }).pipe( | 283 | Layer.effect( |
| 284 | CodexAdapter, | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const codexConfig = Schema.decodeSync(CodexSettings)({}); | ||
| 287 | return yield* makeCodexAdapter(codexConfig, { | ||
| 288 | makeRuntime: sessionRuntimeFactory.factory, | ||
| 289 | }); | ||
| 290 | }), | ||
| 291 | ).pipe( | ||
| 267 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 292 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 268 | Layer.provideMerge(ServerSettingsService.layerTest()), | 293 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 269 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 294 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| ... | @@ -294,7 +319,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { | ... | @@ -294,7 +319,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 294 | Effect.gen(function* () { | 319 | Effect.gen(function* () { |
| 295 | const adapter = yield* CodexAdapter; | 320 | const adapter = yield* CodexAdapter; |
| 296 | yield* adapter.startSession({ | 321 | yield* adapter.startSession({ |
| 297 | provider: "codex", | 322 | provider: ProviderDriverKind.make("codex"), |
| 298 | threadId: asThreadId("sess-missing"), | 323 | threadId: asThreadId("sess-missing"), |
| 299 | runtimeMode: "full-access", | 324 | runtimeMode: "full-access", |
| 300 | }); | 325 | }); |
| ... | @@ -306,7 +331,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { | ... | @@ -306,7 +331,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 306 | adapter.sendTurn({ | 331 | adapter.sendTurn({ |
| 307 | threadId: asThreadId("sess-missing"), | 332 | threadId: asThreadId("sess-missing"), |
| 308 | input: "hello", | 333 | input: "hello", |
| 309 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | 334 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ |
| 310 | { id: "reasoningEffort", value: "high" }, | 335 | { id: "reasoningEffort", value: "high" }, |
| 311 | { id: "fastMode", value: true }, | 336 | { id: "fastMode", value: true }, |
| 312 | ]), | 337 | ]), |
| ... | @@ -322,11 +347,74 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { | ... | @@ -322,11 +347,74 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 322 | }); | 347 | }); |
| 323 | }), | 348 | }), |
| 324 | ); | 349 | ); |
| 350 | |||
| 351 | it.effect("maps codex model options for the adapter's bound custom instance id", () => { | ||
| 352 | const customInstanceId = ProviderInstanceId.make("codex_personal"); | ||
| 353 | const customRuntimeFactory = makeRuntimeFactory(); | ||
| 354 | const customLayer = Layer.effect( | ||
| 355 | CodexAdapter, | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const codexConfig = Schema.decodeSync(CodexSettings)({}); | ||
| 358 | return yield* makeCodexAdapter(codexConfig, { | ||
| 359 | instanceId: customInstanceId, | ||
| 360 | makeRuntime: customRuntimeFactory.factory, | ||
| 361 | }); | ||
| 362 | }), | ||
| 363 | ).pipe( | ||
| 364 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 365 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 366 | Layer.provideMerge(providerSessionDirectoryTestLayer), | ||
| 367 | Layer.provideMerge(NodeServices.layer), | ||
| 368 | ); | ||
| 369 | |||
| 370 | return Effect.gen(function* () { | ||
| 371 | const adapter = yield* CodexAdapter; | ||
| 372 | yield* adapter.startSession({ | ||
| 373 | provider: ProviderDriverKind.make("codex"), | ||
| 374 | threadId: asThreadId("sess-custom-instance"), | ||
| 375 | runtimeMode: "full-access", | ||
| 376 | }); | ||
| 377 | const runtime = customRuntimeFactory.lastRuntime; | ||
| 378 | assert.ok(runtime); | ||
| 379 | runtime.sendTurnImpl.mockClear(); | ||
| 380 | |||
| 381 | yield* Effect.ignore( | ||
| 382 | adapter.sendTurn({ | ||
| 383 | threadId: asThreadId("sess-custom-instance"), | ||
| 384 | input: "hello", | ||
| 385 | modelSelection: createModelSelection( | ||
| 386 | ProviderInstanceId.make("codex_personal"), | ||
| 387 | "gpt-5.3-codex", | ||
| 388 | [ | ||
| 389 | { id: "reasoningEffort", value: "high" }, | ||
| 390 | { id: "fastMode", value: true }, | ||
| 391 | ], | ||
| 392 | ), | ||
| 393 | attachments: [], | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | |||
| 397 | assert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { | ||
| 398 | input: "hello", | ||
| 399 | model: "gpt-5.3-codex", | ||
| 400 | effort: "high", | ||
| 401 | serviceTier: "fast", | ||
| 402 | }); | ||
| 403 | }).pipe(Effect.provide(customLayer)); | ||
| 404 | }); | ||
| 325 | }); | 405 | }); |
| 326 | 406 | ||
| 327 | const lifecycleRuntimeFactory = makeRuntimeFactory(); | 407 | const lifecycleRuntimeFactory = makeRuntimeFactory(); |
| 328 | const lifecycleLayer = it.layer( | 408 | const lifecycleLayer = it.layer( |
| 329 | makeCodexAdapterLive({ makeRuntime: lifecycleRuntimeFactory.factory }).pipe( | 409 | Layer.effect( |
| 410 | CodexAdapter, | ||
| 411 | Effect.gen(function* () { | ||
| 412 | const codexConfig = Schema.decodeSync(CodexSettings)({}); | ||
| 413 | return yield* makeCodexAdapter(codexConfig, { | ||
| 414 | makeRuntime: lifecycleRuntimeFactory.factory, | ||
| 415 | }); | ||
| 416 | }), | ||
| 417 | ).pipe( | ||
| 330 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 418 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 331 | Layer.provideMerge(ServerSettingsService.layerTest()), | 419 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 332 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 420 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| ... | @@ -338,7 +426,7 @@ function startLifecycleRuntime() { | ... | @@ -338,7 +426,7 @@ function startLifecycleRuntime() { |
| 338 | return Effect.gen(function* () { | 426 | return Effect.gen(function* () { |
| 339 | const adapter = yield* CodexAdapter; | 427 | const adapter = yield* CodexAdapter; |
| 340 | yield* adapter.startSession({ | 428 | yield* adapter.startSession({ |
| 341 | provider: "codex", | 429 | provider: ProviderDriverKind.make("codex"), |
| 342 | threadId: asThreadId("thread-1"), | 430 | threadId: asThreadId("thread-1"), |
| 343 | runtimeMode: "full-access", | 431 | runtimeMode: "full-access", |
| 344 | }); | 432 | }); |
| ... | @@ -357,7 +445,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -357,7 +445,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 357 | const event: ProviderEvent = { | 445 | const event: ProviderEvent = { |
| 358 | id: asEventId("evt-msg-complete"), | 446 | id: asEventId("evt-msg-complete"), |
| 359 | kind: "notification", | 447 | kind: "notification", |
| 360 | provider: "codex", | 448 | provider: ProviderDriverKind.make("codex"), |
| 361 | createdAt: new Date().toISOString(), | 449 | createdAt: new Date().toISOString(), |
| 362 | method: "item/completed", | 450 | method: "item/completed", |
| 363 | threadId: asThreadId("thread-1"), | 451 | threadId: asThreadId("thread-1"), |
| ... | @@ -399,7 +487,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -399,7 +487,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 399 | const event: ProviderEvent = { | 487 | const event: ProviderEvent = { |
| 400 | id: asEventId("evt-plan-complete"), | 488 | id: asEventId("evt-plan-complete"), |
| 401 | kind: "notification", | 489 | kind: "notification", |
| 402 | provider: "codex", | 490 | provider: ProviderDriverKind.make("codex"), |
| 403 | createdAt: new Date().toISOString(), | 491 | createdAt: new Date().toISOString(), |
| 404 | method: "item/completed", | 492 | method: "item/completed", |
| 405 | threadId: asThreadId("thread-1"), | 493 | threadId: asThreadId("thread-1"), |
| ... | @@ -440,7 +528,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -440,7 +528,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 440 | yield* runtime.emit({ | 528 | yield* runtime.emit({ |
| 441 | id: asEventId("evt-plan-delta"), | 529 | id: asEventId("evt-plan-delta"), |
| 442 | kind: "notification", | 530 | kind: "notification", |
| 443 | provider: "codex", | 531 | provider: ProviderDriverKind.make("codex"), |
| 444 | createdAt: new Date().toISOString(), | 532 | createdAt: new Date().toISOString(), |
| 445 | method: "item/plan/delta", | 533 | method: "item/plan/delta", |
| 446 | threadId: asThreadId("thread-1"), | 534 | threadId: asThreadId("thread-1"), |
| ... | @@ -477,7 +565,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -477,7 +565,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 477 | const event: ProviderEvent = { | 565 | const event: ProviderEvent = { |
| 478 | id: asEventId("evt-session-closed"), | 566 | id: asEventId("evt-session-closed"), |
| 479 | kind: "session", | 567 | kind: "session", |
| 480 | provider: "codex", | 568 | provider: ProviderDriverKind.make("codex"), |
| 481 | threadId: asThreadId("thread-1"), | 569 | threadId: asThreadId("thread-1"), |
| 482 | createdAt: new Date().toISOString(), | 570 | createdAt: new Date().toISOString(), |
| 483 | method: "session/closed", | 571 | method: "session/closed", |
| ... | @@ -508,7 +596,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -508,7 +596,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 508 | yield* runtime.emit({ | 596 | yield* runtime.emit({ |
| 509 | id: asEventId("evt-retryable-error"), | 597 | id: asEventId("evt-retryable-error"), |
| 510 | kind: "notification", | 598 | kind: "notification", |
| 511 | provider: "codex", | 599 | provider: ProviderDriverKind.make("codex"), |
| 512 | threadId: asThreadId("thread-1"), | 600 | threadId: asThreadId("thread-1"), |
| 513 | createdAt: new Date().toISOString(), | 601 | createdAt: new Date().toISOString(), |
| 514 | method: "error", | 602 | method: "error", |
| ... | @@ -546,7 +634,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -546,7 +634,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 546 | yield* runtime.emit({ | 634 | yield* runtime.emit({ |
| 547 | id: asEventId("evt-process-stderr"), | 635 | id: asEventId("evt-process-stderr"), |
| 548 | kind: "notification", | 636 | kind: "notification", |
| 549 | provider: "codex", | 637 | provider: ProviderDriverKind.make("codex"), |
| 550 | threadId: asThreadId("thread-1"), | 638 | threadId: asThreadId("thread-1"), |
| 551 | createdAt: new Date().toISOString(), | 639 | createdAt: new Date().toISOString(), |
| 552 | method: "process/stderr", | 640 | method: "process/stderr", |
| ... | @@ -580,7 +668,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -580,7 +668,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 580 | yield* runtime.emit({ | 668 | yield* runtime.emit({ |
| 581 | id: asEventId("evt-process-stderr-websocket"), | 669 | id: asEventId("evt-process-stderr-websocket"), |
| 582 | kind: "notification", | 670 | kind: "notification", |
| 583 | provider: "codex", | 671 | provider: ProviderDriverKind.make("codex"), |
| 584 | threadId: asThreadId("thread-1"), | 672 | threadId: asThreadId("thread-1"), |
| 585 | createdAt: new Date().toISOString(), | 673 | createdAt: new Date().toISOString(), |
| 586 | method: "process/stderr", | 674 | method: "process/stderr", |
| ... | @@ -616,7 +704,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -616,7 +704,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 616 | const event: ProviderEvent = { | 704 | const event: ProviderEvent = { |
| 617 | id: asEventId("evt-request-resolved"), | 705 | id: asEventId("evt-request-resolved"), |
| 618 | kind: "notification", | 706 | kind: "notification", |
| 619 | provider: "codex", | 707 | provider: ProviderDriverKind.make("codex"), |
| 620 | threadId: asThreadId("thread-1"), | 708 | threadId: asThreadId("thread-1"), |
| 621 | createdAt: new Date().toISOString(), | 709 | createdAt: new Date().toISOString(), |
| 622 | method: "serverRequest/resolved", | 710 | method: "serverRequest/resolved", |
| ... | @@ -651,7 +739,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -651,7 +739,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 651 | const event: ProviderEvent = { | 739 | const event: ProviderEvent = { |
| 652 | id: asEventId("evt-file-read-request-resolved"), | 740 | id: asEventId("evt-file-read-request-resolved"), |
| 653 | kind: "notification", | 741 | kind: "notification", |
| 654 | provider: "codex", | 742 | provider: ProviderDriverKind.make("codex"), |
| 655 | threadId: asThreadId("thread-1"), | 743 | threadId: asThreadId("thread-1"), |
| 656 | createdAt: new Date().toISOString(), | 744 | createdAt: new Date().toISOString(), |
| 657 | method: "serverRequest/resolved", | 745 | method: "serverRequest/resolved", |
| ... | @@ -686,7 +774,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -686,7 +774,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 686 | const event: ProviderEvent = { | 774 | const event: ProviderEvent = { |
| 687 | id: asEventId("evt-user-input-empty"), | 775 | id: asEventId("evt-user-input-empty"), |
| 688 | kind: "notification", | 776 | kind: "notification", |
| 689 | provider: "codex", | 777 | provider: ProviderDriverKind.make("codex"), |
| 690 | threadId: asThreadId("thread-1"), | 778 | threadId: asThreadId("thread-1"), |
| 691 | createdAt: new Date().toISOString(), | 779 | createdAt: new Date().toISOString(), |
| 692 | method: "item/tool/requestUserInput/answered", | 780 | method: "item/tool/requestUserInput/answered", |
| ... | @@ -726,7 +814,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -726,7 +814,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 726 | const event: ProviderEvent = { | 814 | const event: ProviderEvent = { |
| 727 | id: asEventId("evt-windows-sandbox-failed"), | 815 | id: asEventId("evt-windows-sandbox-failed"), |
| 728 | kind: "notification", | 816 | kind: "notification", |
| 729 | provider: "codex", | 817 | provider: ProviderDriverKind.make("codex"), |
| 730 | threadId: asThreadId("thread-1"), | 818 | threadId: asThreadId("thread-1"), |
| 731 | createdAt: new Date().toISOString(), | 819 | createdAt: new Date().toISOString(), |
| 732 | method: "windowsSandbox/setupCompleted", | 820 | method: "windowsSandbox/setupCompleted", |
| ... | @@ -771,7 +859,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -771,7 +859,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 771 | yield* runtime.emit({ | 859 | yield* runtime.emit({ |
| 772 | id: asEventId("evt-user-input-requested"), | 860 | id: asEventId("evt-user-input-requested"), |
| 773 | kind: "request", | 861 | kind: "request", |
| 774 | provider: "codex", | 862 | provider: ProviderDriverKind.make("codex"), |
| 775 | threadId: asThreadId("thread-1"), | 863 | threadId: asThreadId("thread-1"), |
| 776 | createdAt: new Date().toISOString(), | 864 | createdAt: new Date().toISOString(), |
| 777 | method: "item/tool/requestUserInput", | 865 | method: "item/tool/requestUserInput", |
| ... | @@ -798,7 +886,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -798,7 +886,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 798 | yield* runtime.emit({ | 886 | yield* runtime.emit({ |
| 799 | id: asEventId("evt-user-input-resolved"), | 887 | id: asEventId("evt-user-input-resolved"), |
| 800 | kind: "notification", | 888 | kind: "notification", |
| 801 | provider: "codex", | 889 | provider: ProviderDriverKind.make("codex"), |
| 802 | threadId: asThreadId("thread-1"), | 890 | threadId: asThreadId("thread-1"), |
| 803 | createdAt: new Date().toISOString(), | 891 | createdAt: new Date().toISOString(), |
| 804 | method: "item/tool/requestUserInput/answered", | 892 | method: "item/tool/requestUserInput/answered", |
| ... | @@ -838,7 +926,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -838,7 +926,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 838 | yield* runtime.emit({ | 926 | yield* runtime.emit({ |
| 839 | id: asEventId("evt-codex-thread-token-usage-updated"), | 927 | id: asEventId("evt-codex-thread-token-usage-updated"), |
| 840 | kind: "notification", | 928 | kind: "notification", |
| 841 | provider: "codex", | 929 | provider: ProviderDriverKind.make("codex"), |
| 842 | threadId: asThreadId("thread-1"), | 930 | threadId: asThreadId("thread-1"), |
| 843 | turnId: asTurnId("turn-1"), | 931 | turnId: asTurnId("turn-1"), |
| 844 | createdAt: new Date().toISOString(), | 932 | createdAt: new Date().toISOString(), |
| ... | @@ -897,7 +985,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -897,7 +985,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 897 | 985 | ||
| 898 | const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); | 986 | const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); |
| 899 | const scopedLifecycleLayer = it.layer( | 987 | const scopedLifecycleLayer = it.layer( |
| 900 | makeCodexAdapterLive({ makeRuntime: scopedLifecycleRuntimeFactory.factory }).pipe( | 988 | Layer.effect( |
| 989 | CodexAdapter, | ||
| 990 | Effect.gen(function* () { | ||
| 991 | const codexConfig = Schema.decodeSync(CodexSettings)({}); | ||
| 992 | return yield* makeCodexAdapter(codexConfig, { | ||
| 993 | makeRuntime: scopedLifecycleRuntimeFactory.factory, | ||
| 994 | }); | ||
| 995 | }), | ||
| 996 | ).pipe( | ||
| 901 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 997 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 902 | Layer.provideMerge(ServerSettingsService.layerTest()), | 998 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 903 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 999 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| ... | @@ -912,7 +1008,7 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { | ... | @@ -912,7 +1008,7 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { |
| 912 | const adapter = yield* CodexAdapter; | 1008 | const adapter = yield* CodexAdapter; |
| 913 | 1009 | ||
| 914 | yield* adapter.startSession({ | 1010 | yield* adapter.startSession({ |
| 915 | provider: "codex", | 1011 | provider: ProviderDriverKind.make("codex"), |
| 916 | threadId: asThreadId("thread-stop"), | 1012 | threadId: asThreadId("thread-stop"), |
| 917 | runtimeMode: "full-access", | 1013 | runtimeMode: "full-access", |
| 918 | }); | 1014 | }); |
| ... | @@ -933,7 +1029,15 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { | ... | @@ -933,7 +1029,15 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { |
| 933 | 1029 | ||
| 934 | const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true }); | 1030 | const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true }); |
| 935 | const scopedFailureLayer = it.layer( | 1031 | const scopedFailureLayer = it.layer( |
| 936 | makeCodexAdapterLive({ makeRuntime: scopedFailureRuntimeFactory.factory }).pipe( | 1032 | Layer.effect( |
| 1033 | CodexAdapter, | ||
| 1034 | Effect.gen(function* () { | ||
| 1035 | const codexConfig = Schema.decodeSync(CodexSettings)({}); | ||
| 1036 | return yield* makeCodexAdapter(codexConfig, { | ||
| 1037 | makeRuntime: scopedFailureRuntimeFactory.factory, | ||
| 1038 | }); | ||
| 1039 | }), | ||
| 1040 | ).pipe( | ||
| 937 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 1041 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 938 | Layer.provideMerge(ServerSettingsService.layerTest()), | 1042 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 939 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 1043 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| ... | @@ -949,7 +1053,7 @@ scopedFailureLayer("CodexAdapterLive scoped startup failure", (it) => { | ... | @@ -949,7 +1053,7 @@ scopedFailureLayer("CodexAdapterLive scoped startup failure", (it) => { |
| 949 | 1053 | ||
| 950 | const result = yield* adapter | 1054 | const result = yield* adapter |
| 951 | .startSession({ | 1055 | .startSession({ |
| 952 | provider: "codex", | 1056 | provider: ProviderDriverKind.make("codex"), |
| 953 | threadId: asThreadId("thread-fail"), | 1057 | threadId: asThreadId("thread-fail"), |
| 954 | runtimeMode: "full-access", | 1058 | runtimeMode: "full-access", |
| 955 | }) | 1059 | }) |
| ... | @@ -974,10 +1078,16 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => | ... | @@ -974,10 +1078,16 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => |
| 974 | let scopeClosed = false; | 1078 | let scopeClosed = false; |
| 975 | 1079 | ||
| 976 | try { | 1080 | try { |
| 977 | const layer = makeCodexAdapterLive({ | 1081 | const layer = Layer.effect( |
| 978 | makeRuntime: runtimeFactory.factory, | 1082 | CodexAdapter, |
| 979 | nativeEventLogPath: basePath, | 1083 | Effect.gen(function* () { |
| 980 | }).pipe( | 1084 | const codexConfig = Schema.decodeSync(CodexSettings)({}); |
| 1085 | return yield* makeCodexAdapter(codexConfig, { | ||
| 1086 | makeRuntime: runtimeFactory.factory, | ||
| 1087 | nativeEventLogPath: basePath, | ||
| 1088 | }); | ||
| 1089 | }), | ||
| 1090 | ).pipe( | ||
| 981 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 1091 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 982 | Layer.provideMerge(ServerSettingsService.layerTest()), | 1092 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 983 | Layer.provideMerge(providerSessionDirectoryTestLayer), | 1093 | Layer.provideMerge(providerSessionDirectoryTestLayer), |
| ... | @@ -987,7 +1097,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => | ... | @@ -987,7 +1097,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => |
| 987 | const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context)); | 1097 | const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context)); |
| 988 | 1098 | ||
| 989 | yield* adapter.startSession({ | 1099 | yield* adapter.startSession({ |
| 990 | provider: "codex", | 1100 | provider: ProviderDriverKind.make("codex"), |
| 991 | threadId: asThreadId("thread-logger"), | 1101 | threadId: asThreadId("thread-logger"), |
| 992 | runtimeMode: "full-access", | 1102 | runtimeMode: "full-access", |
| 993 | }); | 1103 | }); |
| ... | @@ -999,7 +1109,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => | ... | @@ -999,7 +1109,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => |
| 999 | yield* runtime.emit({ | 1109 | yield* runtime.emit({ |
| 1000 | id: asEventId("evt-native-log"), | 1110 | id: asEventId("evt-native-log"), |
| 1001 | kind: "notification", | 1111 | kind: "notification", |
| 1002 | provider: "codex", | 1112 | provider: ProviderDriverKind.make("codex"), |
| 1003 | threadId: asThreadId("thread-logger"), | 1113 | threadId: asThreadId("thread-logger"), |
| 1004 | createdAt: new Date().toISOString(), | 1114 | createdAt: new Date().toISOString(), |
| 1005 | method: "process/stderr", | 1115 | method: "process/stderr", |
apps/server/src/provider/Layers/CodexAdapter.ts+35-30| ... | @@ -10,7 +10,10 @@ | ... | @@ -10,7 +10,10 @@ |
| 10 | import { | 10 | import { |
| 11 | type CanonicalItemType, | 11 | type CanonicalItemType, |
| 12 | type CanonicalRequestType, | 12 | type CanonicalRequestType, |
| 13 | type CodexSettings, | ||
| 14 | ProviderDriverKind, | ||
| 13 | type ProviderEvent, | 15 | type ProviderEvent, |
| 16 | ProviderInstanceId, | ||
| 14 | type ProviderRuntimeEvent, | 17 | type ProviderRuntimeEvent, |
| 15 | type ProviderRequestKind, | 18 | type ProviderRequestKind, |
| 16 | type ThreadTokenUsageSnapshot, | 19 | type ThreadTokenUsageSnapshot, |
| ... | @@ -21,7 +24,7 @@ import { | ... | @@ -21,7 +24,7 @@ import { |
| 21 | ThreadId, | 24 | ThreadId, |
| 22 | ProviderSendTurnInput, | 25 | ProviderSendTurnInput, |
| 23 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 24 | import { Effect, Exit, Fiber, FileSystem, Layer, Queue, Schema, Scope, Stream } from "effect"; | 27 | import { Effect, Exit, Fiber, FileSystem, Queue, Schema, Scope, Stream } from "effect"; |
| 25 | import { ChildProcessSpawner } from "effect/unstable/process"; | 28 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 26 | import * as CodexErrors from "effect-codex-app-server/errors"; | 29 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 27 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; | 30 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| ... | @@ -39,10 +42,9 @@ import { | ... | @@ -39,10 +42,9 @@ import { |
| 39 | ProviderAdapterValidationError, | 42 | ProviderAdapterValidationError, |
| 40 | type ProviderAdapterError, | 43 | type ProviderAdapterError, |
| 41 | } from "../Errors.ts"; | 44 | } from "../Errors.ts"; |
| 42 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; | 45 | import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 46 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 44 | import { ServerConfig } from "../../config.ts"; | 47 | import { ServerConfig } from "../../config.ts"; |
| 45 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 46 | import { | 48 | import { |
| 47 | CodexResumeCursorSchema, | 49 | CodexResumeCursorSchema, |
| 48 | CodexSessionRuntimeThreadIdMissingError, | 50 | CodexSessionRuntimeThreadIdMissingError, |
| ... | @@ -53,9 +55,11 @@ import { | ... | @@ -53,9 +55,11 @@ import { |
| 53 | } from "./CodexSessionRuntime.ts"; | 55 | } from "./CodexSessionRuntime.ts"; |
| 54 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 56 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 55 | 57 | ||
| 56 | const PROVIDER = "codex" as const; | 58 | const PROVIDER = ProviderDriverKind.make("codex"); |
| 57 | 59 | ||
| 58 | export interface CodexAdapterLiveOptions { | 60 | export interface CodexAdapterLiveOptions { |
| 61 | readonly instanceId?: ProviderInstanceId; | ||
| 62 | readonly environment?: NodeJS.ProcessEnv; | ||
| 59 | readonly makeRuntime?: ( | 63 | readonly makeRuntime?: ( |
| 60 | options: CodexSessionRuntimeOptions, | 64 | options: CodexSessionRuntimeOptions, |
| 61 | ) => Effect.Effect< | 65 | ) => Effect.Effect< |
| ... | @@ -1318,9 +1322,20 @@ function mapToRuntimeEvents( | ... | @@ -1318,9 +1322,20 @@ function mapToRuntimeEvents( |
| 1318 | return []; | 1322 | return []; |
| 1319 | } | 1323 | } |
| 1320 | 1324 | ||
| 1321 | const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | 1325 | /** |
| 1326 | * Build a Codex provider adapter bound to a specific `CodexSettings` payload. | ||
| 1327 | * | ||
| 1328 | * The adapter is a captured closure over `codexConfig` — the `binaryPath` and | ||
| 1329 | * `homePath` are read from that payload, not from `ServerSettingsService`. | ||
| 1330 | * This is what makes multi-instance routing possible: each `ProviderInstance` | ||
| 1331 | * in the registry owns its own closure with its own config, so two Codex | ||
| 1332 | * instances with different `homePath`s cannot step on each other. | ||
| 1333 | */ | ||
| 1334 | export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ||
| 1335 | codexConfig: CodexSettings, | ||
| 1322 | options?: CodexAdapterLiveOptions, | 1336 | options?: CodexAdapterLiveOptions, |
| 1323 | ) { | 1337 | ) { |
| 1338 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); | ||
| 1324 | const fileSystem = yield* FileSystem.FileSystem; | 1339 | const fileSystem = yield* FileSystem.FileSystem; |
| 1325 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1340 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1326 | const serverConfig = yield* Effect.service(ServerConfig); | 1341 | const serverConfig = yield* Effect.service(ServerConfig); |
| ... | @@ -1333,7 +1348,6 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1333,7 +1348,6 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1333 | : undefined); | 1348 | : undefined); |
| 1334 | const managedNativeEventLogger = | 1349 | const managedNativeEventLogger = |
| 1335 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; | 1350 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; |
| 1336 | const serverSettingsService = yield* ServerSettingsService; | ||
| 1337 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 1351 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 1338 | const sessions = new Map<ThreadId, CodexAdapterSessionContext>(); | 1352 | const sessions = new Map<ThreadId, CodexAdapterSessionContext>(); |
| 1339 | 1353 | ||
| ... | @@ -1353,31 +1367,21 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1353,31 +1367,21 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1353 | yield* Effect.suspend(() => stopSessionInternal(existing)); | 1367 | yield* Effect.suspend(() => stopSessionInternal(existing)); |
| 1354 | } | 1368 | } |
| 1355 | 1369 | ||
| 1356 | const codexSettings = yield* serverSettingsService.getSettings.pipe( | ||
| 1357 | Effect.map((settings) => settings.providers.codex), | ||
| 1358 | Effect.mapError( | ||
| 1359 | (error) => | ||
| 1360 | new ProviderAdapterProcessError({ | ||
| 1361 | provider: PROVIDER, | ||
| 1362 | threadId: input.threadId, | ||
| 1363 | detail: error.message, | ||
| 1364 | cause: error, | ||
| 1365 | }), | ||
| 1366 | ), | ||
| 1367 | ); | ||
| 1368 | const runtimeInput: CodexSessionRuntimeOptions = { | 1370 | const runtimeInput: CodexSessionRuntimeOptions = { |
| 1369 | threadId: input.threadId, | 1371 | threadId: input.threadId, |
| 1372 | providerInstanceId: boundInstanceId, | ||
| 1370 | cwd: input.cwd ?? process.cwd(), | 1373 | cwd: input.cwd ?? process.cwd(), |
| 1371 | binaryPath: codexSettings.binaryPath, | 1374 | binaryPath: codexConfig.binaryPath, |
| 1372 | ...(codexSettings.homePath ? { homePath: codexSettings.homePath } : {}), | 1375 | ...(options?.environment ? { environment: options.environment } : {}), |
| 1376 | ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), | ||
| 1373 | ...(Schema.is(CodexResumeCursorSchema)(input.resumeCursor) | 1377 | ...(Schema.is(CodexResumeCursorSchema)(input.resumeCursor) |
| 1374 | ? { resumeCursor: input.resumeCursor } | 1378 | ? { resumeCursor: input.resumeCursor } |
| 1375 | : {}), | 1379 | : {}), |
| 1376 | runtimeMode: input.runtimeMode, | 1380 | runtimeMode: input.runtimeMode, |
| 1377 | ...(input.modelSelection?.provider === "codex" | 1381 | ...(input.modelSelection?.instanceId === boundInstanceId |
| 1378 | ? { model: input.modelSelection.model } | 1382 | ? { model: input.modelSelection.model } |
| 1379 | : {}), | 1383 | : {}), |
| 1380 | ...(input.modelSelection?.provider === "codex" && | 1384 | ...(input.modelSelection?.instanceId === boundInstanceId && |
| 1381 | getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true | 1385 | getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true |
| 1382 | ? { serviceTier: "fast" } | 1386 | ? { serviceTier: "fast" } |
| 1383 | : {}), | 1387 | : {}), |
| ... | @@ -1492,17 +1496,17 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1492,17 +1496,17 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1492 | 1496 | ||
| 1493 | const session = yield* requireSession(input.threadId); | 1497 | const session = yield* requireSession(input.threadId); |
| 1494 | const reasoningEffort = | 1498 | const reasoningEffort = |
| 1495 | input.modelSelection?.provider === "codex" | 1499 | input.modelSelection?.instanceId === boundInstanceId |
| 1496 | ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") | 1500 | ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") |
| 1497 | : undefined; | 1501 | : undefined; |
| 1498 | const fastMode = | 1502 | const fastMode = |
| 1499 | input.modelSelection?.provider === "codex" | 1503 | input.modelSelection?.instanceId === boundInstanceId |
| 1500 | ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") | 1504 | ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") |
| 1501 | : undefined; | 1505 | : undefined; |
| 1502 | return yield* session.runtime | 1506 | return yield* session.runtime |
| 1503 | .sendTurn({ | 1507 | .sendTurn({ |
| 1504 | ...(input.input !== undefined ? { input: input.input } : {}), | 1508 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1505 | ...(input.modelSelection?.provider === "codex" | 1509 | ...(input.modelSelection?.instanceId === boundInstanceId |
| 1506 | ? { model: input.modelSelection.model } | 1510 | ? { model: input.modelSelection.model } |
| 1507 | : {}), | 1511 | : {}), |
| 1508 | ...(reasoningEffort | 1512 | ...(reasoningEffort |
| ... | @@ -1676,8 +1680,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1676,8 +1680,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1676 | } satisfies CodexAdapterShape; | 1680 | } satisfies CodexAdapterShape; |
| 1677 | }); | 1681 | }); |
| 1678 | 1682 | ||
| 1679 | export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); | 1683 | // NOTE: the old `CodexAdapterLive` / `makeCodexAdapterLive` singleton Layer |
| 1680 | 1684 | // exports have been removed as part of the per-instance-driver refactor. | |
| 1681 | export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { | 1685 | // `makeCodexAdapter(codexConfig, options?)` is now invoked directly by |
| 1682 | return Layer.effect(CodexAdapter, makeCodexAdapter(options)); | 1686 | // `CodexDriver.create()` for each configured instance; downstream consumers |
| 1683 | } | 1687 | // (server bootstrap, integration harness, this module's tests) will be |
| 1688 | // migrated to the registry in a follow-up pass. |
apps/server/src/provider/Layers/CodexProvider.ts+42-57| ... | @@ -1,15 +1,4 @@ | ... | @@ -1,15 +1,4 @@ |
| 1 | import { | 1 | import { DateTime, Duration, Effect, Layer, Option, Result, Schema, Types } from "effect"; |
| 2 | DateTime, | ||
| 3 | Duration, | ||
| 4 | Effect, | ||
| 5 | Equal, | ||
| 6 | Layer, | ||
| 7 | Option, | ||
| 8 | Result, | ||
| 9 | Schema, | ||
| 10 | Stream, | ||
| 11 | Types, | ||
| 12 | } from "effect"; | ||
| 13 | import { ChildProcessSpawner } from "effect/unstable/process"; | 2 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 14 | import * as CodexClient from "effect-codex-app-server/client"; | 3 | import * as CodexClient from "effect-codex-app-server/client"; |
| 15 | import * as CodexSchema from "effect-codex-app-server/schema"; | 4 | import * as CodexSchema from "effect-codex-app-server/schema"; |
| ... | @@ -27,14 +16,11 @@ import { ServerSettingsError } from "@t3tools/contracts"; | ... | @@ -27,14 +16,11 @@ import { ServerSettingsError } from "@t3tools/contracts"; |
| 27 | 16 | ||
| 28 | import { createModelCapabilities } from "@t3tools/shared/model"; | 17 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 29 | 18 | ||
| 30 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 19 | import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; |
| 31 | import { buildServerProvider } from "../providerSnapshot.ts"; | ||
| 32 | import { CodexProvider } from "../Services/CodexProvider.ts"; | ||
| 33 | import { expandHomePath } from "../../pathExpansion.ts"; | 20 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 34 | import { ServerSettingsService } from "../../serverSettings.ts"; | 21 | import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; |
| 35 | import packageJson from "../../../package.json" with { type: "json" }; | 22 | import packageJson from "../../../package.json" with { type: "json" }; |
| 36 | 23 | ||
| 37 | const PROVIDER = "codex" as const; | ||
| 38 | const PROVIDER_PROBE_TIMEOUT_MS = 8_000; | 24 | const PROVIDER_PROBE_TIMEOUT_MS = 8_000; |
| 39 | const CODEX_PRESENTATION = { | 25 | const CODEX_PRESENTATION = { |
| 40 | displayName: "Codex", | 26 | displayName: "Codex", |
| ... | @@ -90,6 +76,11 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun | ... | @@ -90,6 +76,11 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun |
| 90 | } | 76 | } |
| 91 | } | 77 | } |
| 92 | 78 | ||
| 79 | function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { | ||
| 80 | if (!account || account.type !== "chatgpt") return undefined; | ||
| 81 | return account.email; | ||
| 82 | } | ||
| 83 | |||
| 93 | function mapCodexModelCapabilities( | 84 | function mapCodexModelCapabilities( |
| 94 | model: CodexSchema.V2ModelListResponse__Model, | 85 | model: CodexSchema.V2ModelListResponse__Model, |
| 95 | ): ModelCapabilities { | 86 | ): ModelCapabilities { |
| ... | @@ -245,18 +236,33 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { | ... | @@ -245,18 +236,33 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { |
| 245 | }; | 236 | }; |
| 246 | } | 237 | } |
| 247 | 238 | ||
| 239 | // Wrapped with `scopedSafeTeardown("codex-probe")` rather than the usual | ||
| 240 | // `Effect.scoped` so that a defect from the `Layer.build` finalizer (e.g. | ||
| 241 | // `ChildProcess.kill` throwing because the `codex app-server` child exited | ||
| 242 | // early) cannot override a successful probe body. Without this guard the | ||
| 243 | // defect bubbles past `Effect.result` in `checkCodexProviderStatus`, dies | ||
| 244 | // `refreshOneSource`, and `providersRef` never receives the snapshot. | ||
| 248 | const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { | 245 | const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { |
| 249 | readonly binaryPath: string; | 246 | readonly binaryPath: string; |
| 250 | readonly homePath?: string; | 247 | readonly homePath?: string; |
| 251 | readonly cwd: string; | 248 | readonly cwd: string; |
| 252 | readonly customModels?: ReadonlyArray<string>; | 249 | readonly customModels?: ReadonlyArray<string>; |
| 250 | readonly environment?: NodeJS.ProcessEnv; | ||
| 253 | }) { | 251 | }) { |
| 252 | // `~` is not shell-expanded when env vars are set via `child_process.spawn`, | ||
| 253 | // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip | ||
| 254 | // "CODEX_HOME points to '~/.codex_work', but that path does not exist". | ||
| 255 | // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. | ||
| 256 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; | ||
| 254 | const clientContext = yield* Layer.build( | 257 | const clientContext = yield* Layer.build( |
| 255 | CodexClient.layerCommand({ | 258 | CodexClient.layerCommand({ |
| 256 | command: input.binaryPath, | 259 | command: input.binaryPath, |
| 257 | args: ["app-server"], | 260 | args: ["app-server"], |
| 258 | cwd: input.cwd, | 261 | cwd: input.cwd, |
| 259 | ...(input.homePath ? { env: { CODEX_HOME: expandHomePath(input.homePath) } } : {}), | 262 | env: { |
| 263 | ...(input.environment ?? process.env), | ||
| 264 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | ||
| 265 | }, | ||
| 260 | }), | 266 | }), |
| 261 | ); | 267 | ); |
| 262 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( | 268 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( |
| ... | @@ -305,7 +311,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun | ... | @@ -305,7 +311,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 305 | models: appendCustomCodexModels(models, input.customModels ?? []), | 311 | models: appendCustomCodexModels(models, input.customModels ?? []), |
| 306 | skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), | 312 | skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), |
| 307 | } satisfies CodexAppServerProviderSnapshot; | 313 | } satisfies CodexAppServerProviderSnapshot; |
| 308 | }, Effect.scoped); | 314 | }, scopedSafeTeardown("codex-probe")); |
| 309 | 315 | ||
| 310 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => | 316 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => |
| 311 | codexSettings.customModels | 317 | codexSettings.customModels |
| ... | @@ -318,13 +324,12 @@ const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvi | ... | @@ -318,13 +324,12 @@ const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvi |
| 318 | capabilities: null, | 324 | capabilities: null, |
| 319 | })); | 325 | })); |
| 320 | 326 | ||
| 321 | const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => { | 327 | const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProviderDraft => { |
| 322 | const checkedAt = new Date().toISOString(); | 328 | const checkedAt = new Date().toISOString(); |
| 323 | const models = emptyCodexModelsFromSettings(codexSettings); | 329 | const models = emptyCodexModelsFromSettings(codexSettings); |
| 324 | 330 | ||
| 325 | if (!codexSettings.enabled) { | 331 | if (!codexSettings.enabled) { |
| 326 | return buildServerProvider({ | 332 | return buildServerProvider({ |
| 327 | provider: PROVIDER, | ||
| 328 | presentation: CODEX_PRESENTATION, | 333 | presentation: CODEX_PRESENTATION, |
| 329 | enabled: false, | 334 | enabled: false, |
| 330 | checkedAt, | 335 | checkedAt, |
| ... | @@ -341,7 +346,6 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider | ... | @@ -341,7 +346,6 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider |
| 341 | } | 346 | } |
| 342 | 347 | ||
| 343 | return buildServerProvider({ | 348 | return buildServerProvider({ |
| 344 | provider: PROVIDER, | ||
| 345 | presentation: CODEX_PRESENTATION, | 349 | presentation: CODEX_PRESENTATION, |
| 346 | enabled: true, | 350 | enabled: true, |
| 347 | checkedAt, | 351 | checkedAt, |
| ... | @@ -363,10 +367,12 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): | ... | @@ -363,10 +367,12 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): |
| 363 | readonly message?: string; | 367 | readonly message?: string; |
| 364 | } { | 368 | } { |
| 365 | const authLabel = codexAccountAuthLabel(account.account); | 369 | const authLabel = codexAccountAuthLabel(account.account); |
| 370 | const authEmail = codexAccountEmail(account.account); | ||
| 366 | const auth = { | 371 | const auth = { |
| 367 | status: account.account ? ("authenticated" as const) : ("unknown" as const), | 372 | status: account.account ? ("authenticated" as const) : ("unknown" as const), |
| 368 | ...(account.account?.type ? { type: account.account?.type } : {}), | 373 | ...(account.account?.type ? { type: account.account?.type } : {}), |
| 369 | ...(authLabel ? { label: authLabel } : {}), | 374 | ...(authLabel ? { label: authLabel } : {}), |
| 375 | ...(authEmail ? { email: authEmail } : {}), | ||
| 370 | } satisfies ServerProvider["auth"]; | 376 | } satisfies ServerProvider["auth"]; |
| 371 | 377 | ||
| 372 | if (account.account) { | 378 | if (account.account) { |
| ... | @@ -385,31 +391,29 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): | ... | @@ -385,31 +391,29 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): |
| 385 | } | 391 | } |
| 386 | 392 | ||
| 387 | export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( | 393 | export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( |
| 394 | codexSettings: CodexSettings, | ||
| 388 | probe: (input: { | 395 | probe: (input: { |
| 389 | readonly binaryPath: string; | 396 | readonly binaryPath: string; |
| 390 | readonly homePath?: string; | 397 | readonly homePath?: string; |
| 391 | readonly cwd: string; | 398 | readonly cwd: string; |
| 392 | readonly customModels: ReadonlyArray<string>; | 399 | readonly customModels: ReadonlyArray<string>; |
| 400 | readonly environment?: NodeJS.ProcessEnv; | ||
| 393 | }) => Effect.Effect< | 401 | }) => Effect.Effect< |
| 394 | CodexAppServerProviderSnapshot, | 402 | CodexAppServerProviderSnapshot, |
| 395 | CodexErrors.CodexAppServerError, | 403 | CodexErrors.CodexAppServerError, |
| 396 | ChildProcessSpawner.ChildProcessSpawner | 404 | ChildProcessSpawner.ChildProcessSpawner |
| 397 | > = probeCodexAppServerProvider, | 405 | > = probeCodexAppServerProvider, |
| 406 | environment: NodeJS.ProcessEnv = process.env, | ||
| 398 | ): Effect.fn.Return< | 407 | ): Effect.fn.Return< |
| 399 | ServerProvider, | 408 | ServerProviderDraft, |
| 400 | ServerSettingsError, | 409 | ServerSettingsError, |
| 401 | ServerSettingsService | ChildProcessSpawner.ChildProcessSpawner | 410 | ChildProcessSpawner.ChildProcessSpawner |
| 402 | > { | 411 | > { |
| 403 | const codexSettings = yield* Effect.service(ServerSettingsService).pipe( | ||
| 404 | Effect.flatMap((service) => service.getSettings), | ||
| 405 | Effect.map((settings) => settings.providers.codex), | ||
| 406 | ); | ||
| 407 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | 412 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 408 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); | 413 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); |
| 409 | 414 | ||
| 410 | if (!codexSettings.enabled) { | 415 | if (!codexSettings.enabled) { |
| 411 | return buildServerProvider({ | 416 | return buildServerProvider({ |
| 412 | provider: PROVIDER, | ||
| 413 | presentation: CODEX_PRESENTATION, | 417 | presentation: CODEX_PRESENTATION, |
| 414 | enabled: false, | 418 | enabled: false, |
| 415 | checkedAt, | 419 | checkedAt, |
| ... | @@ -430,13 +434,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -430,13 +434,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 430 | homePath: codexSettings.homePath, | 434 | homePath: codexSettings.homePath, |
| 431 | cwd: process.cwd(), | 435 | cwd: process.cwd(), |
| 432 | customModels: codexSettings.customModels, | 436 | customModels: codexSettings.customModels, |
| 437 | environment, | ||
| 433 | }).pipe(Effect.timeoutOption(Duration.millis(PROVIDER_PROBE_TIMEOUT_MS)), Effect.result); | 438 | }).pipe(Effect.timeoutOption(Duration.millis(PROVIDER_PROBE_TIMEOUT_MS)), Effect.result); |
| 434 | 439 | ||
| 435 | if (Result.isFailure(probeResult)) { | 440 | if (Result.isFailure(probeResult)) { |
| 436 | const error = probeResult.failure; | 441 | const error = probeResult.failure; |
| 437 | const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error); | 442 | const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error); |
| 438 | return buildServerProvider({ | 443 | return buildServerProvider({ |
| 439 | provider: PROVIDER, | ||
| 440 | presentation: CODEX_PRESENTATION, | 444 | presentation: CODEX_PRESENTATION, |
| 441 | enabled: codexSettings.enabled, | 445 | enabled: codexSettings.enabled, |
| 442 | checkedAt, | 446 | checkedAt, |
| ... | @@ -456,7 +460,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -456,7 +460,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 456 | 460 | ||
| 457 | if (Option.isNone(probeResult.success)) { | 461 | if (Option.isNone(probeResult.success)) { |
| 458 | return buildServerProvider({ | 462 | return buildServerProvider({ |
| 459 | provider: PROVIDER, | ||
| 460 | presentation: CODEX_PRESENTATION, | 463 | presentation: CODEX_PRESENTATION, |
| 461 | enabled: codexSettings.enabled, | 464 | enabled: codexSettings.enabled, |
| 462 | checkedAt, | 465 | checkedAt, |
| ... | @@ -476,7 +479,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -476,7 +479,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 476 | const accountStatus = accountProbeStatus(snapshot.account); | 479 | const accountStatus = accountProbeStatus(snapshot.account); |
| 477 | 480 | ||
| 478 | return buildServerProvider({ | 481 | return buildServerProvider({ |
| 479 | provider: PROVIDER, | ||
| 480 | presentation: CODEX_PRESENTATION, | 482 | presentation: CODEX_PRESENTATION, |
| 481 | enabled: codexSettings.enabled, | 483 | enabled: codexSettings.enabled, |
| 482 | checkedAt, | 484 | checkedAt, |
| ... | @@ -492,28 +494,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -492,28 +494,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 492 | }); | 494 | }); |
| 493 | }); | 495 | }); |
| 494 | 496 | ||
| 495 | export const CodexProviderLive = Layer.effect( | 497 | // NOTE: the singleton `CodexProviderLive` Layer has been removed as part of |
| 496 | CodexProvider, | 498 | // the per-instance-driver refactor. `CodexDriver.create()` builds a managed |
| 497 | Effect.gen(function* () { | 499 | // snapshot per instance (each with its own `CodexSettings`) and hands the |
| 498 | const serverSettings = yield* ServerSettingsService; | 500 | // resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. |
| 499 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 501 | // |
| 500 | const checkProvider = checkCodexProviderStatus().pipe( | 502 | // The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are |
| 501 | Effect.provideService(ServerSettingsService, serverSettings), | 503 | // re-exported for use by `CodexDriver`. |
| 502 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 504 | export { makePendingCodexProvider }; |
| 503 | ); | ||
| 504 | |||
| 505 | return yield* makeManagedServerProvider<CodexSettings>({ | ||
| 506 | getSettings: serverSettings.getSettings.pipe( | ||
| 507 | Effect.map((settings) => settings.providers.codex), | ||
| 508 | Effect.orDie, | ||
| 509 | ), | ||
| 510 | streamSettings: serverSettings.streamChanges.pipe( | ||
| 511 | Stream.map((settings) => settings.providers.codex), | ||
| 512 | ), | ||
| 513 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | ||
| 514 | initialSnapshot: makePendingCodexProvider, | ||
| 515 | checkProvider, | ||
| 516 | refreshInterval: Duration.minutes(5), | ||
| 517 | }); | ||
| 518 | }), | ||
| 519 | ); |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+39-22| ... | @@ -1,10 +1,10 @@ | ... | @@ -1,10 +1,10 @@ |
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import { | 1 | import { |
| 4 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 5 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL, |
| 6 | EventId, | 4 | EventId, |
| 5 | ProviderDriverKind, | ||
| 7 | ProviderItemId, | 6 | ProviderItemId, |
| 7 | type ProviderInstanceId, | ||
| 8 | type ProviderApprovalDecision, | 8 | type ProviderApprovalDecision, |
| 9 | type ProviderEvent, | 9 | type ProviderEvent, |
| 10 | type ProviderInteractionMode, | 10 | type ProviderInteractionMode, |
| ... | @@ -17,7 +17,7 @@ import { | ... | @@ -17,7 +17,7 @@ import { |
| 17 | TurnId, | 17 | TurnId, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 19 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 20 | import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Schema, Stream } from "effect"; | 20 | import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Random, Schema, Stream } from "effect"; |
| 21 | import * as SchemaIssue from "effect/SchemaIssue"; | 21 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | import * as CodexClient from "effect-codex-app-server/client"; | 23 | import * as CodexClient from "effect-codex-app-server/client"; |
| ... | @@ -26,13 +26,13 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; | ... | @@ -26,13 +26,13 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; |
| 26 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; | 26 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 27 | 27 | ||
| 28 | import { buildCodexInitializeParams } from "./CodexProvider.ts"; | 28 | import { buildCodexInitializeParams } from "./CodexProvider.ts"; |
| 29 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 29 | import { | 30 | import { |
| 30 | CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, | 31 | CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, |
| 31 | CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, | 32 | CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, |
| 32 | } from "../CodexDeveloperInstructions.ts"; | 33 | } from "../CodexDeveloperInstructions.ts"; |
| 33 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 34 | 34 | ||
| 35 | const PROVIDER = "codex" as const; | 35 | const PROVIDER = ProviderDriverKind.make("codex"); |
| 36 | 36 | ||
| 37 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); | 37 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); |
| 38 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); | 38 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); |
| ... | @@ -76,8 +76,10 @@ type CodexThreadItem = | ... | @@ -76,8 +76,10 @@ type CodexThreadItem = |
| 76 | 76 | ||
| 77 | export interface CodexSessionRuntimeOptions { | 77 | export interface CodexSessionRuntimeOptions { |
| 78 | readonly threadId: ThreadId; | 78 | readonly threadId: ThreadId; |
| 79 | readonly providerInstanceId?: ProviderInstanceId; | ||
| 79 | readonly binaryPath: string; | 80 | readonly binaryPath: string; |
| 80 | readonly homePath?: string; | 81 | readonly homePath?: string; |
| 82 | readonly environment?: NodeJS.ProcessEnv; | ||
| 81 | readonly cwd: string; | 83 | readonly cwd: string; |
| 82 | readonly runtimeMode: RuntimeMode; | 84 | readonly runtimeMode: RuntimeMode; |
| 83 | readonly model?: string; | 85 | readonly model?: string; |
| ... | @@ -87,7 +89,10 @@ export interface CodexSessionRuntimeOptions { | ... | @@ -87,7 +89,10 @@ export interface CodexSessionRuntimeOptions { |
| 87 | 89 | ||
| 88 | export interface CodexSessionRuntimeSendTurnInput { | 90 | export interface CodexSessionRuntimeSendTurnInput { |
| 89 | readonly input?: string; | 91 | readonly input?: string; |
| 90 | readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string }>; | 92 | readonly attachments?: ReadonlyArray<{ |
| 93 | readonly type: "image"; | ||
| 94 | readonly url: string; | ||
| 95 | }>; | ||
| 91 | readonly model?: string; | 96 | readonly model?: string; |
| 92 | readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier | undefined; | 97 | readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier | undefined; |
| 93 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; | 98 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; |
| ... | @@ -303,7 +308,7 @@ function buildCodexCollaborationMode(input: { | ... | @@ -303,7 +308,7 @@ function buildCodexCollaborationMode(input: { |
| 303 | if (input.interactionMode === undefined) { | 308 | if (input.interactionMode === undefined) { |
| 304 | return undefined; | 309 | return undefined; |
| 305 | } | 310 | } |
| 306 | const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL_BY_PROVIDER.codex; | 311 | const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; |
| 307 | return { | 312 | return { |
| 308 | mode: input.interactionMode, | 313 | mode: input.interactionMode, |
| 309 | settings: { | 314 | settings: { |
| ... | @@ -321,7 +326,10 @@ export function buildTurnStartParams(input: { | ... | @@ -321,7 +326,10 @@ export function buildTurnStartParams(input: { |
| 321 | readonly threadId: string; | 326 | readonly threadId: string; |
| 322 | readonly runtimeMode: RuntimeMode; | 327 | readonly runtimeMode: RuntimeMode; |
| 323 | readonly prompt?: string; | 328 | readonly prompt?: string; |
| 324 | readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string }>; | 329 | readonly attachments?: ReadonlyArray<{ |
| 330 | readonly type: "image"; | ||
| 331 | readonly url: string; | ||
| 332 | }>; | ||
| 325 | readonly model?: string; | 333 | readonly model?: string; |
| 326 | readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier; | 334 | readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier; |
| 327 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; | 335 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; |
| ... | @@ -680,13 +688,19 @@ export const makeCodexSessionRuntime = ( | ... | @@ -680,13 +688,19 @@ export const makeCodexSessionRuntime = ( |
| 680 | const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); | 688 | const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); |
| 681 | const closedRef = yield* Ref.make(false); | 689 | const closedRef = yield* Ref.make(false); |
| 682 | 690 | ||
| 691 | // `~` is not shell-expanded when env vars are set via | ||
| 692 | // `child_process.spawn`; `expandHomePath` lets a configured | ||
| 693 | // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. | ||
| 694 | const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; | ||
| 695 | const env = { | ||
| 696 | ...(options.environment ?? process.env), | ||
| 697 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | ||
| 698 | }; | ||
| 683 | const child = yield* spawner | 699 | const child = yield* spawner |
| 684 | .spawn( | 700 | .spawn( |
| 685 | ChildProcess.make(options.binaryPath, ["app-server"], { | 701 | ChildProcess.make(options.binaryPath, ["app-server"], { |
| 686 | cwd: options.cwd, | 702 | cwd: options.cwd, |
| 687 | ...(options.homePath | 703 | env, |
| 688 | ? { env: { ...process.env, CODEX_HOME: expandHomePath(options.homePath) } } | ||
| 689 | : {}), | ||
| 690 | shell: process.platform === "win32", | 704 | shell: process.platform === "win32", |
| 691 | }), | 705 | }), |
| 692 | ) | 706 | ) |
| ... | @@ -712,6 +726,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -712,6 +726,7 @@ export const makeCodexSessionRuntime = ( |
| 712 | 726 | ||
| 713 | const initialSession = { | 727 | const initialSession = { |
| 714 | provider: PROVIDER, | 728 | provider: PROVIDER, |
| 729 | ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), | ||
| 715 | status: "connecting", | 730 | status: "connecting", |
| 716 | runtimeMode: options.runtimeMode, | 731 | runtimeMode: options.runtimeMode, |
| 717 | cwd: options.cwd, | 732 | cwd: options.cwd, |
| ... | @@ -725,13 +740,15 @@ export const makeCodexSessionRuntime = ( | ... | @@ -725,13 +740,15 @@ export const makeCodexSessionRuntime = ( |
| 725 | const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); | 740 | const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); |
| 726 | 741 | ||
| 727 | const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) => | 742 | const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) => |
| 728 | offerEvent({ | 743 | Effect.flatMap(Random.nextUUIDv4, (id) => |
| 729 | id: EventId.make(randomUUID()), | 744 | offerEvent({ |
| 730 | provider: PROVIDER, | 745 | id: EventId.make(id), |
| 731 | createdAt: new Date().toISOString(), | 746 | provider: PROVIDER, |
| 732 | ...event, | 747 | ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), |
| 733 | }); | 748 | createdAt: new Date().toISOString(), |
| 734 | 749 | ...event, | |
| 750 | }), | ||
| 751 | ); | ||
| 735 | const emitSessionEvent = (method: string, message: string) => | 752 | const emitSessionEvent = (method: string, message: string) => |
| 736 | emitEvent({ | 753 | emitEvent({ |
| 737 | kind: "session", | 754 | kind: "session", |
| ... | @@ -891,7 +908,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -891,7 +908,7 @@ export const makeCodexSessionRuntime = ( |
| 891 | 908 | ||
| 892 | yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => | 909 | yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => |
| 893 | Effect.gen(function* () { | 910 | Effect.gen(function* () { |
| 894 | const requestId = ApprovalRequestId.make(randomUUID()); | 911 | const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); |
| 895 | const turnId = TurnId.make(payload.turnId); | 912 | const turnId = TurnId.make(payload.turnId); |
| 896 | const itemId = ProviderItemId.make(payload.itemId); | 913 | const itemId = ProviderItemId.make(payload.itemId); |
| 897 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | 914 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| ... | @@ -947,7 +964,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -947,7 +964,7 @@ export const makeCodexSessionRuntime = ( |
| 947 | 964 | ||
| 948 | yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => | 965 | yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => |
| 949 | Effect.gen(function* () { | 966 | Effect.gen(function* () { |
| 950 | const requestId = ApprovalRequestId.make(randomUUID()); | 967 | const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); |
| 951 | const turnId = TurnId.make(payload.turnId); | 968 | const turnId = TurnId.make(payload.turnId); |
| 952 | const itemId = ProviderItemId.make(payload.itemId); | 969 | const itemId = ProviderItemId.make(payload.itemId); |
| 953 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | 970 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| ... | @@ -1003,7 +1020,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1003,7 +1020,7 @@ export const makeCodexSessionRuntime = ( |
| 1003 | 1020 | ||
| 1004 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => | 1021 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => |
| 1005 | Effect.gen(function* () { | 1022 | Effect.gen(function* () { |
| 1006 | const requestId = ApprovalRequestId.make(randomUUID()); | 1023 | const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); |
| 1007 | const turnId = TurnId.make(payload.turnId); | 1024 | const turnId = TurnId.make(payload.turnId); |
| 1008 | const itemId = ProviderItemId.make(payload.itemId); | 1025 | const itemId = ProviderItemId.make(payload.itemId); |
| 1009 | const answers = yield* Deferred.make<ProviderUserInputAnswers>(); | 1026 | const answers = yield* Deferred.make<ProviderUserInputAnswers>(); |
apps/server/src/provider/Layers/CursorAdapter.test.ts+171-42| ... | @@ -5,15 +5,27 @@ import { fileURLToPath } from "node:url"; | ... | @@ -5,15 +5,27 @@ import { fileURLToPath } from "node:url"; |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { assert, it } from "@effect/vitest"; | 7 | import { assert, it } from "@effect/vitest"; |
| 8 | import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; | 8 | import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | 10 | ||
| 11 | import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; | 11 | import { |
| 12 | ApprovalRequestId, | ||
| 13 | CursorSettings, | ||
| 14 | ProviderDriverKind, | ||
| 15 | type ProviderRuntimeEvent, | ||
| 16 | ThreadId, | ||
| 17 | ProviderInstanceId, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 12 | 19 | ||
| 13 | import { ServerConfig } from "../../config.ts"; | 20 | import { ServerConfig } from "../../config.ts"; |
| 14 | import { ServerSettingsService } from "../../serverSettings.ts"; | 21 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 15 | import { CursorAdapter } from "../Services/CursorAdapter.ts"; | 22 | import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; |
| 16 | import { makeCursorAdapterLive } from "./CursorAdapter.ts"; | 23 | import { makeCursorAdapter } from "./CursorAdapter.ts"; |
| 24 | |||
| 25 | // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. | ||
| 26 | class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()( | ||
| 27 | "test/CursorAdapter", | ||
| 28 | ) {} | ||
| 17 | 29 | ||
| 18 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 30 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 19 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 31 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); |
| ... | @@ -91,8 +103,31 @@ async function waitForFileContent(filePath: string, attempts = 40) { | ... | @@ -91,8 +103,31 @@ async function waitForFileContent(filePath: string, attempts = 40) { |
| 91 | throw new Error(`Timed out waiting for file content at ${filePath}`); | 103 | throw new Error(`Timed out waiting for file content at ${filePath}`); |
| 92 | } | 104 | } |
| 93 | 105 | ||
| 106 | // Tests mutate `ServerSettingsService` mid-flight (e.g. setting | ||
| 107 | // `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter | ||
| 108 | // captures `cursorSettings` once at construction, so without a resolver | ||
| 109 | // the mutation is invisible — sessions would spawn the constructor's | ||
| 110 | // (empty) binary path. Wiring `resolveSettings` through | ||
| 111 | // `ServerSettingsService.getSettings` makes each session read the latest | ||
| 112 | // snapshot, matching the old "always read live" behavior that these | ||
| 113 | // tests assumed. | ||
| 114 | const makeResolveCursorSettings = Effect.gen(function* () { | ||
| 115 | const serverSettings = yield* ServerSettingsService; | ||
| 116 | return serverSettings.getSettings.pipe( | ||
| 117 | Effect.map((snapshot) => snapshot.providers.cursor), | ||
| 118 | Effect.orDie, | ||
| 119 | ); | ||
| 120 | }); | ||
| 121 | |||
| 94 | const cursorAdapterTestLayer = it.layer( | 122 | const cursorAdapterTestLayer = it.layer( |
| 95 | makeCursorAdapterLive().pipe( | 123 | Layer.effect( |
| 124 | CursorAdapter, | ||
| 125 | Effect.gen(function* () { | ||
| 126 | const cursorConfig = Schema.decodeSync(CursorSettings)({}); | ||
| 127 | const resolveSettings = yield* makeResolveCursorSettings; | ||
| 128 | return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); | ||
| 129 | }), | ||
| 130 | ).pipe( | ||
| 96 | Layer.provideMerge(ServerSettingsService.layerTest()), | 131 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 97 | Layer.provideMerge( | 132 | Layer.provideMerge( |
| 98 | ServerConfig.layerTest(process.cwd(), { | 133 | ServerConfig.layerTest(process.cwd(), { |
| ... | @@ -120,10 +155,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -120,10 +155,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 120 | 155 | ||
| 121 | const session = yield* adapter.startSession({ | 156 | const session = yield* adapter.startSession({ |
| 122 | threadId, | 157 | threadId, |
| 123 | provider: "cursor", | 158 | provider: ProviderDriverKind.make("cursor"), |
| 124 | cwd: process.cwd(), | 159 | cwd: process.cwd(), |
| 125 | runtimeMode: "full-access", | 160 | runtimeMode: "full-access", |
| 126 | modelSelection: { provider: "cursor", model: "default" }, | 161 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 127 | }); | 162 | }); |
| 128 | 163 | ||
| 129 | assert.equal(session.provider, "cursor"); | 164 | assert.equal(session.provider, "cursor"); |
| ... | @@ -205,10 +240,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -205,10 +240,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 205 | 240 | ||
| 206 | yield* adapter.startSession({ | 241 | yield* adapter.startSession({ |
| 207 | threadId, | 242 | threadId, |
| 208 | provider: "cursor", | 243 | provider: ProviderDriverKind.make("cursor"), |
| 209 | cwd: process.cwd(), | 244 | cwd: process.cwd(), |
| 210 | runtimeMode: "full-access", | 245 | runtimeMode: "full-access", |
| 211 | modelSelection: { provider: "cursor", model: "default" }, | 246 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 212 | }); | 247 | }); |
| 213 | 248 | ||
| 214 | yield* adapter.stopSession(threadId); | 249 | yield* adapter.stopSession(threadId); |
| ... | @@ -244,17 +279,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -244,17 +279,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 244 | [ | 279 | [ |
| 245 | adapter.startSession({ | 280 | adapter.startSession({ |
| 246 | threadId, | 281 | threadId, |
| 247 | provider: "cursor", | 282 | provider: ProviderDriverKind.make("cursor"), |
| 248 | cwd: process.cwd(), | 283 | cwd: process.cwd(), |
| 249 | runtimeMode: "full-access", | 284 | runtimeMode: "full-access", |
| 250 | modelSelection: { provider: "cursor", model: "default" }, | 285 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 251 | }), | 286 | }), |
| 252 | adapter.startSession({ | 287 | adapter.startSession({ |
| 253 | threadId, | 288 | threadId, |
| 254 | provider: "cursor", | 289 | provider: ProviderDriverKind.make("cursor"), |
| 255 | cwd: process.cwd(), | 290 | cwd: process.cwd(), |
| 256 | runtimeMode: "full-access", | 291 | runtimeMode: "full-access", |
| 257 | modelSelection: { provider: "cursor", model: "default" }, | 292 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 258 | }), | 293 | }), |
| 259 | ], | 294 | ], |
| 260 | { concurrency: "unbounded" }, | 295 | { concurrency: "unbounded" }, |
| ... | @@ -276,7 +311,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -276,7 +311,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 276 | const result = yield* adapter | 311 | const result = yield* adapter |
| 277 | .startSession({ | 312 | .startSession({ |
| 278 | threadId: ThreadId.make("bad-provider"), | 313 | threadId: ThreadId.make("bad-provider"), |
| 279 | provider: "codex", | 314 | provider: ProviderDriverKind.make("codex"), |
| 280 | cwd: process.cwd(), | 315 | cwd: process.cwd(), |
| 281 | runtimeMode: "full-access", | 316 | runtimeMode: "full-access", |
| 282 | }) | 317 | }) |
| ... | @@ -302,10 +337,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -302,10 +337,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 302 | 337 | ||
| 303 | yield* adapter.startSession({ | 338 | yield* adapter.startSession({ |
| 304 | threadId, | 339 | threadId, |
| 305 | provider: "cursor", | 340 | provider: ProviderDriverKind.make("cursor"), |
| 306 | cwd: process.cwd(), | 341 | cwd: process.cwd(), |
| 307 | runtimeMode: "full-access", | 342 | runtimeMode: "full-access", |
| 308 | modelSelection: { provider: "cursor", model: "composer-2" }, | 343 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, |
| 309 | }); | 344 | }); |
| 310 | 345 | ||
| 311 | yield* adapter.sendTurn({ | 346 | yield* adapter.sendTurn({ |
| ... | @@ -358,7 +393,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -358,7 +393,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 358 | providers: { cursor: { binaryPath: wrapperPath } }, | 393 | providers: { cursor: { binaryPath: wrapperPath } }, |
| 359 | }); | 394 | }); |
| 360 | 395 | ||
| 361 | const modelSelection = createModelSelection("cursor", "gpt-5.4", [ | 396 | const modelSelection = createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ |
| 362 | { id: "reasoning", value: "xhigh" }, | 397 | { id: "reasoning", value: "xhigh" }, |
| 363 | { id: "contextWindow", value: "1m" }, | 398 | { id: "contextWindow", value: "1m" }, |
| 364 | { id: "fastMode", value: true }, | 399 | { id: "fastMode", value: true }, |
| ... | @@ -366,7 +401,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -366,7 +401,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 366 | 401 | ||
| 367 | yield* adapter.startSession({ | 402 | yield* adapter.startSession({ |
| 368 | threadId, | 403 | threadId, |
| 369 | provider: "cursor", | 404 | provider: ProviderDriverKind.make("cursor"), |
| 370 | cwd: process.cwd(), | 405 | cwd: process.cwd(), |
| 371 | runtimeMode: "full-access", | 406 | runtimeMode: "full-access", |
| 372 | modelSelection, | 407 | modelSelection, |
| ... | @@ -460,10 +495,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -460,10 +495,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 460 | const program = Effect.gen(function* () { | 495 | const program = Effect.gen(function* () { |
| 461 | yield* adapter.startSession({ | 496 | yield* adapter.startSession({ |
| 462 | threadId, | 497 | threadId, |
| 463 | provider: "cursor", | 498 | provider: ProviderDriverKind.make("cursor"), |
| 464 | cwd: process.cwd(), | 499 | cwd: process.cwd(), |
| 465 | runtimeMode: "approval-required", | 500 | runtimeMode: "approval-required", |
| 466 | modelSelection: { provider: "cursor", model: "default" }, | 501 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 467 | }); | 502 | }); |
| 468 | 503 | ||
| 469 | const turn = yield* adapter.sendTurn({ | 504 | const turn = yield* adapter.sendTurn({ |
| ... | @@ -560,7 +595,14 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -560,7 +595,14 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 560 | ); | 595 | ); |
| 561 | }).pipe( | 596 | }).pipe( |
| 562 | Effect.provide( | 597 | Effect.provide( |
| 563 | makeCursorAdapterLive().pipe( | 598 | Layer.effect( |
| 599 | CursorAdapter, | ||
| 600 | Effect.gen(function* () { | ||
| 601 | const cursorConfig = Schema.decodeSync(CursorSettings)({}); | ||
| 602 | const resolveSettings = yield* makeResolveCursorSettings; | ||
| 603 | return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); | ||
| 604 | }), | ||
| 605 | ).pipe( | ||
| 564 | Layer.provideMerge(ServerSettingsService.layerTest()), | 606 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 565 | Layer.provideMerge( | 607 | Layer.provideMerge( |
| 566 | ServerConfig.layerTest(process.cwd(), { | 608 | ServerConfig.layerTest(process.cwd(), { |
| ... | @@ -615,10 +657,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -615,10 +657,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 615 | 657 | ||
| 616 | yield* adapter.startSession({ | 658 | yield* adapter.startSession({ |
| 617 | threadId, | 659 | threadId, |
| 618 | provider: "cursor", | 660 | provider: ProviderDriverKind.make("cursor"), |
| 619 | cwd: process.cwd(), | 661 | cwd: process.cwd(), |
| 620 | runtimeMode: "full-access", | 662 | runtimeMode: "full-access", |
| 621 | modelSelection: { provider: "cursor", model: "default" }, | 663 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 622 | }); | 664 | }); |
| 623 | 665 | ||
| 624 | const turn = yield* adapter.sendTurn({ | 666 | const turn = yield* adapter.sendTurn({ |
| ... | @@ -714,10 +756,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -714,10 +756,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 714 | 756 | ||
| 715 | yield* adapter.startSession({ | 757 | yield* adapter.startSession({ |
| 716 | threadId, | 758 | threadId, |
| 717 | provider: "cursor", | 759 | provider: ProviderDriverKind.make("cursor"), |
| 718 | cwd: process.cwd(), | 760 | cwd: process.cwd(), |
| 719 | runtimeMode: "full-access", | 761 | runtimeMode: "full-access", |
| 720 | modelSelection: { provider: "cursor", model: "default" }, | 762 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 721 | }); | 763 | }); |
| 722 | 764 | ||
| 723 | const turn = yield* adapter.sendTurn({ | 765 | const turn = yield* adapter.sendTurn({ |
| ... | @@ -836,10 +878,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -836,10 +878,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 836 | 878 | ||
| 837 | yield* adapter.startSession({ | 879 | yield* adapter.startSession({ |
| 838 | threadId, | 880 | threadId, |
| 839 | provider: "cursor", | 881 | provider: ProviderDriverKind.make("cursor"), |
| 840 | cwd: process.cwd(), | 882 | cwd: process.cwd(), |
| 841 | runtimeMode: "approval-required", | 883 | runtimeMode: "approval-required", |
| 842 | modelSelection: { provider: "cursor", model: "default" }, | 884 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 843 | }); | 885 | }); |
| 844 | 886 | ||
| 845 | const sendTurnFiber = yield* adapter | 887 | const sendTurnFiber = yield* adapter |
| ... | @@ -906,10 +948,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -906,10 +948,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 906 | 948 | ||
| 907 | yield* adapter.startSession({ | 949 | yield* adapter.startSession({ |
| 908 | threadId, | 950 | threadId, |
| 909 | provider: "cursor", | 951 | provider: ProviderDriverKind.make("cursor"), |
| 910 | cwd: process.cwd(), | 952 | cwd: process.cwd(), |
| 911 | runtimeMode: "approval-required", | 953 | runtimeMode: "approval-required", |
| 912 | modelSelection: { provider: "cursor", model: "default" }, | 954 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 913 | }); | 955 | }); |
| 914 | 956 | ||
| 915 | const sendTurnFiber = yield* adapter | 957 | const sendTurnFiber = yield* adapter |
| ... | @@ -949,10 +991,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -949,10 +991,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 949 | 991 | ||
| 950 | yield* adapter.startSession({ | 992 | yield* adapter.startSession({ |
| 951 | threadId, | 993 | threadId, |
| 952 | provider: "cursor", | 994 | provider: ProviderDriverKind.make("cursor"), |
| 953 | cwd: process.cwd(), | 995 | cwd: process.cwd(), |
| 954 | runtimeMode: "full-access", | 996 | runtimeMode: "full-access", |
| 955 | modelSelection: { provider: "cursor", model: "default" }, | 997 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 956 | }); | 998 | }); |
| 957 | 999 | ||
| 958 | const sendTurnFiber = yield* adapter | 1000 | const sendTurnFiber = yield* adapter |
| ... | @@ -992,10 +1034,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -992,10 +1034,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 992 | 1034 | ||
| 993 | yield* adapter.startSession({ | 1035 | yield* adapter.startSession({ |
| 994 | threadId, | 1036 | threadId, |
| 995 | provider: "cursor", | 1037 | provider: ProviderDriverKind.make("cursor"), |
| 996 | cwd: process.cwd(), | 1038 | cwd: process.cwd(), |
| 997 | runtimeMode: "full-access", | 1039 | runtimeMode: "full-access", |
| 998 | modelSelection: { provider: "cursor", model: "default" }, | 1040 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 999 | }); | 1041 | }); |
| 1000 | 1042 | ||
| 1001 | const sendTurnFiber = yield* adapter | 1043 | const sendTurnFiber = yield* adapter |
| ... | @@ -1035,10 +1077,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1035,10 +1077,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1035 | 1077 | ||
| 1036 | yield* adapter.startSession({ | 1078 | yield* adapter.startSession({ |
| 1037 | threadId, | 1079 | threadId, |
| 1038 | provider: "cursor", | 1080 | provider: ProviderDriverKind.make("cursor"), |
| 1039 | cwd: process.cwd(), | 1081 | cwd: process.cwd(), |
| 1040 | runtimeMode: "full-access", | 1082 | runtimeMode: "full-access", |
| 1041 | modelSelection: { provider: "cursor", model: "default" }, | 1083 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, |
| 1042 | }); | 1084 | }); |
| 1043 | 1085 | ||
| 1044 | const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); | 1086 | const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); |
| ... | @@ -1073,10 +1115,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1073,10 +1115,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1073 | 1115 | ||
| 1074 | yield* adapter.startSession({ | 1116 | yield* adapter.startSession({ |
| 1075 | threadId, | 1117 | threadId, |
| 1076 | provider: "cursor", | 1118 | provider: ProviderDriverKind.make("cursor"), |
| 1077 | cwd: process.cwd(), | 1119 | cwd: process.cwd(), |
| 1078 | runtimeMode: "full-access", | 1120 | runtimeMode: "full-access", |
| 1079 | modelSelection: { provider: "cursor", model: "composer-2" }, | 1121 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, |
| 1080 | }); | 1122 | }); |
| 1081 | 1123 | ||
| 1082 | yield* adapter.sendTurn({ | 1124 | yield* adapter.sendTurn({ |
| ... | @@ -1089,7 +1131,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1089,7 +1131,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1089 | threadId, | 1131 | threadId, |
| 1090 | input: "second turn after switching model", | 1132 | input: "second turn after switching model", |
| 1091 | attachments: [], | 1133 | attachments: [], |
| 1092 | modelSelection: createModelSelection("cursor", "composer-2", [ | 1134 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ |
| 1093 | { id: "fastMode", value: true }, | 1135 | { id: "fastMode", value: true }, |
| 1094 | ]), | 1136 | ]), |
| 1095 | }); | 1137 | }); |
| ... | @@ -1136,17 +1178,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1136,17 +1178,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1136 | 1178 | ||
| 1137 | yield* adapter.startSession({ | 1179 | yield* adapter.startSession({ |
| 1138 | threadId, | 1180 | threadId, |
| 1139 | provider: "cursor", | 1181 | provider: ProviderDriverKind.make("cursor"), |
| 1140 | cwd: process.cwd(), | 1182 | cwd: process.cwd(), |
| 1141 | runtimeMode: "full-access", | 1183 | runtimeMode: "full-access", |
| 1142 | modelSelection: { provider: "cursor", model: "composer-2" }, | 1184 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, |
| 1143 | }); | 1185 | }); |
| 1144 | 1186 | ||
| 1145 | yield* adapter.sendTurn({ | 1187 | yield* adapter.sendTurn({ |
| 1146 | threadId, | 1188 | threadId, |
| 1147 | input: "first turn with fast mode", | 1189 | input: "first turn with fast mode", |
| 1148 | attachments: [], | 1190 | attachments: [], |
| 1149 | modelSelection: createModelSelection("cursor", "composer-2", [ | 1191 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ |
| 1150 | { id: "fastMode", value: true }, | 1192 | { id: "fastMode", value: true }, |
| 1151 | ]), | 1193 | ]), |
| 1152 | }); | 1194 | }); |
| ... | @@ -1155,7 +1197,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1155,7 +1197,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1155 | threadId, | 1197 | threadId, |
| 1156 | input: "second turn without fast mode", | 1198 | input: "second turn without fast mode", |
| 1157 | attachments: [], | 1199 | attachments: [], |
| 1158 | modelSelection: createModelSelection("cursor", "composer-2", [ | 1200 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ |
| 1159 | { id: "fastMode", value: false }, | 1201 | { id: "fastMode", value: false }, |
| 1160 | ]), | 1202 | ]), |
| 1161 | }); | 1203 | }); |
| ... | @@ -1174,4 +1216,91 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1174,4 +1216,91 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1174 | yield* adapter.stopSession(threadId); | 1216 | yield* adapter.stopSession(threadId); |
| 1175 | }), | 1217 | }), |
| 1176 | ); | 1218 | ); |
| 1219 | |||
| 1220 | it.effect( | ||
| 1221 | "applies fast mode on the first turn when modelSelection uses a non-default instance id", | ||
| 1222 | () => { | ||
| 1223 | const customInstanceId = ProviderInstanceId.make("cursor_secondary"); | ||
| 1224 | // Custom-instance cases can't share the suite-level `CursorAdapter` | ||
| 1225 | // layer because that one binds `instanceId: "cursor"`. We build a | ||
| 1226 | // fresh layer graph — including a fresh `ServerSettingsService` — so | ||
| 1227 | // mid-test `updateSettings` calls target the same service instance the | ||
| 1228 | // adapter's `resolveSettings` reads from, and so the outer | ||
| 1229 | // `yield* ServerSettingsService` sees the same snapshot as well. | ||
| 1230 | const customAdapterLayer = Layer.effect( | ||
| 1231 | CursorAdapter, | ||
| 1232 | Effect.gen(function* () { | ||
| 1233 | const cursorConfig = Schema.decodeSync(CursorSettings)({}); | ||
| 1234 | const resolveSettings = yield* makeResolveCursorSettings; | ||
| 1235 | return yield* makeCursorAdapter(cursorConfig, { | ||
| 1236 | instanceId: customInstanceId, | ||
| 1237 | resolveSettings, | ||
| 1238 | }); | ||
| 1239 | }), | ||
| 1240 | ).pipe( | ||
| 1241 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 1242 | Layer.provideMerge( | ||
| 1243 | ServerConfig.layerTest(process.cwd(), { | ||
| 1244 | prefix: "t3code-cursor-adapter-custom-instance-", | ||
| 1245 | }), | ||
| 1246 | ), | ||
| 1247 | Layer.provideMerge(NodeServices.layer), | ||
| 1248 | ); | ||
| 1249 | |||
| 1250 | return Effect.gen(function* () { | ||
| 1251 | const adapter = yield* CursorAdapter; | ||
| 1252 | const serverSettings = yield* ServerSettingsService; | ||
| 1253 | const threadId = ThreadId.make("cursor-fast-mode-custom-instance"); | ||
| 1254 | const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); | ||
| 1255 | const requestLogPath = path.join(tempDir, "requests.ndjson"); | ||
| 1256 | const argvLogPath = path.join(tempDir, "argv.txt"); | ||
| 1257 | yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); | ||
| 1258 | const wrapperPath = yield* Effect.promise(() => | ||
| 1259 | makeProbeWrapper(requestLogPath, argvLogPath), | ||
| 1260 | ); | ||
| 1261 | yield* serverSettings.updateSettings({ | ||
| 1262 | providers: { cursor: { binaryPath: wrapperPath } }, | ||
| 1263 | }); | ||
| 1264 | |||
| 1265 | yield* adapter.startSession({ | ||
| 1266 | threadId, | ||
| 1267 | provider: ProviderDriverKind.make("cursor"), | ||
| 1268 | cwd: process.cwd(), | ||
| 1269 | runtimeMode: "full-access", | ||
| 1270 | modelSelection: { | ||
| 1271 | instanceId: customInstanceId, | ||
| 1272 | model: "composer-2", | ||
| 1273 | }, | ||
| 1274 | }); | ||
| 1275 | |||
| 1276 | yield* adapter.sendTurn({ | ||
| 1277 | threadId, | ||
| 1278 | input: "first turn with fast mode", | ||
| 1279 | attachments: [], | ||
| 1280 | modelSelection: { | ||
| 1281 | ...createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ | ||
| 1282 | { id: "fastMode", value: true }, | ||
| 1283 | ]), | ||
| 1284 | instanceId: customInstanceId, | ||
| 1285 | }, | ||
| 1286 | }); | ||
| 1287 | |||
| 1288 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); | ||
| 1289 | const fastConfigRequests = requests.filter( | ||
| 1290 | (entry) => | ||
| 1291 | entry.method === "session/set_config_option" && | ||
| 1292 | (entry.params as Record<string, unknown> | undefined)?.configId === "fast", | ||
| 1293 | ); | ||
| 1294 | assert.isAbove( | ||
| 1295 | fastConfigRequests.length, | ||
| 1296 | 0, | ||
| 1297 | "fast mode should apply when instance id matches the adapter binding", | ||
| 1298 | ); | ||
| 1299 | const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; | ||
| 1300 | assert.equal((lastFastConfig?.params as Record<string, unknown>)?.value, "true"); | ||
| 1301 | |||
| 1302 | yield* adapter.stopSession(threadId); | ||
| 1303 | }).pipe(Effect.provide(customAdapterLayer)); | ||
| 1304 | }, | ||
| 1305 | ); | ||
| 1177 | }); | 1306 | }); |
apps/server/src/provider/Layers/CursorAdapter.ts+45-28| ... | @@ -7,6 +7,7 @@ import * as nodePath from "node:path"; | ... | @@ -7,6 +7,7 @@ import * as nodePath from "node:path"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | ApprovalRequestId, | 9 | ApprovalRequestId, |
| 10 | type CursorSettings, | ||
| 10 | type ProviderOptionSelection, | 11 | type ProviderOptionSelection, |
| 11 | EventId, | 12 | EventId, |
| 12 | type ProviderApprovalDecision, | 13 | type ProviderApprovalDecision, |
| ... | @@ -14,6 +15,8 @@ import { | ... | @@ -14,6 +15,8 @@ import { |
| 14 | type ProviderRuntimeEvent, | 15 | type ProviderRuntimeEvent, |
| 15 | type ProviderSession, | 16 | type ProviderSession, |
| 16 | type ProviderUserInputAnswers, | 17 | type ProviderUserInputAnswers, |
| 18 | ProviderDriverKind, | ||
| 19 | ProviderInstanceId, | ||
| 17 | RuntimeRequestId, | 20 | RuntimeRequestId, |
| 18 | type RuntimeMode, | 21 | type RuntimeMode, |
| 19 | type ThreadId, | 22 | type ThreadId, |
| ... | @@ -26,7 +29,6 @@ import { | ... | @@ -26,7 +29,6 @@ import { |
| 26 | Exit, | 29 | Exit, |
| 27 | Fiber, | 30 | Fiber, |
| 28 | FileSystem, | 31 | FileSystem, |
| 29 | Layer, | ||
| 30 | Option, | 32 | Option, |
| 31 | PubSub, | 33 | PubSub, |
| 32 | Random, | 34 | Random, |
| ... | @@ -40,7 +42,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; | ... | @@ -40,7 +42,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 40 | 42 | ||
| 41 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 42 | import { ServerConfig } from "../../config.ts"; | 44 | import { ServerConfig } from "../../config.ts"; |
| 43 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 44 | import { | 45 | import { |
| 45 | ProviderAdapterProcessError, | 46 | ProviderAdapterProcessError, |
| 46 | ProviderAdapterRequestError, | 47 | ProviderAdapterRequestError, |
| ... | @@ -72,19 +73,37 @@ import { | ... | @@ -72,19 +73,37 @@ import { |
| 72 | extractPlanMarkdown, | 73 | extractPlanMarkdown, |
| 73 | extractTodosAsPlan, | 74 | extractTodosAsPlan, |
| 74 | } from "../acp/CursorAcpExtension.ts"; | 75 | } from "../acp/CursorAcpExtension.ts"; |
| 75 | import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; | 76 | import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; |
| 76 | import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; | 77 | import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; |
| 77 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 78 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 78 | 79 | ||
| 79 | const PROVIDER = "cursor" as const; | 80 | const PROVIDER = ProviderDriverKind.make("cursor"); |
| 80 | const CURSOR_RESUME_VERSION = 1 as const; | 81 | const CURSOR_RESUME_VERSION = 1 as const; |
| 81 | const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; | 82 | const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; |
| 82 | const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; | 83 | const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; |
| 83 | const ACP_APPROVAL_MODE_ALIASES = ["ask"]; | 84 | const ACP_APPROVAL_MODE_ALIASES = ["ask"]; |
| 84 | 85 | ||
| 85 | export interface CursorAdapterLiveOptions { | 86 | export interface CursorAdapterLiveOptions { |
| 87 | readonly environment?: NodeJS.ProcessEnv; | ||
| 86 | readonly nativeEventLogPath?: string; | 88 | readonly nativeEventLogPath?: string; |
| 87 | readonly nativeEventLogger?: EventNdjsonLogger; | 89 | readonly nativeEventLogger?: EventNdjsonLogger; |
| 90 | /** | ||
| 91 | * Selections are honored when `modelSelection.instanceId` matches this value. | ||
| 92 | * Defaults to the legacy built-in instance id (`cursor`). | ||
| 93 | */ | ||
| 94 | readonly instanceId?: typeof ProviderInstanceId.Type; | ||
| 95 | /** | ||
| 96 | * Optional per-session settings resolver. When provided the adapter yields | ||
| 97 | * this effect at the start of every session and uses the result instead of | ||
| 98 | * the `cursorSettings` captured at construction. | ||
| 99 | * | ||
| 100 | * Production instances bind settings to the instance scope (the hydration | ||
| 101 | * layer rebuilds the adapter on config change) and leave this undefined. | ||
| 102 | * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to | ||
| 103 | * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads | ||
| 104 | * the latest snapshot so the closure isn't stale. | ||
| 105 | */ | ||
| 106 | readonly resolveSettings?: Effect.Effect<CursorSettings>; | ||
| 88 | } | 107 | } |
| 89 | 108 | ||
| 90 | interface PendingApproval { | 109 | interface PendingApproval { |
| ... | @@ -280,12 +299,15 @@ function selectAutoApprovedPermissionOption( | ... | @@ -280,12 +299,15 @@ function selectAutoApprovedPermissionOption( |
| 280 | return undefined; | 299 | return undefined; |
| 281 | } | 300 | } |
| 282 | 301 | ||
| 283 | function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | 302 | export function makeCursorAdapter( |
| 303 | cursorSettings: CursorSettings, | ||
| 304 | options?: CursorAdapterLiveOptions, | ||
| 305 | ) { | ||
| 284 | return Effect.gen(function* () { | 306 | return Effect.gen(function* () { |
| 307 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); | ||
| 285 | const fileSystem = yield* FileSystem.FileSystem; | 308 | const fileSystem = yield* FileSystem.FileSystem; |
| 286 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 309 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 287 | const serverConfig = yield* Effect.service(ServerConfig); | 310 | const serverConfig = yield* Effect.service(ServerConfig); |
| 288 | const serverSettingsService = yield* ServerSettingsService; | ||
| 289 | const nativeEventLogger = | 311 | const nativeEventLogger = |
| 290 | options?.nativeEventLogger ?? | 312 | options?.nativeEventLogger ?? |
| 291 | (options?.nativeEventLogPath !== undefined | 313 | (options?.nativeEventLogPath !== undefined |
| ... | @@ -440,25 +462,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | ... | @@ -440,25 +462,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 440 | 462 | ||
| 441 | const cwd = nodePath.resolve(input.cwd.trim()); | 463 | const cwd = nodePath.resolve(input.cwd.trim()); |
| 442 | const cursorModelSelection = | 464 | const cursorModelSelection = |
| 443 | input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; | 465 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; |
| 444 | const existing = sessions.get(input.threadId); | 466 | const existing = sessions.get(input.threadId); |
| 445 | if (existing && !existing.stopped) { | 467 | if (existing && !existing.stopped) { |
| 446 | yield* stopSessionInternal(existing); | 468 | yield* stopSessionInternal(existing); |
| 447 | } | 469 | } |
| 448 | 470 | ||
| 449 | const cursorSettings = yield* serverSettingsService.getSettings.pipe( | ||
| 450 | Effect.map((settings) => settings.providers.cursor), | ||
| 451 | Effect.mapError( | ||
| 452 | (error) => | ||
| 453 | new ProviderAdapterProcessError({ | ||
| 454 | provider: PROVIDER, | ||
| 455 | threadId: input.threadId, | ||
| 456 | detail: error.message, | ||
| 457 | cause: error, | ||
| 458 | }), | ||
| 459 | ), | ||
| 460 | ); | ||
| 461 | |||
| 462 | const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>(); | 471 | const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>(); |
| 463 | const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>(); | 472 | const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>(); |
| 464 | const sessionScope = yield* Scope.make("sequential"); | 473 | const sessionScope = yield* Scope.make("sequential"); |
| ... | @@ -475,8 +484,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | ... | @@ -475,8 +484,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 475 | threadId: input.threadId, | 484 | threadId: input.threadId, |
| 476 | }); | 485 | }); |
| 477 | 486 | ||
| 487 | // Resolve the CursorSettings used to spawn the ACP child. Production | ||
| 488 | // leaves `options.resolveSettings` undefined so we use the value | ||
| 489 | // captured at adapter construction — per-instance isolation is | ||
| 490 | // enforced by the hydration layer rebuilding this adapter whenever | ||
| 491 | // its config changes. Tests set `resolveSettings` to pull the latest | ||
| 492 | // snapshot from `ServerSettingsService` so that mid-suite | ||
| 493 | // `updateSettings({ providers: { cursor: { binaryPath } } })` calls | ||
| 494 | // actually take effect when the next session spawns. | ||
| 495 | const effectiveCursorSettings = options?.resolveSettings | ||
| 496 | ? yield* options.resolveSettings | ||
| 497 | : cursorSettings; | ||
| 498 | |||
| 478 | const acp = yield* makeCursorAcpRuntime({ | 499 | const acp = yield* makeCursorAcpRuntime({ |
| 479 | cursorSettings, | 500 | cursorSettings: effectiveCursorSettings, |
| 501 | ...(options?.environment ? { environment: options.environment } : {}), | ||
| 480 | childProcessSpawner, | 502 | childProcessSpawner, |
| 481 | cwd, | 503 | cwd, |
| 482 | ...(resumeSessionId ? { resumeSessionId } : {}), | 504 | ...(resumeSessionId ? { resumeSessionId } : {}), |
| ... | @@ -666,6 +688,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | ... | @@ -666,6 +688,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 666 | const now = yield* nowIso; | 688 | const now = yield* nowIso; |
| 667 | const session: ProviderSession = { | 689 | const session: ProviderSession = { |
| 668 | provider: PROVIDER, | 690 | provider: PROVIDER, |
| 691 | providerInstanceId: boundInstanceId, | ||
| 669 | status: "ready", | 692 | status: "ready", |
| 670 | runtimeMode: input.runtimeMode, | 693 | runtimeMode: input.runtimeMode, |
| 671 | cwd, | 694 | cwd, |
| ... | @@ -815,7 +838,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | ... | @@ -815,7 +838,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 815 | const ctx = yield* requireSession(input.threadId); | 838 | const ctx = yield* requireSession(input.threadId); |
| 816 | const turnId = TurnId.make(crypto.randomUUID()); | 839 | const turnId = TurnId.make(crypto.randomUUID()); |
| 817 | const turnModelSelection = | 840 | const turnModelSelection = |
| 818 | input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; | 841 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; |
| 819 | const model = turnModelSelection?.model ?? ctx.session.model; | 842 | const model = turnModelSelection?.model ?? ctx.session.model; |
| 820 | const resolvedModel = resolveCursorAcpBaseModelId(model); | 843 | const resolvedModel = resolveCursorAcpBaseModelId(model); |
| 821 | yield* applyRequestedSessionConfiguration({ | 844 | yield* applyRequestedSessionConfiguration({ |
| ... | @@ -1049,9 +1072,3 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | ... | @@ -1049,9 +1072,3 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1049 | } satisfies CursorAdapterShape; | 1072 | } satisfies CursorAdapterShape; |
| 1050 | }); | 1073 | }); |
| 1051 | } | 1074 | } |
| 1052 | |||
| 1053 | export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); | ||
| 1054 | |||
| 1055 | export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) { | ||
| 1056 | return Layer.effect(CursorAdapter, makeCursorAdapter(opts)); | ||
| 1057 | } |
apps/server/src/provider/Layers/CursorProvider.test.ts+127-32| ... | @@ -1,10 +1,7 @@ | ... | @@ -1,10 +1,7 @@ |
| 1 | import * as path from "node:path"; | 1 | import * as NodeOS from "node:os"; |
| 2 | import * as os from "node:os"; | ||
| 3 | import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; | ||
| 4 | import { fileURLToPath } from "node:url"; | ||
| 5 | 2 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { Effect } from "effect"; | 4 | import { Effect, FileSystem, Path } from "effect"; |
| 8 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "vitest"; |
| 9 | import type * as EffectAcpSchema from "effect-acp/schema"; | 6 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; | 7 | import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; |
| ... | @@ -14,6 +11,7 @@ import { | ... | @@ -14,6 +11,7 @@ import { |
| 14 | buildCursorProviderSnapshot, | 11 | buildCursorProviderSnapshot, |
| 15 | buildCursorCapabilitiesFromConfigOptions, | 12 | buildCursorCapabilitiesFromConfigOptions, |
| 16 | buildCursorDiscoveredModelsFromConfigOptions, | 13 | buildCursorDiscoveredModelsFromConfigOptions, |
| 14 | checkCursorProviderStatus, | ||
| 17 | discoverCursorModelCapabilitiesViaAcp, | 15 | discoverCursorModelCapabilitiesViaAcp, |
| 18 | discoverCursorModelsViaAcp, | 16 | discoverCursorModelsViaAcp, |
| 19 | getCursorFallbackModels, | 17 | getCursorFallbackModels, |
| ... | @@ -25,8 +23,14 @@ import { | ... | @@ -25,8 +23,14 @@ import { |
| 25 | resolveCursorAcpConfigUpdates, | 23 | resolveCursorAcpConfigUpdates, |
| 26 | } from "./CursorProvider.ts"; | 24 | } from "./CursorProvider.ts"; |
| 27 | 25 | ||
| 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 26 | const runNode = <A, E>( |
| 29 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 27 | effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, |
| 28 | ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); | ||
| 29 | |||
| 30 | const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { | ||
| 31 | const path = yield* Path.Path; | ||
| 32 | return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); | ||
| 33 | }); | ||
| 30 | 34 | ||
| 31 | function selectDescriptor( | 35 | function selectDescriptor( |
| 32 | id: string, | 36 | id: string, |
| ... | @@ -53,8 +57,16 @@ function booleanDescriptor(id: string, label: string, currentValue?: boolean) { | ... | @@ -53,8 +57,16 @@ function booleanDescriptor(id: string, label: string, currentValue?: boolean) { |
| 53 | }; | 57 | }; |
| 54 | } | 58 | } |
| 55 | 59 | ||
| 56 | async function makeMockAgentWrapper(extraEnv?: Record<string, string>) { | 60 | const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( |
| 57 | const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); | 61 | extraEnv?: Record<string, string>, |
| 62 | ) { | ||
| 63 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 64 | const path = yield* Path.Path; | ||
| 65 | const mockAgentPath = yield* resolveMockAgentPath(); | ||
| 66 | const dir = yield* fileSystem.makeTempDirectory({ | ||
| 67 | directory: NodeOS.tmpdir(), | ||
| 68 | prefix: "cursor-provider-mock-", | ||
| 69 | }); | ||
| 58 | const wrapperPath = path.join(dir, "fake-agent.sh"); | 70 | const wrapperPath = path.join(dir, "fake-agent.sh"); |
| 59 | const envExports = Object.entries(extraEnv ?? {}) | 71 | const envExports = Object.entries(extraEnv ?? {}) |
| 60 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) | 72 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) |
| ... | @@ -63,23 +75,80 @@ async function makeMockAgentWrapper(extraEnv?: Record<string, string>) { | ... | @@ -63,23 +75,80 @@ async function makeMockAgentWrapper(extraEnv?: Record<string, string>) { |
| 63 | ${envExports} | 75 | ${envExports} |
| 64 | exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" | 76 | exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" |
| 65 | `; | 77 | `; |
| 66 | await writeFile(wrapperPath, script, "utf8"); | 78 | yield* fileSystem.writeFileString(wrapperPath, script); |
| 67 | await chmod(wrapperPath, 0o755); | 79 | yield* fileSystem.chmod(wrapperPath, 0o755); |
| 68 | return wrapperPath; | 80 | return wrapperPath; |
| 69 | } | 81 | }); |
| 82 | |||
| 83 | const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { | ||
| 84 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 85 | const path = yield* Path.Path; | ||
| 86 | const mockAgentPath = yield* resolveMockAgentPath(); | ||
| 87 | const dir = yield* fileSystem.makeTempDirectory({ | ||
| 88 | directory: NodeOS.tmpdir(), | ||
| 89 | prefix: "cursor-provider-about-mock-", | ||
| 90 | }); | ||
| 91 | const wrapperPath = path.join(dir, "fake-agent.sh"); | ||
| 92 | const script = `#!/bin/sh | ||
| 93 | if [ "$1" = "about" ]; then | ||
| 94 | printf 'CLI Version 2026.04.09-f2b0fcd\\n' | ||
| 95 | printf 'User Email cursor@example.com\\n' | ||
| 96 | exit 0 | ||
| 97 | fi | ||
| 98 | exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" | ||
| 99 | `; | ||
| 100 | yield* fileSystem.writeFileString(wrapperPath, script); | ||
| 101 | yield* fileSystem.chmod(wrapperPath, 0o755); | ||
| 102 | return wrapperPath; | ||
| 103 | }); | ||
| 70 | 104 | ||
| 71 | async function waitForFileContent(filePath: string, attempts = 40): Promise<string> { | 105 | const waitForFileContent = Effect.fn("waitForFileContent")(function* ( |
| 106 | filePath: string, | ||
| 107 | attempts = 40, | ||
| 108 | ) { | ||
| 109 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 72 | for (let attempt = 0; attempt < attempts; attempt += 1) { | 110 | for (let attempt = 0; attempt < attempts; attempt += 1) { |
| 73 | try { | 111 | const content = yield* fileSystem |
| 74 | const content = await readFile(filePath, "utf8"); | 112 | .readFileString(filePath) |
| 113 | .pipe(Effect.catch(() => Effect.void)); | ||
| 114 | if (content !== undefined) { | ||
| 75 | if (content.trim().length > 0) { | 115 | if (content.trim().length > 0) { |
| 76 | return content; | 116 | return content; |
| 77 | } | 117 | } |
| 78 | } catch {} | 118 | } |
| 79 | await new Promise((resolve) => setTimeout(resolve, 50)); | 119 | yield* Effect.sleep("50 millis"); |
| 80 | } | 120 | } |
| 81 | throw new Error(`Timed out waiting for file content at ${filePath}`); | 121 | return yield* Effect.fail(new Error(`Timed out waiting for file content at ${filePath}`)); |
| 82 | } | 122 | }); |
| 123 | |||
| 124 | const makeProviderStatusEnvFixture = Effect.fn("makeProviderStatusEnvFixture")(function* () { | ||
| 125 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 126 | const path = yield* Path.Path; | ||
| 127 | const tempDir = yield* fileSystem.makeTempDirectory({ | ||
| 128 | directory: NodeOS.tmpdir(), | ||
| 129 | prefix: "cursor-provider-status-env-", | ||
| 130 | }); | ||
| 131 | return { | ||
| 132 | requestLogPath: path.join(tempDir, "requests.ndjson"), | ||
| 133 | wrapperPath: yield* makeMockAgentWithAboutWrapper(), | ||
| 134 | }; | ||
| 135 | }); | ||
| 136 | |||
| 137 | const makeExitLogFixture = Effect.fn("makeExitLogFixture")(function* (prefix: string) { | ||
| 138 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 139 | const path = yield* Path.Path; | ||
| 140 | const tempDir = yield* fileSystem.makeTempDirectory({ | ||
| 141 | directory: NodeOS.tmpdir(), | ||
| 142 | prefix, | ||
| 143 | }); | ||
| 144 | const exitLogPath = path.join(tempDir, "exit.log"); | ||
| 145 | return { | ||
| 146 | exitLogPath, | ||
| 147 | wrapperPath: yield* makeMockAgentWrapper({ | ||
| 148 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | ||
| 149 | }), | ||
| 150 | }; | ||
| 151 | }); | ||
| 83 | 152 | ||
| 84 | const parameterizedGpt54ConfigOptions = [ | 153 | const parameterizedGpt54ConfigOptions = [ |
| 85 | { | 154 | { |
| ... | @@ -428,9 +497,38 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { | ... | @@ -428,9 +497,38 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { |
| 428 | }); | 497 | }); |
| 429 | }); | 498 | }); |
| 430 | 499 | ||
| 500 | describe("checkCursorProviderStatus", () => { | ||
| 501 | it("passes the injected environment to ACP model discovery", async () => { | ||
| 502 | const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); | ||
| 503 | |||
| 504 | const provider = await Effect.runPromise( | ||
| 505 | checkCursorProviderStatus( | ||
| 506 | { | ||
| 507 | enabled: true, | ||
| 508 | binaryPath: wrapperPath, | ||
| 509 | apiEndpoint: "", | ||
| 510 | customModels: [], | ||
| 511 | }, | ||
| 512 | { | ||
| 513 | ...process.env, | ||
| 514 | T3_ACP_REQUEST_LOG_PATH: requestLogPath, | ||
| 515 | }, | ||
| 516 | ).pipe(Effect.provide(NodeServices.layer)), | ||
| 517 | ); | ||
| 518 | |||
| 519 | expect(provider.models.map((model) => model.slug)).toEqual([ | ||
| 520 | "default", | ||
| 521 | "composer-2", | ||
| 522 | "gpt-5.4", | ||
| 523 | "claude-opus-4-6", | ||
| 524 | ]); | ||
| 525 | await expect(runNode(waitForFileContent(requestLogPath))).resolves.toContain("initialize"); | ||
| 526 | }); | ||
| 527 | }); | ||
| 528 | |||
| 431 | describe("discoverCursorModelsViaAcp", () => { | 529 | describe("discoverCursorModelsViaAcp", () => { |
| 432 | it("keeps the ACP probe runtime alive long enough to discover models", async () => { | 530 | it("keeps the ACP probe runtime alive long enough to discover models", async () => { |
| 433 | const wrapperPath = await makeMockAgentWrapper(); | 531 | const wrapperPath = await runNode(makeMockAgentWrapper()); |
| 434 | 532 | ||
| 435 | const models = await Effect.runPromise( | 533 | const models = await Effect.runPromise( |
| 436 | discoverCursorModelsViaAcp({ | 534 | discoverCursorModelsViaAcp({ |
| ... | @@ -450,11 +548,9 @@ describe("discoverCursorModelsViaAcp", () => { | ... | @@ -450,11 +548,9 @@ describe("discoverCursorModelsViaAcp", () => { |
| 450 | }); | 548 | }); |
| 451 | 549 | ||
| 452 | it("closes the ACP probe runtime after discovery completes", async () => { | 550 | it("closes the ACP probe runtime after discovery completes", async () => { |
| 453 | const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-")); | 551 | const { exitLogPath, wrapperPath } = await runNode( |
| 454 | const exitLogPath = path.join(tempDir, "exit.log"); | 552 | makeExitLogFixture("cursor-provider-exit-log-"), |
| 455 | const wrapperPath = await makeMockAgentWrapper({ | 553 | ); |
| 456 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | ||
| 457 | }); | ||
| 458 | 554 | ||
| 459 | await Effect.runPromise( | 555 | await Effect.runPromise( |
| 460 | discoverCursorModelsViaAcp({ | 556 | discoverCursorModelsViaAcp({ |
| ... | @@ -465,18 +561,16 @@ describe("discoverCursorModelsViaAcp", () => { | ... | @@ -465,18 +561,16 @@ describe("discoverCursorModelsViaAcp", () => { |
| 465 | }).pipe(Effect.provide(NodeServices.layer)), | 561 | }).pipe(Effect.provide(NodeServices.layer)), |
| 466 | ); | 562 | ); |
| 467 | 563 | ||
| 468 | const exitLog = await waitForFileContent(exitLogPath); | 564 | const exitLog = await runNode(waitForFileContent(exitLogPath)); |
| 469 | expect(exitLog).toContain("SIGTERM"); | 565 | expect(exitLog).toContain("SIGTERM"); |
| 470 | }); | 566 | }); |
| 471 | }); | 567 | }); |
| 472 | 568 | ||
| 473 | describe("discoverCursorModelCapabilitiesViaAcp", () => { | 569 | describe("discoverCursorModelCapabilitiesViaAcp", () => { |
| 474 | it("closes all ACP probe runtimes after capability enrichment completes", async () => { | 570 | it("closes all ACP probe runtimes after capability enrichment completes", async () => { |
| 475 | const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-")); | 571 | const { exitLogPath, wrapperPath } = await runNode( |
| 476 | const exitLogPath = path.join(tempDir, "exit.log"); | 572 | makeExitLogFixture("cursor-capabilities-exit-log-"), |
| 477 | const wrapperPath = await makeMockAgentWrapper({ | 573 | ); |
| 478 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | ||
| 479 | }); | ||
| 480 | const existingModels: ReadonlyArray<ServerProviderModel> = [ | 574 | const existingModels: ReadonlyArray<ServerProviderModel> = [ |
| 481 | { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, | 575 | { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, |
| 482 | { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, | 576 | { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, |
| ... | @@ -508,7 +602,7 @@ describe("discoverCursorModelCapabilitiesViaAcp", () => { | ... | @@ -508,7 +602,7 @@ describe("discoverCursorModelCapabilitiesViaAcp", () => { |
| 508 | "claude-opus-4-6", | 602 | "claude-opus-4-6", |
| 509 | ]); | 603 | ]); |
| 510 | 604 | ||
| 511 | const exitLog = await waitForFileContent(exitLogPath); | 605 | const exitLog = await runNode(waitForFileContent(exitLogPath)); |
| 512 | expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); | 606 | expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); |
| 513 | }); | 607 | }); |
| 514 | }); | 608 | }); |
| ... | @@ -530,6 +624,7 @@ describe("parseCursorAboutOutput", () => { | ... | @@ -530,6 +624,7 @@ describe("parseCursorAboutOutput", () => { |
| 530 | status: "ready", | 624 | status: "ready", |
| 531 | auth: { | 625 | auth: { |
| 532 | status: "authenticated", | 626 | status: "authenticated", |
| 627 | email: "jmarminge@gmail.com", | ||
| 533 | type: "Team", | 628 | type: "Team", |
| 534 | label: "Cursor Team Subscription", | 629 | label: "Cursor Team Subscription", |
| 535 | }, | 630 | }, |
apps/server/src/provider/Layers/CursorProvider.ts+352-313| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | import * as nodeFs from "node:fs"; | ||
| 2 | import * as nodeOs from "node:os"; | 1 | import * as nodeOs from "node:os"; |
| 3 | import * as nodePath from "node:path"; | ||
| 4 | 2 | ||
| 5 | import type { | 3 | import type { |
| 6 | CursorSettings, | 4 | CursorSettings, |
| ... | @@ -10,10 +8,10 @@ import type { | ... | @@ -10,10 +8,10 @@ import type { |
| 10 | ServerProviderAuth, | 8 | ServerProviderAuth, |
| 11 | ServerProviderModel, | 9 | ServerProviderModel, |
| 12 | ServerProviderState, | 10 | ServerProviderState, |
| 13 | ServerSettingsError, | ||
| 14 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 15 | import type * as EffectAcpSchema from "effect-acp/schema"; | 13 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 16 | import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; | 14 | import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect"; |
| 17 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 15 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | import { | 16 | import { |
| 19 | createModelCapabilities, | 17 | createModelCapabilities, |
| ... | @@ -29,13 +27,11 @@ import { | ... | @@ -29,13 +27,11 @@ import { |
| 29 | isCommandMissingCause, | 27 | isCommandMissingCause, |
| 30 | providerModelsFromSettings, | 28 | providerModelsFromSettings, |
| 31 | type CommandResult, | 29 | type CommandResult, |
| 30 | type ServerProviderDraft, | ||
| 32 | } from "../providerSnapshot.ts"; | 31 | } from "../providerSnapshot.ts"; |
| 33 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 34 | import { CursorProvider } from "../Services/CursorProvider.ts"; | ||
| 35 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; | 32 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; |
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 37 | 33 | ||
| 38 | const PROVIDER = "cursor" as const; | 34 | const PROVIDER = ProviderDriverKind.make("cursor"); |
| 39 | const CURSOR_PRESENTATION = { | 35 | const CURSOR_PRESENTATION = { |
| 40 | displayName: "Cursor", | 36 | displayName: "Cursor", |
| 41 | badgeLabel: "Early Access", | 37 | badgeLabel: "Early Access", |
| ... | @@ -48,7 +44,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ... | @@ -48,7 +44,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 48 | const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; | 44 | const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; |
| 49 | const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; | 45 | const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; |
| 50 | const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; | 46 | const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; |
| 51 | const CURSOR_REFRESH_INTERVAL = "1 hour"; | ||
| 52 | const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; | 47 | const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; |
| 53 | export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { | 48 | export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { |
| 54 | _meta: { | 49 | _meta: { |
| ... | @@ -56,13 +51,14 @@ export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { | ... | @@ -56,13 +51,14 @@ export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { |
| 56 | }, | 51 | }, |
| 57 | } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>; | 52 | } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>; |
| 58 | 53 | ||
| 59 | function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider { | 54 | export function buildInitialCursorProviderSnapshot( |
| 55 | cursorSettings: CursorSettings, | ||
| 56 | ): ServerProviderDraft { | ||
| 60 | const checkedAt = new Date().toISOString(); | 57 | const checkedAt = new Date().toISOString(); |
| 61 | const models = getCursorFallbackModels(cursorSettings); | 58 | const models = getCursorFallbackModels(cursorSettings); |
| 62 | 59 | ||
| 63 | if (!cursorSettings.enabled) { | 60 | if (!cursorSettings.enabled) { |
| 64 | return buildServerProvider({ | 61 | return buildServerProvider({ |
| 65 | provider: PROVIDER, | ||
| 66 | presentation: CURSOR_PRESENTATION, | 62 | presentation: CURSOR_PRESENTATION, |
| 67 | enabled: false, | 63 | enabled: false, |
| 68 | checkedAt, | 64 | checkedAt, |
| ... | @@ -78,7 +74,6 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser | ... | @@ -78,7 +74,6 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser |
| 78 | } | 74 | } |
| 79 | 75 | ||
| 80 | return buildServerProvider({ | 76 | return buildServerProvider({ |
| 81 | provider: PROVIDER, | ||
| 82 | presentation: CURSOR_PRESENTATION, | 77 | presentation: CURSOR_PRESENTATION, |
| 83 | enabled: true, | 78 | enabled: true, |
| 84 | checkedAt, | 79 | checkedAt, |
| ... | @@ -112,7 +107,12 @@ function flattenSessionConfigSelectOptions( | ... | @@ -112,7 +107,12 @@ function flattenSessionConfigSelectOptions( |
| 112 | } | 107 | } |
| 113 | return configOption.options.flatMap((entry) => | 108 | return configOption.options.flatMap((entry) => |
| 114 | "value" in entry | 109 | "value" in entry |
| 115 | ? [{ value: entry.value.trim(), name: entry.name.trim() } satisfies CursorSessionSelectOption] | 110 | ? [ |
| 111 | { | ||
| 112 | value: entry.value.trim(), | ||
| 113 | name: entry.name.trim(), | ||
| 114 | } satisfies CursorSessionSelectOption, | ||
| 115 | ] | ||
| 116 | : entry.options.map( | 116 | : entry.options.map( |
| 117 | (option) => | 117 | (option) => |
| 118 | ({ | 118 | ({ |
| ... | @@ -392,7 +392,10 @@ export function buildCursorDiscoveredModelsFromConfigOptions( | ... | @@ -392,7 +392,10 @@ export function buildCursorDiscoveredModelsFromConfigOptions( |
| 392 | ); | 392 | ); |
| 393 | } | 393 | } |
| 394 | 394 | ||
| 395 | const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => | 395 | const makeCursorAcpProbeRuntime = ( |
| 396 | cursorSettings: CursorSettings, | ||
| 397 | environment: NodeJS.ProcessEnv = process.env, | ||
| 398 | ) => | ||
| 396 | Effect.gen(function* () { | 399 | Effect.gen(function* () { |
| 397 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 400 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 398 | const acpContext = yield* Layer.build( | 401 | const acpContext = yield* Layer.build( |
| ... | @@ -404,6 +407,7 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => | ... | @@ -404,6 +407,7 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => |
| 404 | "acp", | 407 | "acp", |
| 405 | ], | 408 | ], |
| 406 | cwd: process.cwd(), | 409 | cwd: process.cwd(), |
| 410 | env: environment, | ||
| 407 | }, | 411 | }, |
| 408 | cwd: process.cwd(), | 412 | cwd: process.cwd(), |
| 409 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, | 413 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, |
| ... | @@ -417,7 +421,12 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => | ... | @@ -417,7 +421,12 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => |
| 417 | const withCursorAcpProbeRuntime = <A, E, R>( | 421 | const withCursorAcpProbeRuntime = <A, E, R>( |
| 418 | cursorSettings: CursorSettings, | 422 | cursorSettings: CursorSettings, |
| 419 | useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>, | 423 | useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>, |
| 420 | ) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped); | 424 | environment: NodeJS.ProcessEnv = process.env, |
| 425 | ) => | ||
| 426 | makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( | ||
| 427 | Effect.flatMap(useRuntime), | ||
| 428 | Effect.scoped, | ||
| 429 | ); | ||
| 421 | 430 | ||
| 422 | function normalizeCursorConfigOptionToken(value: string | null | undefined): string { | 431 | function normalizeCursorConfigOptionToken(value: string | null | undefined): string { |
| 423 | return ( | 432 | return ( |
| ... | @@ -460,12 +469,18 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s | ... | @@ -460,12 +469,18 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s |
| 460 | export function resolveCursorAcpConfigUpdates( | 469 | export function resolveCursorAcpConfigUpdates( |
| 461 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | 470 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, |
| 462 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, | 471 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, |
| 463 | ): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { | 472 | ): ReadonlyArray<{ |
| 473 | readonly configId: string; | ||
| 474 | readonly value: string | boolean; | ||
| 475 | }> { | ||
| 464 | if (!configOptions || configOptions.length === 0) { | 476 | if (!configOptions || configOptions.length === 0) { |
| 465 | return []; | 477 | return []; |
| 466 | } | 478 | } |
| 467 | 479 | ||
| 468 | const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; | 480 | const updates: Array<{ |
| 481 | readonly configId: string; | ||
| 482 | readonly value: string | boolean; | ||
| 483 | }> = []; | ||
| 469 | 484 | ||
| 470 | const reasoningOption = findCursorEffortConfigOption(configOptions); | 485 | const reasoningOption = findCursorEffortConfigOption(configOptions); |
| 471 | const requestedReasoning = normalizeCursorReasoningValue( | 486 | const requestedReasoning = normalizeCursorReasoningValue( |
| ... | @@ -525,43 +540,129 @@ export function resolveCursorAcpConfigUpdates( | ... | @@ -525,43 +540,129 @@ export function resolveCursorAcpConfigUpdates( |
| 525 | return updates; | 540 | return updates; |
| 526 | } | 541 | } |
| 527 | 542 | ||
| 528 | export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => | 543 | export const discoverCursorModelsViaAcp = ( |
| 529 | withCursorAcpProbeRuntime(cursorSettings, (acp) => | 544 | cursorSettings: CursorSettings, |
| 530 | Effect.map(acp.start(), (started) => | 545 | environment: NodeJS.ProcessEnv = process.env, |
| 531 | buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []), | 546 | ) => |
| 532 | ), | 547 | withCursorAcpProbeRuntime( |
| 548 | cursorSettings, | ||
| 549 | (acp) => | ||
| 550 | Effect.map(acp.start(), (started) => | ||
| 551 | buildCursorDiscoveredModelsFromConfigOptions( | ||
| 552 | started.sessionSetupResult.configOptions ?? [], | ||
| 553 | ), | ||
| 554 | ), | ||
| 555 | environment, | ||
| 533 | ); | 556 | ); |
| 534 | 557 | ||
| 535 | export const discoverCursorModelCapabilitiesViaAcp = ( | 558 | export const discoverCursorModelCapabilitiesViaAcp = ( |
| 536 | cursorSettings: CursorSettings, | 559 | cursorSettings: CursorSettings, |
| 537 | existingModels: ReadonlyArray<ServerProviderModel>, | 560 | existingModels: ReadonlyArray<ServerProviderModel>, |
| 561 | environment: NodeJS.ProcessEnv = process.env, | ||
| 538 | ) => | 562 | ) => |
| 539 | withCursorAcpProbeRuntime(cursorSettings, (acp) => | 563 | withCursorAcpProbeRuntime( |
| 540 | Effect.gen(function* () { | 564 | cursorSettings, |
| 541 | const started = yield* acp.start(); | 565 | (acp) => |
| 542 | const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; | 566 | Effect.gen(function* () { |
| 543 | const modelOption = findCursorModelConfigOption(initialConfigOptions); | 567 | const started = yield* acp.start(); |
| 544 | const modelChoices = flattenSessionConfigSelectOptions(modelOption); | 568 | const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; |
| 545 | if (!modelOption || modelChoices.length === 0) { | 569 | const modelOption = findCursorModelConfigOption(initialConfigOptions); |
| 546 | return []; | 570 | const modelChoices = flattenSessionConfigSelectOptions(modelOption); |
| 547 | } | 571 | if (!modelOption || modelChoices.length === 0) { |
| 572 | return []; | ||
| 573 | } | ||
| 574 | |||
| 575 | const currentModelValue = | ||
| 576 | modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; | ||
| 577 | const capabilitiesBySlug = new Map<string, ModelCapabilities>(); | ||
| 578 | if (currentModelValue) { | ||
| 579 | capabilitiesBySlug.set( | ||
| 580 | currentModelValue, | ||
| 581 | buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), | ||
| 582 | ); | ||
| 583 | } | ||
| 548 | 584 | ||
| 549 | const currentModelValue = | 585 | const targetModelSlugs = new Set( |
| 550 | modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; | 586 | existingModels |
| 551 | const capabilitiesBySlug = new Map<string, ModelCapabilities>(); | 587 | .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) |
| 552 | if (currentModelValue) { | 588 | .map((model) => model.slug), |
| 553 | capabilitiesBySlug.set( | ||
| 554 | currentModelValue, | ||
| 555 | buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), | ||
| 556 | ); | 589 | ); |
| 557 | } | 590 | if (targetModelSlugs.size === 0) { |
| 591 | return buildCursorDiscoveredModels( | ||
| 592 | modelChoices.map((modelChoice) => ({ | ||
| 593 | slug: modelChoice.value.trim(), | ||
| 594 | name: modelChoice.name.trim(), | ||
| 595 | capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, | ||
| 596 | })), | ||
| 597 | ); | ||
| 598 | } | ||
| 599 | |||
| 600 | const probedCapabilities = yield* Effect.forEach( | ||
| 601 | modelChoices, | ||
| 602 | (modelChoice) => { | ||
| 603 | const modelSlug = modelChoice.value.trim(); | ||
| 604 | if ( | ||
| 605 | !modelSlug || | ||
| 606 | !targetModelSlugs.has(modelSlug) || | ||
| 607 | capabilitiesBySlug.has(modelSlug) | ||
| 608 | ) { | ||
| 609 | return Effect.void.pipe( | ||
| 610 | Effect.as<readonly [string, ModelCapabilities] | undefined>(undefined), | ||
| 611 | ); | ||
| 612 | } | ||
| 613 | |||
| 614 | return withCursorAcpProbeRuntime( | ||
| 615 | cursorSettings, | ||
| 616 | (probeAcp) => | ||
| 617 | Effect.gen(function* () { | ||
| 618 | const probeStarted = yield* probeAcp.start(); | ||
| 619 | const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; | ||
| 620 | const probeModelOption = findCursorModelConfigOption(probeConfigOptions); | ||
| 621 | const probeCurrentModelValue = | ||
| 622 | probeModelOption?.type === "select" | ||
| 623 | ? probeModelOption.currentValue?.trim() || undefined | ||
| 624 | : undefined; | ||
| 625 | yield* Effect.annotateCurrentSpan({ | ||
| 626 | "cursor.acp.model.value": modelSlug, | ||
| 627 | "cursor.acp.model.currentValue": probeCurrentModelValue, | ||
| 628 | "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, | ||
| 629 | }); | ||
| 630 | const nextConfigOptions = | ||
| 631 | probeCurrentModelValue === modelSlug | ||
| 632 | ? probeConfigOptions | ||
| 633 | : yield* probeAcp | ||
| 634 | .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) | ||
| 635 | .pipe( | ||
| 636 | Effect.map((response) => response.configOptions ?? probeConfigOptions), | ||
| 637 | ); | ||
| 638 | return [ | ||
| 639 | modelSlug, | ||
| 640 | buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), | ||
| 641 | ] as const; | ||
| 642 | }), | ||
| 643 | environment, | ||
| 644 | ).pipe( | ||
| 645 | Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), | ||
| 646 | Effect.retry({ times: 3 }), | ||
| 647 | Effect.withSpan("cursor-acp-model-capability-probe"), | ||
| 648 | Effect.catchCause((cause) => | ||
| 649 | Effect.logWarning("Cursor ACP capability probe failed", { | ||
| 650 | modelSlug, | ||
| 651 | cause: Cause.pretty(cause), | ||
| 652 | }), | ||
| 653 | ), | ||
| 654 | ); | ||
| 655 | }, | ||
| 656 | { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, | ||
| 657 | ); | ||
| 658 | |||
| 659 | for (const entry of probedCapabilities) { | ||
| 660 | if (!entry) { | ||
| 661 | continue; | ||
| 662 | } | ||
| 663 | capabilitiesBySlug.set(entry[0], entry[1]); | ||
| 664 | } | ||
| 558 | 665 | ||
| 559 | const targetModelSlugs = new Set( | ||
| 560 | existingModels | ||
| 561 | .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) | ||
| 562 | .map((model) => model.slug), | ||
| 563 | ); | ||
| 564 | if (targetModelSlugs.size === 0) { | ||
| 565 | return buildCursorDiscoveredModels( | 666 | return buildCursorDiscoveredModels( |
| 566 | modelChoices.map((modelChoice) => ({ | 667 | modelChoices.map((modelChoice) => ({ |
| 567 | slug: modelChoice.value.trim(), | 668 | slug: modelChoice.value.trim(), |
| ... | @@ -569,73 +670,8 @@ export const discoverCursorModelCapabilitiesViaAcp = ( | ... | @@ -569,73 +670,8 @@ export const discoverCursorModelCapabilitiesViaAcp = ( |
| 569 | capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, | 670 | capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, |
| 570 | })), | 671 | })), |
| 571 | ); | 672 | ); |
| 572 | } | 673 | }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), |
| 573 | 674 | environment, | |
| 574 | const probedCapabilities = yield* Effect.forEach( | ||
| 575 | modelChoices, | ||
| 576 | (modelChoice) => { | ||
| 577 | const modelSlug = modelChoice.value.trim(); | ||
| 578 | if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) { | ||
| 579 | return Effect.void.pipe( | ||
| 580 | Effect.as<readonly [string, ModelCapabilities] | undefined>(undefined), | ||
| 581 | ); | ||
| 582 | } | ||
| 583 | |||
| 584 | return withCursorAcpProbeRuntime(cursorSettings, (probeAcp) => | ||
| 585 | Effect.gen(function* () { | ||
| 586 | const probeStarted = yield* probeAcp.start(); | ||
| 587 | const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; | ||
| 588 | const probeModelOption = findCursorModelConfigOption(probeConfigOptions); | ||
| 589 | const probeCurrentModelValue = | ||
| 590 | probeModelOption?.type === "select" | ||
| 591 | ? probeModelOption.currentValue?.trim() || undefined | ||
| 592 | : undefined; | ||
| 593 | yield* Effect.annotateCurrentSpan({ | ||
| 594 | "cursor.acp.model.value": modelSlug, | ||
| 595 | "cursor.acp.model.currentValue": probeCurrentModelValue, | ||
| 596 | "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, | ||
| 597 | }); | ||
| 598 | const nextConfigOptions = | ||
| 599 | probeCurrentModelValue === modelSlug | ||
| 600 | ? probeConfigOptions | ||
| 601 | : yield* probeAcp | ||
| 602 | .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) | ||
| 603 | .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions)); | ||
| 604 | return [ | ||
| 605 | modelSlug, | ||
| 606 | buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), | ||
| 607 | ] as const; | ||
| 608 | }), | ||
| 609 | ).pipe( | ||
| 610 | Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), | ||
| 611 | Effect.retry({ times: 3 }), | ||
| 612 | Effect.withSpan("cursor-acp-model-capability-probe"), | ||
| 613 | Effect.catchCause((cause) => | ||
| 614 | Effect.logWarning("Cursor ACP capability probe failed", { | ||
| 615 | modelSlug, | ||
| 616 | cause: Cause.pretty(cause), | ||
| 617 | }), | ||
| 618 | ), | ||
| 619 | ); | ||
| 620 | }, | ||
| 621 | { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, | ||
| 622 | ); | ||
| 623 | |||
| 624 | for (const entry of probedCapabilities) { | ||
| 625 | if (!entry) { | ||
| 626 | continue; | ||
| 627 | } | ||
| 628 | capabilitiesBySlug.set(entry[0], entry[1]); | ||
| 629 | } | ||
| 630 | |||
| 631 | return buildCursorDiscoveredModels( | ||
| 632 | modelChoices.map((modelChoice) => ({ | ||
| 633 | slug: modelChoice.value.trim(), | ||
| 634 | name: modelChoice.name.trim(), | ||
| 635 | capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, | ||
| 636 | })), | ||
| 637 | ); | ||
| 638 | }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), | ||
| 639 | ); | 675 | ); |
| 640 | 676 | ||
| 641 | export function getCursorFallbackModels( | 677 | export function getCursorFallbackModels( |
| ... | @@ -683,10 +719,9 @@ export function buildCursorProviderSnapshot(input: { | ... | @@ -683,10 +719,9 @@ export function buildCursorProviderSnapshot(input: { |
| 683 | readonly parsed: CursorAboutResult; | 719 | readonly parsed: CursorAboutResult; |
| 684 | readonly discoveredModels?: ReadonlyArray<ServerProviderModel>; | 720 | readonly discoveredModels?: ReadonlyArray<ServerProviderModel>; |
| 685 | readonly discoveryWarning?: string; | 721 | readonly discoveryWarning?: string; |
| 686 | }): ServerProvider { | 722 | }): ServerProviderDraft { |
| 687 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); | 723 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); |
| 688 | return buildServerProvider({ | 724 | return buildServerProvider({ |
| 689 | provider: PROVIDER, | ||
| 690 | presentation: CURSOR_PRESENTATION, | 725 | presentation: CURSOR_PRESENTATION, |
| 691 | enabled: input.cursorSettings.enabled, | 726 | enabled: input.cursorSettings.enabled, |
| 692 | checkedAt: input.checkedAt, | 727 | checkedAt: input.checkedAt, |
| ... | @@ -811,14 +846,13 @@ function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { | ... | @@ -811,14 +846,13 @@ function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { |
| 811 | ); | 846 | ); |
| 812 | } | 847 | } |
| 813 | 848 | ||
| 814 | function readCursorCliConfigChannel(): string | undefined { | 849 | const readCursorCliConfigChannel = Effect.fn("readCursorCliConfigChannel")(function* () { |
| 815 | try { | 850 | const fileSystem = yield* FileSystem.FileSystem; |
| 816 | const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json"); | 851 | const path = yield* Path.Path; |
| 817 | return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8")); | 852 | const configPath = path.join(nodeOs.homedir(), ".cursor", "cli-config.json"); |
| 818 | } catch { | 853 | const raw = yield* fileSystem.readFileString(configPath).pipe(Effect.orElseSucceed(() => "")); |
| 819 | return undefined; | 854 | return parseCursorCliConfigChannel(raw); |
| 820 | } | 855 | }); |
| 821 | } | ||
| 822 | 856 | ||
| 823 | export function getCursorParameterizedModelPickerUnsupportedMessage(input: { | 857 | export function getCursorParameterizedModelPickerUnsupportedMessage(input: { |
| 824 | readonly version: string | null | undefined; | 858 | readonly version: string | null | undefined; |
| ... | @@ -934,6 +968,7 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult | ... | @@ -934,6 +968,7 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult |
| 934 | status: "ready", | 968 | status: "ready", |
| 935 | auth: { | 969 | auth: { |
| 936 | status: "authenticated", | 970 | status: "authenticated", |
| 971 | email: userEmail, | ||
| 937 | ...authMetadata, | 972 | ...authMetadata, |
| 938 | }, | 973 | }, |
| 939 | }; | 974 | }; |
| ... | @@ -989,17 +1024,22 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult | ... | @@ -989,17 +1024,22 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult |
| 989 | } | 1024 | } |
| 990 | 1025 | ||
| 991 | // Any non-empty email value means authenticated. | 1026 | // Any non-empty email value means authenticated. |
| 992 | return { version, status: "ready", auth: { status: "authenticated" } }; | 1027 | return { |
| 1028 | version, | ||
| 1029 | status: "ready", | ||
| 1030 | auth: { status: "authenticated", email: userEmail }, | ||
| 1031 | }; | ||
| 993 | } | 1032 | } |
| 994 | 1033 | ||
| 995 | const runCursorCommand = (args: ReadonlyArray<string>) => | 1034 | const runCursorCommand = ( |
| 1035 | cursorSettings: CursorSettings, | ||
| 1036 | args: ReadonlyArray<string>, | ||
| 1037 | environment: NodeJS.ProcessEnv = process.env, | ||
| 1038 | ) => | ||
| 996 | Effect.gen(function* () { | 1039 | Effect.gen(function* () { |
| 997 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1040 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 998 | const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( | ||
| 999 | Effect.flatMap((service) => service.getSettings), | ||
| 1000 | Effect.map((settings) => settings.providers.cursor), | ||
| 1001 | ); | ||
| 1002 | const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { | 1041 | const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { |
| 1042 | env: environment, | ||
| 1003 | shell: process.platform === "win32", | 1043 | shell: process.platform === "win32", |
| 1004 | }); | 1044 | }); |
| 1005 | 1045 | ||
| ... | @@ -1016,203 +1056,202 @@ const runCursorCommand = (args: ReadonlyArray<string>) => | ... | @@ -1016,203 +1056,202 @@ const runCursorCommand = (args: ReadonlyArray<string>) => |
| 1016 | return { stdout, stderr, code: exitCode } satisfies CommandResult; | 1056 | return { stdout, stderr, code: exitCode } satisfies CommandResult; |
| 1017 | }).pipe(Effect.scoped); | 1057 | }).pipe(Effect.scoped); |
| 1018 | 1058 | ||
| 1019 | const runCursorAboutCommand = Effect.gen(function* () { | 1059 | const runCursorAboutCommand = ( |
| 1020 | const jsonResult = yield* runCursorCommand(["about", "--format", "json"]); | 1060 | cursorSettings: CursorSettings, |
| 1021 | if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { | 1061 | environment: NodeJS.ProcessEnv = process.env, |
| 1022 | return jsonResult; | 1062 | ) => |
| 1023 | } | 1063 | Effect.gen(function* () { |
| 1024 | return yield* runCursorCommand(["about"]); | 1064 | const jsonResult = yield* runCursorCommand( |
| 1025 | }); | 1065 | cursorSettings, |
| 1026 | 1066 | ["about", "--format", "json"], | |
| 1027 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( | 1067 | environment, |
| 1028 | function* (): Effect.fn.Return< | ||
| 1029 | ServerProvider, | ||
| 1030 | ServerSettingsError, | ||
| 1031 | ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService | ||
| 1032 | > { | ||
| 1033 | const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( | ||
| 1034 | Effect.flatMap((service) => service.getSettings), | ||
| 1035 | Effect.map((settings) => settings.providers.cursor), | ||
| 1036 | ); | 1068 | ); |
| 1037 | const checkedAt = new Date().toISOString(); | 1069 | if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { |
| 1038 | const fallbackModels = getCursorFallbackModels(cursorSettings); | 1070 | return jsonResult; |
| 1039 | |||
| 1040 | if (!cursorSettings.enabled) { | ||
| 1041 | return buildServerProvider({ | ||
| 1042 | provider: PROVIDER, | ||
| 1043 | presentation: CURSOR_PRESENTATION, | ||
| 1044 | enabled: false, | ||
| 1045 | checkedAt, | ||
| 1046 | models: fallbackModels, | ||
| 1047 | probe: { | ||
| 1048 | installed: false, | ||
| 1049 | version: null, | ||
| 1050 | status: "warning", | ||
| 1051 | auth: { status: "unknown" }, | ||
| 1052 | message: "Cursor is disabled in T3 Code settings.", | ||
| 1053 | }, | ||
| 1054 | }); | ||
| 1055 | } | 1071 | } |
| 1072 | return yield* runCursorCommand(cursorSettings, ["about"], environment); | ||
| 1073 | }); | ||
| 1056 | 1074 | ||
| 1057 | // Single `agent about` probe: returns version + auth status in one call. | 1075 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( |
| 1058 | const aboutProbe = yield* runCursorAboutCommand.pipe( | 1076 | cursorSettings: CursorSettings, |
| 1059 | Effect.timeoutOption(ABOUT_TIMEOUT_MS), | 1077 | environment: NodeJS.ProcessEnv = process.env, |
| 1060 | Effect.result, | 1078 | ): Effect.fn.Return< |
| 1061 | ); | 1079 | ServerProviderDraft, |
| 1080 | never, | ||
| 1081 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 1082 | > { | ||
| 1083 | const checkedAt = new Date().toISOString(); | ||
| 1084 | const fallbackModels = getCursorFallbackModels(cursorSettings); | ||
| 1062 | 1085 | ||
| 1063 | if (Result.isFailure(aboutProbe)) { | 1086 | if (!cursorSettings.enabled) { |
| 1064 | const error = aboutProbe.failure; | 1087 | return buildServerProvider({ |
| 1065 | return buildServerProvider({ | 1088 | presentation: CURSOR_PRESENTATION, |
| 1066 | provider: PROVIDER, | 1089 | enabled: false, |
| 1067 | presentation: CURSOR_PRESENTATION, | 1090 | checkedAt, |
| 1068 | enabled: cursorSettings.enabled, | 1091 | models: fallbackModels, |
| 1069 | checkedAt, | 1092 | probe: { |
| 1070 | models: fallbackModels, | 1093 | installed: false, |
| 1071 | probe: { | 1094 | version: null, |
| 1072 | installed: !isCommandMissingCause(error), | 1095 | status: "warning", |
| 1073 | version: null, | 1096 | auth: { status: "unknown" }, |
| 1074 | status: "error", | 1097 | message: "Cursor is disabled in T3 Code settings.", |
| 1075 | auth: { status: "unknown" }, | 1098 | }, |
| 1076 | message: isCommandMissingCause(error) | 1099 | }); |
| 1077 | ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." | 1100 | } |
| 1078 | : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | ||
| 1079 | }, | ||
| 1080 | }); | ||
| 1081 | } | ||
| 1082 | 1101 | ||
| 1083 | if (Option.isNone(aboutProbe.success)) { | 1102 | // Single `agent about` probe: returns version + auth status in one call. |
| 1084 | return buildServerProvider({ | 1103 | const aboutProbe = yield* runCursorAboutCommand(cursorSettings, environment).pipe( |
| 1085 | provider: PROVIDER, | 1104 | Effect.timeoutOption(ABOUT_TIMEOUT_MS), |
| 1086 | presentation: CURSOR_PRESENTATION, | 1105 | Effect.result, |
| 1087 | enabled: cursorSettings.enabled, | 1106 | ); |
| 1088 | checkedAt, | ||
| 1089 | models: fallbackModels, | ||
| 1090 | probe: { | ||
| 1091 | installed: true, | ||
| 1092 | version: null, | ||
| 1093 | status: "error", | ||
| 1094 | auth: { status: "unknown" }, | ||
| 1095 | message: "Cursor Agent CLI is installed but timed out while running `agent about`.", | ||
| 1096 | }, | ||
| 1097 | }); | ||
| 1098 | } | ||
| 1099 | 1107 | ||
| 1100 | const parsed = parseCursorAboutOutput(aboutProbe.success.value); | 1108 | if (Result.isFailure(aboutProbe)) { |
| 1101 | const parameterizedModelPickerUnsupportedMessage = | 1109 | const error = aboutProbe.failure; |
| 1102 | getCursorParameterizedModelPickerUnsupportedMessage({ | 1110 | return buildServerProvider({ |
| 1103 | version: parsed.version, | 1111 | presentation: CURSOR_PRESENTATION, |
| 1104 | channel: readCursorCliConfigChannel(), | 1112 | enabled: cursorSettings.enabled, |
| 1105 | }); | ||
| 1106 | if (parameterizedModelPickerUnsupportedMessage) { | ||
| 1107 | return buildServerProvider({ | ||
| 1108 | provider: PROVIDER, | ||
| 1109 | presentation: CURSOR_PRESENTATION, | ||
| 1110 | enabled: cursorSettings.enabled, | ||
| 1111 | checkedAt, | ||
| 1112 | models: fallbackModels, | ||
| 1113 | probe: { | ||
| 1114 | installed: true, | ||
| 1115 | version: parsed.version, | ||
| 1116 | status: "error", | ||
| 1117 | auth: parsed.auth, | ||
| 1118 | message: | ||
| 1119 | parsed.auth.status === "unauthenticated" && parsed.message | ||
| 1120 | ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` | ||
| 1121 | : parameterizedModelPickerUnsupportedMessage, | ||
| 1122 | }, | ||
| 1123 | }); | ||
| 1124 | } | ||
| 1125 | let discoveredModels = Option.none<ReadonlyArray<ServerProviderModel>>(); | ||
| 1126 | let discoveryWarning: string | undefined; | ||
| 1127 | if (parsed.auth.status !== "unauthenticated") { | ||
| 1128 | const discoveryExit = yield* Effect.exit( | ||
| 1129 | discoverCursorModelsViaAcp(cursorSettings).pipe( | ||
| 1130 | Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), | ||
| 1131 | ), | ||
| 1132 | ); | ||
| 1133 | if (Exit.isFailure(discoveryExit)) { | ||
| 1134 | yield* Effect.logWarning("Cursor ACP model discovery failed", { | ||
| 1135 | cause: Cause.pretty(discoveryExit.cause), | ||
| 1136 | }); | ||
| 1137 | discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; | ||
| 1138 | } else if (Option.isNone(discoveryExit.value)) { | ||
| 1139 | discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; | ||
| 1140 | } else if (discoveryExit.value.value.length === 0) { | ||
| 1141 | discoveryWarning = "Cursor ACP model discovery returned no built-in models."; | ||
| 1142 | } else { | ||
| 1143 | discoveredModels = discoveryExit.value; | ||
| 1144 | } | ||
| 1145 | } | ||
| 1146 | return buildCursorProviderSnapshot({ | ||
| 1147 | checkedAt, | 1113 | checkedAt, |
| 1148 | cursorSettings, | 1114 | models: fallbackModels, |
| 1149 | parsed, | 1115 | probe: { |
| 1150 | discoveredModels: Option.getOrElse( | 1116 | installed: !isCommandMissingCause(error), |
| 1151 | Option.filter(discoveredModels, (models) => models.length > 0), | 1117 | version: null, |
| 1152 | () => [] as const, | 1118 | status: "error", |
| 1153 | ), | 1119 | auth: { status: "unknown" }, |
| 1154 | ...(discoveryWarning ? { discoveryWarning } : {}), | 1120 | message: isCommandMissingCause(error) |
| 1121 | ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." | ||
| 1122 | : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | ||
| 1123 | }, | ||
| 1155 | }); | 1124 | }); |
| 1156 | }, | 1125 | } |
| 1157 | ); | ||
| 1158 | 1126 | ||
| 1159 | export const CursorProviderLive = Layer.effect( | 1127 | if (Option.isNone(aboutProbe.success)) { |
| 1160 | CursorProvider, | 1128 | return buildServerProvider({ |
| 1161 | Effect.gen(function* () { | 1129 | presentation: CURSOR_PRESENTATION, |
| 1162 | const serverSettings = yield* ServerSettingsService; | 1130 | enabled: cursorSettings.enabled, |
| 1163 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1131 | checkedAt, |
| 1132 | models: fallbackModels, | ||
| 1133 | probe: { | ||
| 1134 | installed: true, | ||
| 1135 | version: null, | ||
| 1136 | status: "error", | ||
| 1137 | auth: { status: "unknown" }, | ||
| 1138 | message: "Cursor Agent CLI is installed but timed out while running `agent about`.", | ||
| 1139 | }, | ||
| 1140 | }); | ||
| 1141 | } | ||
| 1164 | 1142 | ||
| 1165 | const checkProvider = checkCursorProviderStatus().pipe( | 1143 | const parsed = parseCursorAboutOutput(aboutProbe.success.value); |
| 1166 | Effect.provideService(ServerSettingsService, serverSettings), | 1144 | const cursorCliConfigChannel = yield* readCursorCliConfigChannel(); |
| 1167 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 1145 | const parameterizedModelPickerUnsupportedMessage = |
| 1146 | getCursorParameterizedModelPickerUnsupportedMessage({ | ||
| 1147 | version: parsed.version, | ||
| 1148 | channel: cursorCliConfigChannel, | ||
| 1149 | }); | ||
| 1150 | if (parameterizedModelPickerUnsupportedMessage) { | ||
| 1151 | return buildServerProvider({ | ||
| 1152 | presentation: CURSOR_PRESENTATION, | ||
| 1153 | enabled: cursorSettings.enabled, | ||
| 1154 | checkedAt, | ||
| 1155 | models: fallbackModels, | ||
| 1156 | probe: { | ||
| 1157 | installed: true, | ||
| 1158 | version: parsed.version, | ||
| 1159 | status: "error", | ||
| 1160 | auth: parsed.auth, | ||
| 1161 | message: | ||
| 1162 | parsed.auth.status === "unauthenticated" && parsed.message | ||
| 1163 | ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` | ||
| 1164 | : parameterizedModelPickerUnsupportedMessage, | ||
| 1165 | }, | ||
| 1166 | }); | ||
| 1167 | } | ||
| 1168 | let discoveredModels = Option.none<ReadonlyArray<ServerProviderModel>>(); | ||
| 1169 | let discoveryWarning: string | undefined; | ||
| 1170 | if (parsed.auth.status !== "unauthenticated") { | ||
| 1171 | const discoveryExit = yield* Effect.exit( | ||
| 1172 | discoverCursorModelsViaAcp(cursorSettings, environment).pipe( | ||
| 1173 | Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), | ||
| 1174 | ), | ||
| 1168 | ); | 1175 | ); |
| 1176 | if (Exit.isFailure(discoveryExit)) { | ||
| 1177 | yield* Effect.logWarning("Cursor ACP model discovery failed", { | ||
| 1178 | cause: Cause.pretty(discoveryExit.cause), | ||
| 1179 | }); | ||
| 1180 | discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; | ||
| 1181 | } else if (Option.isNone(discoveryExit.value)) { | ||
| 1182 | discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; | ||
| 1183 | } else if (discoveryExit.value.value.length === 0) { | ||
| 1184 | discoveryWarning = "Cursor ACP model discovery returned no built-in models."; | ||
| 1185 | } else { | ||
| 1186 | discoveredModels = discoveryExit.value; | ||
| 1187 | } | ||
| 1188 | } | ||
| 1189 | return buildCursorProviderSnapshot({ | ||
| 1190 | checkedAt, | ||
| 1191 | cursorSettings, | ||
| 1192 | parsed, | ||
| 1193 | discoveredModels: Option.getOrElse( | ||
| 1194 | Option.filter(discoveredModels, (models) => models.length > 0), | ||
| 1195 | () => [] as const, | ||
| 1196 | ), | ||
| 1197 | ...(discoveryWarning ? { discoveryWarning } : {}), | ||
| 1198 | }); | ||
| 1199 | }); | ||
| 1169 | 1200 | ||
| 1170 | return yield* makeManagedServerProvider<CursorSettings>({ | 1201 | export function hasUncapturedCursorModels(snapshot: Pick<ServerProvider, "models">): boolean { |
| 1171 | getSettings: serverSettings.getSettings.pipe( | 1202 | return snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)); |
| 1172 | Effect.map((settings) => settings.providers.cursor), | 1203 | } |
| 1173 | Effect.orDie, | ||
| 1174 | ), | ||
| 1175 | streamSettings: serverSettings.streamChanges.pipe( | ||
| 1176 | Stream.map((settings) => settings.providers.cursor), | ||
| 1177 | ), | ||
| 1178 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | ||
| 1179 | initialSnapshot: buildInitialCursorProviderSnapshot, | ||
| 1180 | checkProvider, | ||
| 1181 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => { | ||
| 1182 | if ( | ||
| 1183 | !settings.enabled || | ||
| 1184 | snapshot.auth.status === "unauthenticated" || | ||
| 1185 | !snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)) | ||
| 1186 | ) { | ||
| 1187 | return Effect.void; | ||
| 1188 | } | ||
| 1189 | 1204 | ||
| 1190 | return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe( | 1205 | /** |
| 1191 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 1206 | * Background capability enrichment for a Cursor snapshot. |
| 1192 | Effect.flatMap((discoveredModels) => { | 1207 | * |
| 1193 | if (discoveredModels.length === 0) { | 1208 | * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot` |
| 1194 | return Effect.void; | 1209 | * hook: runs the slow ACP per-model capability probe, and republishes the |
| 1195 | } | 1210 | * snapshot through `publishSnapshot` when new capabilities arrive. Skips |
| 1211 | * the probe when the provider is disabled, unauthenticated, or has no | ||
| 1212 | * uncaptured models. Keeps `EMPTY_CAPABILITIES` and the `PROVIDER` literal | ||
| 1213 | * private to this module. | ||
| 1214 | */ | ||
| 1215 | export const enrichCursorSnapshot = (input: { | ||
| 1216 | readonly settings: CursorSettings; | ||
| 1217 | readonly environment?: NodeJS.ProcessEnv; | ||
| 1218 | readonly snapshot: ServerProvider; | ||
| 1219 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | ||
| 1220 | readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; | ||
| 1221 | }): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => { | ||
| 1222 | const { settings, snapshot, publishSnapshot } = input; | ||
| 1223 | const stampIdentity = input.stampIdentity ?? ((value) => value); | ||
| 1196 | 1224 | ||
| 1197 | return publishSnapshot({ | 1225 | if ( |
| 1198 | ...snapshot, | 1226 | !settings.enabled || |
| 1199 | models: providerModelsFromSettings( | 1227 | snapshot.auth.status === "unauthenticated" || |
| 1200 | discoveredModels, | 1228 | !hasUncapturedCursorModels(snapshot) |
| 1201 | PROVIDER, | 1229 | ) { |
| 1202 | settings.customModels, | 1230 | return Effect.void; |
| 1203 | EMPTY_CAPABILITIES, | 1231 | } |
| 1204 | ), | 1232 | |
| 1205 | }); | 1233 | return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe( |
| 1206 | }), | 1234 | Effect.flatMap((discoveredModels) => { |
| 1207 | Effect.catchCause((cause) => | 1235 | if (discoveredModels.length === 0) { |
| 1208 | Effect.logWarning("Cursor ACP background capability enrichment failed", { | 1236 | return Effect.void; |
| 1209 | models: snapshot.models.map((model) => model.slug), | 1237 | } |
| 1210 | cause: Cause.pretty(cause), | 1238 | return publishSnapshot( |
| 1211 | }).pipe(Effect.asVoid), | 1239 | stampIdentity({ |
| 1240 | ...snapshot, | ||
| 1241 | models: providerModelsFromSettings( | ||
| 1242 | discoveredModels, | ||
| 1243 | PROVIDER, | ||
| 1244 | settings.customModels, | ||
| 1245 | EMPTY_CAPABILITIES, | ||
| 1212 | ), | 1246 | ), |
| 1213 | ); | 1247 | }), |
| 1214 | }, | 1248 | ); |
| 1215 | refreshInterval: CURSOR_REFRESH_INTERVAL, | 1249 | }), |
| 1216 | }); | 1250 | Effect.catchCause((cause) => |
| 1217 | }), | 1251 | Effect.logWarning("Cursor ACP background capability enrichment failed", { |
| 1218 | ); | 1252 | models: snapshot.models.map((model) => model.slug), |
| 1253 | cause: Cause.pretty(cause), | ||
| 1254 | }).pipe(Effect.asVoid), | ||
| 1255 | ), | ||
| 1256 | ); | ||
| 1257 | }; |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+265-21| ... | @@ -2,14 +2,20 @@ import assert from "node:assert/strict"; | ... | @@ -2,14 +2,20 @@ import assert from "node:assert/strict"; |
| 2 | 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 4 | import { it } from "@effect/vitest"; |
| 5 | import { Effect, Layer, Option } from "effect"; | 5 | import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"; |
| 6 | import { beforeEach } from "vitest"; | 6 | import { beforeEach } from "vitest"; |
| 7 | 7 | ||
| 8 | import { ThreadId } from "@t3tools/contracts"; | 8 | import { |
| 9 | OpenCodeSettings, | ||
| 10 | ProviderDriverKind, | ||
| 11 | ProviderInstanceId, | ||
| 12 | ThreadId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 9 | import { ServerConfig } from "../../config.ts"; | 15 | import { ServerConfig } from "../../config.ts"; |
| 10 | import { ServerSettingsService } from "../../serverSettings.ts"; | 16 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 11 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 17 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 12 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | 18 | import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 13 | import { | 19 | import { |
| 14 | OpenCodeRuntime, | 20 | OpenCodeRuntime, |
| 15 | OpenCodeRuntimeError, | 21 | OpenCodeRuntimeError, |
| ... | @@ -17,10 +23,15 @@ import { | ... | @@ -17,10 +23,15 @@ import { |
| 17 | } from "../opencodeRuntime.ts"; | 23 | } from "../opencodeRuntime.ts"; |
| 18 | import { | 24 | import { |
| 19 | appendOpenCodeAssistantTextDelta, | 25 | appendOpenCodeAssistantTextDelta, |
| 20 | makeOpenCodeAdapterLive, | 26 | makeOpenCodeAdapter, |
| 21 | mergeOpenCodeAssistantText, | 27 | mergeOpenCodeAssistantText, |
| 22 | } from "./OpenCodeAdapter.ts"; | 28 | } from "./OpenCodeAdapter.ts"; |
| 23 | 29 | ||
| 30 | // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. | ||
| 31 | class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()( | ||
| 32 | "test/OpenCodeAdapter", | ||
| 33 | ) {} | ||
| 34 | |||
| 24 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); | 35 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); |
| 25 | 36 | ||
| 26 | type MessageEntry = { | 37 | type MessageEntry = { |
| ... | @@ -39,6 +50,7 @@ const runtimeMock = { | ... | @@ -39,6 +50,7 @@ const runtimeMock = { |
| 39 | abortCalls: [] as string[], | 50 | abortCalls: [] as string[], |
| 40 | closeCalls: [] as string[], | 51 | closeCalls: [] as string[], |
| 41 | revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, | 52 | revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, |
| 53 | promptCalls: [] as Array<unknown>, | ||
| 42 | promptAsyncError: null as Error | null, | 54 | promptAsyncError: null as Error | null, |
| 43 | closeError: null as Error | null, | 55 | closeError: null as Error | null, |
| 44 | messages: [] as MessageEntry[], | 56 | messages: [] as MessageEntry[], |
| ... | @@ -51,6 +63,7 @@ const runtimeMock = { | ... | @@ -51,6 +63,7 @@ const runtimeMock = { |
| 51 | this.state.abortCalls.length = 0; | 63 | this.state.abortCalls.length = 0; |
| 52 | this.state.closeCalls.length = 0; | 64 | this.state.closeCalls.length = 0; |
| 53 | this.state.revertCalls.length = 0; | 65 | this.state.revertCalls.length = 0; |
| 66 | this.state.promptCalls.length = 0; | ||
| 54 | this.state.promptAsyncError = null; | 67 | this.state.promptAsyncError = null; |
| 55 | this.state.closeError = null; | 68 | this.state.closeError = null; |
| 56 | this.state.messages = []; | 69 | this.state.messages = []; |
| ... | @@ -111,7 +124,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -111,7 +124,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 111 | abort: async ({ sessionID }: { sessionID: string }) => { | 124 | abort: async ({ sessionID }: { sessionID: string }) => { |
| 112 | runtimeMock.state.abortCalls.push(sessionID); | 125 | runtimeMock.state.abortCalls.push(sessionID); |
| 113 | }, | 126 | }, |
| 114 | promptAsync: async () => { | 127 | promptAsync: async (input: unknown) => { |
| 128 | runtimeMock.state.promptCalls.push(input); | ||
| 115 | if (runtimeMock.state.promptAsyncError) { | 129 | if (runtimeMock.state.promptAsyncError) { |
| 116 | throw runtimeMock.state.promptAsyncError; | 130 | throw runtimeMock.state.promptAsyncError; |
| 117 | } | 131 | } |
| ... | @@ -165,7 +179,24 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory | ... | @@ -165,7 +179,24 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory |
| 165 | listBindings: () => Effect.succeed([]), | 179 | listBindings: () => Effect.succeed([]), |
| 166 | }); | 180 | }); |
| 167 | 181 | ||
| 168 | const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( | 182 | // The adapter now receives its settings as a plain argument (the old design |
| 183 | // read from `ServerSettingsService` internally). The test-only | ||
| 184 | // `ServerSettingsService` below is still kept because other dependencies in | ||
| 185 | // the layer graph reach for it — but the routing values the assertions | ||
| 186 | // probe (serverUrl, serverPassword) must be threaded directly through the | ||
| 187 | // decoded `OpenCodeSettings`. | ||
| 188 | const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ | ||
| 189 | binaryPath: "fake-opencode", | ||
| 190 | serverUrl: "http://127.0.0.1:9999", | ||
| 191 | serverPassword: "secret-password", | ||
| 192 | }); | ||
| 193 | |||
| 194 | const OpenCodeAdapterTestLayer = Layer.effect( | ||
| 195 | OpenCodeAdapter, | ||
| 196 | Effect.gen(function* () { | ||
| 197 | return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings); | ||
| 198 | }), | ||
| 199 | ).pipe( | ||
| 169 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | 200 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 170 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 201 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 171 | Layer.provideMerge( | 202 | Layer.provideMerge( |
| ... | @@ -196,7 +227,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -196,7 +227,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 196 | const adapter = yield* OpenCodeAdapter; | 227 | const adapter = yield* OpenCodeAdapter; |
| 197 | 228 | ||
| 198 | const session = yield* adapter.startSession({ | 229 | const session = yield* adapter.startSession({ |
| 199 | provider: "opencode", | 230 | provider: ProviderDriverKind.make("opencode"), |
| 200 | threadId: asThreadId("thread-opencode"), | 231 | threadId: asThreadId("thread-opencode"), |
| 201 | runtimeMode: "full-access", | 232 | runtimeMode: "full-access", |
| 202 | }); | 233 | }); |
| ... | @@ -215,7 +246,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -215,7 +246,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 215 | Effect.gen(function* () { | 246 | Effect.gen(function* () { |
| 216 | const adapter = yield* OpenCodeAdapter; | 247 | const adapter = yield* OpenCodeAdapter; |
| 217 | yield* adapter.startSession({ | 248 | yield* adapter.startSession({ |
| 218 | provider: "opencode", | 249 | provider: ProviderDriverKind.make("opencode"), |
| 219 | threadId: asThreadId("thread-opencode"), | 250 | threadId: asThreadId("thread-opencode"), |
| 220 | runtimeMode: "full-access", | 251 | runtimeMode: "full-access", |
| 221 | }); | 252 | }); |
| ... | @@ -230,16 +261,42 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -230,16 +261,42 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 230 | }), | 261 | }), |
| 231 | ); | 262 | ); |
| 232 | 263 | ||
| 264 | it.effect("emits one session.exited event when stopping a session", () => | ||
| 265 | Effect.gen(function* () { | ||
| 266 | const adapter = yield* OpenCodeAdapter; | ||
| 267 | const threadId = asThreadId("thread-opencode-stop-event"); | ||
| 268 | const eventsFiber = yield* adapter.streamEvents.pipe( | ||
| 269 | Stream.filter((event) => event.threadId === threadId), | ||
| 270 | Stream.take(3), | ||
| 271 | Stream.runCollect, | ||
| 272 | Effect.forkChild, | ||
| 273 | ); | ||
| 274 | |||
| 275 | yield* adapter.startSession({ | ||
| 276 | provider: ProviderDriverKind.make("opencode"), | ||
| 277 | threadId, | ||
| 278 | runtimeMode: "full-access", | ||
| 279 | }); | ||
| 280 | yield* adapter.stopSession(threadId); | ||
| 281 | |||
| 282 | const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); | ||
| 283 | assert.deepEqual( | ||
| 284 | events.map((event) => event.type), | ||
| 285 | ["session.started", "thread.started", "session.exited"], | ||
| 286 | ); | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | |||
| 233 | it.effect("clears session state even when cleanup finalizers throw", () => | 290 | it.effect("clears session state even when cleanup finalizers throw", () => |
| 234 | Effect.gen(function* () { | 291 | Effect.gen(function* () { |
| 235 | const adapter = yield* OpenCodeAdapter; | 292 | const adapter = yield* OpenCodeAdapter; |
| 236 | yield* adapter.startSession({ | 293 | yield* adapter.startSession({ |
| 237 | provider: "opencode", | 294 | provider: ProviderDriverKind.make("opencode"), |
| 238 | threadId: asThreadId("thread-stop-all-a"), | 295 | threadId: asThreadId("thread-stop-all-a"), |
| 239 | runtimeMode: "full-access", | 296 | runtimeMode: "full-access", |
| 240 | }); | 297 | }); |
| 241 | yield* adapter.startSession({ | 298 | yield* adapter.startSession({ |
| 242 | provider: "opencode", | 299 | provider: ProviderDriverKind.make("opencode"), |
| 243 | threadId: asThreadId("thread-stop-all-b"), | 300 | threadId: asThreadId("thread-stop-all-b"), |
| 244 | runtimeMode: "full-access", | 301 | runtimeMode: "full-access", |
| 245 | }); | 302 | }); |
| ... | @@ -261,11 +318,44 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -261,11 +318,44 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 261 | }), | 318 | }), |
| 262 | ); | 319 | ); |
| 263 | 320 | ||
| 321 | it.effect("completes streamEvents when the adapter scope closes", () => | ||
| 322 | Effect.gen(function* () { | ||
| 323 | const scope = yield* Scope.make("sequential"); | ||
| 324 | let scopeClosed = false; | ||
| 325 | |||
| 326 | try { | ||
| 327 | const adapterLayer = Layer.effect( | ||
| 328 | OpenCodeAdapter, | ||
| 329 | makeOpenCodeAdapter(openCodeAdapterTestSettings), | ||
| 330 | ).pipe( | ||
| 331 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | ||
| 332 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 333 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 334 | Layer.provideMerge(providerSessionDirectoryTestLayer), | ||
| 335 | Layer.provideMerge(NodeServices.layer), | ||
| 336 | ); | ||
| 337 | const context = yield* Layer.buildWithScope(adapterLayer, scope); | ||
| 338 | const adapter = yield* Effect.service(OpenCodeAdapter).pipe(Effect.provide(context)); | ||
| 339 | const eventsFiber = yield* adapter.streamEvents.pipe(Stream.runCollect, Effect.forkChild); | ||
| 340 | |||
| 341 | yield* Scope.close(scope, Exit.void); | ||
| 342 | scopeClosed = true; | ||
| 343 | |||
| 344 | const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); | ||
| 345 | assert.equal(Exit.hasInterrupts(exit), true); | ||
| 346 | } finally { | ||
| 347 | if (!scopeClosed) { | ||
| 348 | yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); | ||
| 349 | } | ||
| 350 | } | ||
| 351 | }), | ||
| 352 | ); | ||
| 353 | |||
| 264 | it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => | 354 | it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => |
| 265 | Effect.gen(function* () { | 355 | Effect.gen(function* () { |
| 266 | const adapter = yield* OpenCodeAdapter; | 356 | const adapter = yield* OpenCodeAdapter; |
| 267 | yield* adapter.startSession({ | 357 | yield* adapter.startSession({ |
| 268 | provider: "opencode", | 358 | provider: ProviderDriverKind.make("opencode"), |
| 269 | threadId: asThreadId("thread-send-turn-failure"), | 359 | threadId: asThreadId("thread-send-turn-failure"), |
| 270 | runtimeMode: "full-access", | 360 | runtimeMode: "full-access", |
| 271 | }); | 361 | }); |
| ... | @@ -276,7 +366,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -276,7 +366,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 276 | threadId: asThreadId("thread-send-turn-failure"), | 366 | threadId: asThreadId("thread-send-turn-failure"), |
| 277 | input: "Fix it", | 367 | input: "Fix it", |
| 278 | modelSelection: { | 368 | modelSelection: { |
| 279 | provider: "opencode", | 369 | instanceId: ProviderInstanceId.make("opencode"), |
| 280 | model: "openai/gpt-5", | 370 | model: "openai/gpt-5", |
| 281 | }, | 371 | }, |
| 282 | }) | 372 | }) |
| ... | @@ -299,12 +389,158 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -299,12 +389,158 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 299 | }), | 389 | }), |
| 300 | ); | 390 | ); |
| 301 | 391 | ||
| 392 | it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { | ||
| 393 | const customInstanceId = ProviderInstanceId.make("opencode_zen"); | ||
| 394 | const adapterLayer = Layer.effect( | ||
| 395 | OpenCodeAdapter, | ||
| 396 | Effect.gen(function* () { | ||
| 397 | return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | ||
| 398 | instanceId: customInstanceId, | ||
| 399 | }); | ||
| 400 | }), | ||
| 401 | ).pipe( | ||
| 402 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | ||
| 403 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 404 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 405 | Layer.provideMerge(providerSessionDirectoryTestLayer), | ||
| 406 | Layer.provideMerge(NodeServices.layer), | ||
| 407 | ); | ||
| 408 | |||
| 409 | return Effect.gen(function* () { | ||
| 410 | const adapter = yield* OpenCodeAdapter; | ||
| 411 | yield* adapter.startSession({ | ||
| 412 | provider: ProviderDriverKind.make("opencode"), | ||
| 413 | threadId: asThreadId("thread-custom-instance"), | ||
| 414 | runtimeMode: "full-access", | ||
| 415 | }); | ||
| 416 | |||
| 417 | yield* adapter.sendTurn({ | ||
| 418 | threadId: asThreadId("thread-custom-instance"), | ||
| 419 | input: "Fix it", | ||
| 420 | modelSelection: createModelSelection( | ||
| 421 | ProviderInstanceId.make("opencode_zen"), | ||
| 422 | "anthropic/claude-sonnet-4-5", | ||
| 423 | [ | ||
| 424 | { id: "agent", value: "github-copilot" }, | ||
| 425 | { id: "variant", value: "high" }, | ||
| 426 | ], | ||
| 427 | ), | ||
| 428 | }); | ||
| 429 | |||
| 430 | assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { | ||
| 431 | sessionID: "http://127.0.0.1:9999/session", | ||
| 432 | model: { | ||
| 433 | providerID: "anthropic", | ||
| 434 | modelID: "claude-sonnet-4-5", | ||
| 435 | }, | ||
| 436 | agent: "github-copilot", | ||
| 437 | variant: "high", | ||
| 438 | parts: [{ type: "text", text: "Fix it" }], | ||
| 439 | }); | ||
| 440 | }).pipe(Effect.provide(adapterLayer)); | ||
| 441 | }); | ||
| 442 | |||
| 443 | it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { | ||
| 444 | const customInstanceId = ProviderInstanceId.make("opencode_zen"); | ||
| 445 | const adapterLayer = Layer.effect( | ||
| 446 | OpenCodeAdapter, | ||
| 447 | Effect.gen(function* () { | ||
| 448 | return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | ||
| 449 | instanceId: customInstanceId, | ||
| 450 | }); | ||
| 451 | }), | ||
| 452 | ).pipe( | ||
| 453 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | ||
| 454 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 455 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 456 | Layer.provideMerge(providerSessionDirectoryTestLayer), | ||
| 457 | Layer.provideMerge(NodeServices.layer), | ||
| 458 | ); | ||
| 459 | |||
| 460 | return Effect.gen(function* () { | ||
| 461 | const adapter = yield* OpenCodeAdapter; | ||
| 462 | const threadId = asThreadId("thread-custom-instance-fallback-model"); | ||
| 463 | yield* adapter.startSession({ | ||
| 464 | provider: ProviderDriverKind.make("opencode"), | ||
| 465 | threadId, | ||
| 466 | runtimeMode: "full-access", | ||
| 467 | modelSelection: createModelSelection( | ||
| 468 | ProviderInstanceId.make("opencode_zen"), | ||
| 469 | "anthropic/claude-sonnet-4-5", | ||
| 470 | ), | ||
| 471 | }); | ||
| 472 | |||
| 473 | yield* adapter.sendTurn({ | ||
| 474 | threadId, | ||
| 475 | input: "Fix it", | ||
| 476 | }); | ||
| 477 | |||
| 478 | assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { | ||
| 479 | sessionID: "http://127.0.0.1:9999/session", | ||
| 480 | model: { | ||
| 481 | providerID: "anthropic", | ||
| 482 | modelID: "claude-sonnet-4-5", | ||
| 483 | }, | ||
| 484 | parts: [{ type: "text", text: "Fix it" }], | ||
| 485 | }); | ||
| 486 | }).pipe(Effect.provide(adapterLayer)); | ||
| 487 | }); | ||
| 488 | |||
| 489 | it.effect("rejects sendTurn model selections for another instance id", () => { | ||
| 490 | const customInstanceId = ProviderInstanceId.make("opencode_zen"); | ||
| 491 | const adapterLayer = Layer.effect( | ||
| 492 | OpenCodeAdapter, | ||
| 493 | Effect.gen(function* () { | ||
| 494 | return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | ||
| 495 | instanceId: customInstanceId, | ||
| 496 | }); | ||
| 497 | }), | ||
| 498 | ).pipe( | ||
| 499 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | ||
| 500 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 501 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 502 | Layer.provideMerge(providerSessionDirectoryTestLayer), | ||
| 503 | Layer.provideMerge(NodeServices.layer), | ||
| 504 | ); | ||
| 505 | |||
| 506 | return Effect.gen(function* () { | ||
| 507 | const adapter = yield* OpenCodeAdapter; | ||
| 508 | const threadId = asThreadId("thread-custom-instance-wrong-selection"); | ||
| 509 | yield* adapter.startSession({ | ||
| 510 | provider: ProviderDriverKind.make("opencode"), | ||
| 511 | threadId, | ||
| 512 | runtimeMode: "full-access", | ||
| 513 | }); | ||
| 514 | |||
| 515 | const error = yield* adapter | ||
| 516 | .sendTurn({ | ||
| 517 | threadId, | ||
| 518 | input: "Fix it", | ||
| 519 | modelSelection: createModelSelection( | ||
| 520 | ProviderInstanceId.make("opencode"), | ||
| 521 | "anthropic/claude-sonnet-4-5", | ||
| 522 | ), | ||
| 523 | }) | ||
| 524 | .pipe(Effect.flip); | ||
| 525 | |||
| 526 | assert.equal(error._tag, "ProviderAdapterValidationError"); | ||
| 527 | if (error._tag !== "ProviderAdapterValidationError") { | ||
| 528 | throw new Error("Unexpected error type"); | ||
| 529 | } | ||
| 530 | assert.equal( | ||
| 531 | error.issue, | ||
| 532 | "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", | ||
| 533 | ); | ||
| 534 | assert.deepEqual(runtimeMock.state.promptCalls, []); | ||
| 535 | }).pipe(Effect.provide(adapterLayer)); | ||
| 536 | }); | ||
| 537 | |||
| 302 | it.effect("reverts the full thread when rollback removes every assistant turn", () => | 538 | it.effect("reverts the full thread when rollback removes every assistant turn", () => |
| 303 | Effect.gen(function* () { | 539 | Effect.gen(function* () { |
| 304 | const adapter = yield* OpenCodeAdapter; | 540 | const adapter = yield* OpenCodeAdapter; |
| 305 | const threadId = asThreadId("thread-rollback-all"); | 541 | const threadId = asThreadId("thread-rollback-all"); |
| 306 | yield* adapter.startSession({ | 542 | yield* adapter.startSession({ |
| 307 | provider: "opencode", | 543 | provider: ProviderDriverKind.make("opencode"), |
| 308 | threadId, | 544 | threadId, |
| 309 | runtimeMode: "full-access", | 545 | runtimeMode: "full-access", |
| 310 | }); | 546 | }); |
| ... | @@ -396,9 +632,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -396,9 +632,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 396 | close: () => Effect.void, | 632 | close: () => Effect.void, |
| 397 | }; | 633 | }; |
| 398 | 634 | ||
| 399 | const adapterLayer = makeOpenCodeAdapterLive({ | 635 | const adapterLayer = Layer.effect( |
| 400 | nativeEventLogger, | 636 | OpenCodeAdapter, |
| 401 | }).pipe( | 637 | Effect.gen(function* () { |
| 638 | return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | ||
| 639 | nativeEventLogger, | ||
| 640 | }); | ||
| 641 | }), | ||
| 642 | ).pipe( | ||
| 402 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | 643 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 403 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 644 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 404 | Layer.provideMerge( | 645 | Layer.provideMerge( |
| ... | @@ -419,7 +660,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -419,7 +660,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 419 | const session = yield* Effect.gen(function* () { | 660 | const session = yield* Effect.gen(function* () { |
| 420 | const adapter = yield* OpenCodeAdapter; | 661 | const adapter = yield* OpenCodeAdapter; |
| 421 | const started = yield* adapter.startSession({ | 662 | const started = yield* adapter.startSession({ |
| 422 | provider: "opencode", | 663 | provider: ProviderDriverKind.make("opencode"), |
| 423 | threadId: asThreadId("thread-native-log"), | 664 | threadId: asThreadId("thread-native-log"), |
| 424 | runtimeMode: "full-access", | 665 | runtimeMode: "full-access", |
| 425 | }); | 666 | }); |
| ... | @@ -475,9 +716,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -475,9 +716,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 475 | close: () => Effect.void, | 716 | close: () => Effect.void, |
| 476 | }; | 717 | }; |
| 477 | 718 | ||
| 478 | const adapterLayer = makeOpenCodeAdapterLive({ | 719 | const adapterLayer = Layer.effect( |
| 479 | nativeEventLogger, | 720 | OpenCodeAdapter, |
| 480 | }).pipe( | 721 | makeOpenCodeAdapter(openCodeAdapterTestSettings, { |
| 722 | nativeEventLogger, | ||
| 723 | }), | ||
| 724 | ).pipe( | ||
| 481 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | 725 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 482 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 726 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 483 | Layer.provideMerge( | 727 | Layer.provideMerge( |
| ... | @@ -504,7 +748,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | ... | @@ -504,7 +748,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 504 | const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { | 748 | const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { |
| 505 | const adapter = yield* OpenCodeAdapter; | 749 | const adapter = yield* OpenCodeAdapter; |
| 506 | yield* adapter.startSession({ | 750 | yield* adapter.startSession({ |
| 507 | provider: "opencode", | 751 | provider: ProviderDriverKind.make("opencode"), |
| 508 | threadId: asThreadId("thread-native-log-failure"), | 752 | threadId: asThreadId("thread-native-log-failure"), |
| 509 | runtimeMode: "full-access", | 753 | runtimeMode: "full-access", |
| 510 | }); | 754 | }); |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+907-863| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import { | 1 | import { |
| 4 | EventId, | 2 | EventId, |
| 3 | type OpenCodeSettings, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 5 | type ProviderRuntimeEvent, | 6 | type ProviderRuntimeEvent, |
| 6 | type ProviderSession, | 7 | type ProviderSession, |
| 7 | RuntimeItemId, | 8 | RuntimeItemId, |
| ... | @@ -11,13 +12,12 @@ import { | ... | @@ -11,13 +12,12 @@ import { |
| 11 | TurnId, | 12 | TurnId, |
| 12 | type UserInputQuestion, | 13 | type UserInputQuestion, |
| 13 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 14 | import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; | 15 | import { Cause, Effect, Exit, Queue, Random, Ref, Scope, Stream } from "effect"; |
| 15 | import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; | 16 | import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; |
| 16 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 17 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 17 | 18 | ||
| 18 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 19 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 19 | import { ServerConfig } from "../../config.ts"; | 20 | import { ServerConfig } from "../../config.ts"; |
| 20 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 21 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 21 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 22 | import { | 22 | import { |
| 23 | ProviderAdapterProcessError, | 23 | ProviderAdapterProcessError, |
| ... | @@ -26,7 +26,7 @@ import { | ... | @@ -26,7 +26,7 @@ import { |
| 26 | ProviderAdapterSessionNotFoundError, | 26 | ProviderAdapterSessionNotFoundError, |
| 27 | ProviderAdapterValidationError, | 27 | ProviderAdapterValidationError, |
| 28 | } from "../Errors.ts"; | 28 | } from "../Errors.ts"; |
| 29 | import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | 29 | import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 30 | import { | 30 | import { |
| 31 | buildOpenCodePermissionRules, | 31 | buildOpenCodePermissionRules, |
| 32 | OpenCodeRuntime, | 32 | OpenCodeRuntime, |
| ... | @@ -41,7 +41,7 @@ import { | ... | @@ -41,7 +41,7 @@ import { |
| 41 | type OpenCodeServerConnection, | 41 | type OpenCodeServerConnection, |
| 42 | } from "../opencodeRuntime.ts"; | 42 | } from "../opencodeRuntime.ts"; |
| 43 | 43 | ||
| 44 | const PROVIDER = "opencode" as const; | 44 | const PROVIDER = ProviderDriverKind.make("opencode"); |
| 45 | 45 | ||
| 46 | interface OpenCodeTurnSnapshot { | 46 | interface OpenCodeTurnSnapshot { |
| 47 | readonly id: TurnId; | 47 | readonly id: TurnId; |
| ... | @@ -89,6 +89,8 @@ interface OpenCodeSessionContext { | ... | @@ -89,6 +89,8 @@ interface OpenCodeSessionContext { |
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | export interface OpenCodeAdapterLiveOptions { | 91 | export interface OpenCodeAdapterLiveOptions { |
| 92 | readonly instanceId?: ProviderInstanceId; | ||
| 93 | readonly environment?: NodeJS.ProcessEnv; | ||
| 92 | readonly nativeEventLogPath?: string; | 94 | readonly nativeEventLogPath?: string; |
| 93 | readonly nativeEventLogger?: EventNdjsonLogger; | 95 | readonly nativeEventLogger?: EventNdjsonLogger; |
| 94 | } | 96 | } |
| ... | @@ -125,35 +127,38 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc | ... | @@ -125,35 +127,38 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc |
| 125 | cause, | 127 | cause, |
| 126 | }); | 128 | }); |
| 127 | 129 | ||
| 128 | function buildEventBase(input: { | 130 | const buildEventBase = (input: { |
| 129 | readonly threadId: ThreadId; | 131 | readonly threadId: ThreadId; |
| 130 | readonly turnId?: TurnId | undefined; | 132 | readonly turnId?: TurnId | undefined; |
| 131 | readonly itemId?: string | undefined; | 133 | readonly itemId?: string | undefined; |
| 132 | readonly requestId?: string | undefined; | 134 | readonly requestId?: string | undefined; |
| 133 | readonly createdAt?: string | undefined; | 135 | readonly createdAt?: string | undefined; |
| 134 | readonly raw?: unknown; | 136 | readonly raw?: unknown; |
| 135 | }): Pick< | 137 | }): Effect.Effect< |
| 136 | ProviderRuntimeEvent, | 138 | Pick< |
| 137 | "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" | 139 | ProviderRuntimeEvent, |
| 138 | > { | 140 | "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" |
| 139 | return { | 141 | > |
| 140 | eventId: EventId.make(randomUUID()), | 142 | > => |
| 141 | provider: PROVIDER, | 143 | Random.nextUUIDv4.pipe( |
| 142 | threadId: input.threadId, | 144 | Effect.map((uuid) => ({ |
| 143 | createdAt: input.createdAt ?? nowIso(), | 145 | eventId: EventId.make(uuid), |
| 144 | ...(input.turnId ? { turnId: input.turnId } : {}), | 146 | provider: PROVIDER, |
| 145 | ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), | 147 | threadId: input.threadId, |
| 146 | ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), | 148 | createdAt: input.createdAt ?? nowIso(), |
| 147 | ...(input.raw !== undefined | 149 | ...(input.turnId ? { turnId: input.turnId } : {}), |
| 148 | ? { | 150 | ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), |
| 149 | raw: { | 151 | ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), |
| 150 | source: "opencode.sdk.event", | 152 | ...(input.raw !== undefined |
| 151 | payload: input.raw, | 153 | ? { |
| 152 | }, | 154 | raw: { |
| 153 | } | 155 | source: "opencode.sdk.event", |
| 154 | : {}), | 156 | payload: input.raw, |
| 155 | }; | 157 | }, |
| 156 | } | 158 | } |
| 159 | : {}), | ||
| 160 | })), | ||
| 161 | ); | ||
| 157 | 162 | ||
| 158 | function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { | 163 | function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { |
| 159 | const normalized = toolName.toLowerCase(); | 164 | const normalized = toolName.toLowerCase(); |
| ... | @@ -245,13 +250,19 @@ function ensureSessionContext( | ... | @@ -245,13 +250,19 @@ function ensureSessionContext( |
| 245 | ): OpenCodeSessionContext { | 250 | ): OpenCodeSessionContext { |
| 246 | const session = sessions.get(threadId); | 251 | const session = sessions.get(threadId); |
| 247 | if (!session) { | 252 | if (!session) { |
| 248 | throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); | 253 | throw new ProviderAdapterSessionNotFoundError({ |
| 254 | provider: PROVIDER, | ||
| 255 | threadId, | ||
| 256 | }); | ||
| 249 | } | 257 | } |
| 250 | // `ensureSessionContext` is a sync gate used from both sync helpers and | 258 | // `ensureSessionContext` is a sync gate used from both sync helpers and |
| 251 | // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell — | 259 | // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell — |
| 252 | // no fiber suspension required, which keeps this callable everywhere. | 260 | // no fiber suspension required, which keeps this callable everywhere. |
| 253 | if (Ref.getUnsafe(session.stopped)) { | 261 | if (Ref.getUnsafe(session.stopped)) { |
| 254 | throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); | 262 | throw new ProviderAdapterSessionClosedError({ |
| 263 | provider: PROVIDER, | ||
| 264 | threadId, | ||
| 265 | }); | ||
| 255 | } | 266 | } |
| 256 | return session; | 267 | return session; |
| 257 | } | 268 | } |
| ... | @@ -419,7 +430,7 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( | ... | @@ -419,7 +430,7 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( |
| 419 | ) { | 430 | ) { |
| 420 | // Race-safe one-shot: first caller flips the flag, everyone else no-ops. | 431 | // Race-safe one-shot: first caller flips the flag, everyone else no-ops. |
| 421 | if (yield* Ref.getAndSet(context.stopped, true)) { | 432 | if (yield* Ref.getAndSet(context.stopped, true)) { |
| 422 | return; | 433 | return false; |
| 423 | } | 434 | } |
| 424 | 435 | ||
| 425 | // Best-effort remote abort. The scope close below tears down the local | 436 | // Best-effort remote abort. The scope close below tears down the local |
| ... | @@ -433,957 +444,990 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( | ... | @@ -433,957 +444,990 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( |
| 433 | // runs each finalizer we registered — the `AbortController.abort()` call, | 444 | // runs each finalizer we registered — the `AbortController.abort()` call, |
| 434 | // the child-process termination, etc. | 445 | // the child-process termination, etc. |
| 435 | yield* Scope.close(context.sessionScope, Exit.void); | 446 | yield* Scope.close(context.sessionScope, Exit.void); |
| 447 | return true; | ||
| 436 | }); | 448 | }); |
| 437 | 449 | ||
| 438 | export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { | 450 | export function makeOpenCodeAdapter( |
| 439 | return Layer.effect( | 451 | openCodeSettings: OpenCodeSettings, |
| 440 | OpenCodeAdapter, | 452 | options?: OpenCodeAdapterLiveOptions, |
| 441 | Effect.gen(function* () { | 453 | ) { |
| 442 | const serverConfig = yield* ServerConfig; | 454 | return Effect.gen(function* () { |
| 443 | const serverSettings = yield* ServerSettingsService; | 455 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); |
| 444 | const openCodeRuntime = yield* OpenCodeRuntime; | 456 | const serverConfig = yield* ServerConfig; |
| 445 | const nativeEventLogger = | 457 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 446 | options?.nativeEventLogger ?? | 458 | const nativeEventLogger = |
| 447 | (options?.nativeEventLogPath !== undefined | 459 | options?.nativeEventLogger ?? |
| 448 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { | 460 | (options?.nativeEventLogPath !== undefined |
| 449 | stream: "native", | 461 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 450 | }) | 462 | stream: "native", |
| 451 | : undefined); | 463 | }) |
| 452 | // Only close loggers we created. If the caller passed one in via | 464 | : undefined); |
| 453 | // `options.nativeEventLogger`, they own its lifecycle. | 465 | // Only close loggers we created. If the caller passed one in via |
| 454 | const managedNativeEventLogger = | 466 | // `options.nativeEventLogger`, they own its lifecycle. |
| 455 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; | 467 | const managedNativeEventLogger = |
| 456 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 468 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; |
| 457 | const sessions = new Map<ThreadId, OpenCodeSessionContext>(); | 469 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 458 | 470 | const sessions = new Map<ThreadId, OpenCodeSessionContext>(); | |
| 459 | // Layer-level finalizer: when the adapter layer shuts down, stop every | 471 | |
| 460 | // session. Each session's `Scope.close` tears down its spawned OpenCode | 472 | // Layer-level finalizer: when the adapter layer shuts down, stop every |
| 461 | // server (via the `ChildProcessSpawner` finalizer installed in | 473 | // session. Each session's `Scope.close` tears down its spawned OpenCode |
| 462 | // `startOpenCodeServerProcess`) and interrupts the forked event/exit | 474 | // server (via the `ChildProcessSpawner` finalizer installed in |
| 463 | // fibers. Consumers that can't reason about Effect scopes therefore | 475 | // `startOpenCodeServerProcess`) and interrupts the forked event/exit |
| 464 | // cannot leak OpenCode child processes by forgetting to call `stopAll`. | 476 | // fibers. Consumers that can't reason about Effect scopes therefore |
| 465 | yield* Effect.addFinalizer(() => | 477 | // cannot leak OpenCode child processes by forgetting to call `stopAll`. |
| 466 | Effect.gen(function* () { | 478 | yield* Effect.addFinalizer(() => |
| 467 | const contexts = [...sessions.values()]; | 479 | Effect.gen(function* () { |
| 468 | sessions.clear(); | 480 | const contexts = [...sessions.values()]; |
| 469 | // `ignoreCause` swallows both typed failures (none here) and defects | 481 | sessions.clear(); |
| 470 | // from throwing scope finalizers so a sibling's death can't interrupt | 482 | // `ignoreCause` swallows both typed failures (none here) and defects |
| 471 | // the remaining cleanups. | 483 | // from throwing scope finalizers so a sibling's death can't interrupt |
| 472 | yield* Effect.forEach( | 484 | // the remaining cleanups. |
| 473 | contexts, | 485 | yield* Effect.forEach( |
| 474 | (context) => Effect.ignoreCause(stopOpenCodeContext(context)), | 486 | contexts, |
| 475 | { concurrency: "unbounded", discard: true }, | 487 | (context) => Effect.ignoreCause(stopOpenCodeContext(context)), |
| 476 | ); | 488 | { concurrency: "unbounded", discard: true }, |
| 477 | // Close the logger AFTER session teardown so any final lifecycle | 489 | ); |
| 478 | // events emitted during shutdown still get written. `close` flushes | 490 | // Close the logger AFTER session teardown so any final lifecycle |
| 479 | // the `Logger.batched` window and closes each per-thread | 491 | // events emitted during shutdown still get written. `close` flushes |
| 480 | // `RotatingFileSink` handle owned by the logger's internal scope. | 492 | // the `Logger.batched` window and closes each per-thread |
| 481 | if (managedNativeEventLogger !== undefined) { | 493 | // `RotatingFileSink` handle owned by the logger's internal scope. |
| 482 | yield* managedNativeEventLogger.close(); | 494 | if (managedNativeEventLogger !== undefined) { |
| 483 | } | 495 | yield* managedNativeEventLogger.close(); |
| 484 | }), | 496 | } |
| 485 | ); | 497 | }).pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))), |
| 486 | 498 | ); | |
| 487 | const emit = (event: ProviderRuntimeEvent) => | 499 | |
| 488 | Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); | 500 | const emit = (event: ProviderRuntimeEvent) => |
| 489 | const writeNativeEvent = ( | 501 | Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); |
| 490 | threadId: ThreadId, | 502 | const writeNativeEvent = ( |
| 491 | event: { | 503 | threadId: ThreadId, |
| 492 | readonly observedAt: string; | 504 | event: { |
| 493 | readonly event: Record<string, unknown>; | 505 | readonly observedAt: string; |
| 506 | readonly event: Record<string, unknown>; | ||
| 507 | }, | ||
| 508 | ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); | ||
| 509 | const writeNativeEventBestEffort = ( | ||
| 510 | threadId: ThreadId, | ||
| 511 | event: { | ||
| 512 | readonly observedAt: string; | ||
| 513 | readonly event: Record<string, unknown>; | ||
| 514 | }, | ||
| 515 | ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); | ||
| 516 | |||
| 517 | const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( | ||
| 518 | context: OpenCodeSessionContext, | ||
| 519 | message: string, | ||
| 520 | ) { | ||
| 521 | // Atomic one-shot: two fibers can race here (the event-pump on stream | ||
| 522 | // failure and the server-exit watcher). `getAndSet` flips the flag in | ||
| 523 | // a single step so the loser observes `true` and returns; a plain | ||
| 524 | // `Ref.get` would let both racers slip past and emit duplicates. | ||
| 525 | if (yield* Ref.getAndSet(context.stopped, true)) { | ||
| 526 | return; | ||
| 527 | } | ||
| 528 | const turnId = context.activeTurnId; | ||
| 529 | sessions.delete(context.session.threadId); | ||
| 530 | // Emit lifecycle events BEFORE tearing down the scope. Both call sites | ||
| 531 | // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; | ||
| 532 | // closing that scope triggers the fiber-interrupt finalizer, so any | ||
| 533 | // subsequent yield point would unwind and silently drop these emits. | ||
| 534 | yield* emit({ | ||
| 535 | ...(yield* buildEventBase({ | ||
| 536 | threadId: context.session.threadId, | ||
| 537 | turnId, | ||
| 538 | })), | ||
| 539 | type: "runtime.error", | ||
| 540 | payload: { | ||
| 541 | message, | ||
| 542 | class: "transport_error", | ||
| 494 | }, | 543 | }, |
| 495 | ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); | 544 | }).pipe(Effect.ignore); |
| 496 | const writeNativeEventBestEffort = ( | 545 | yield* emit({ |
| 497 | threadId: ThreadId, | 546 | ...(yield* buildEventBase({ |
| 498 | event: { | 547 | threadId: context.session.threadId, |
| 499 | readonly observedAt: string; | 548 | turnId, |
| 500 | readonly event: Record<string, unknown>; | 549 | })), |
| 550 | type: "session.exited", | ||
| 551 | payload: { | ||
| 552 | reason: message, | ||
| 553 | recoverable: false, | ||
| 554 | exitKind: "error", | ||
| 501 | }, | 555 | }, |
| 502 | ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); | 556 | }).pipe(Effect.ignore); |
| 503 | 557 | // Inline the teardown that `stopOpenCodeContext` would do; we can't | |
| 504 | const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( | 558 | // delegate to it because our `getAndSet` above already flipped the |
| 505 | context: OpenCodeSessionContext, | 559 | // one-shot guard, so the call would no-op. |
| 506 | message: string, | 560 | yield* runOpenCodeSdk("session.abort", () => |
| 507 | ) { | 561 | context.client.session.abort({ sessionID: context.openCodeSessionId }), |
| 508 | // Atomic one-shot: two fibers can race here (the event-pump on stream | 562 | ).pipe(Effect.ignore({ log: true })); |
| 509 | // failure and the server-exit watcher). `getAndSet` flips the flag in | 563 | yield* Scope.close(context.sessionScope, Exit.void); |
| 510 | // a single step so the loser observes `true` and returns; a plain | 564 | }); |
| 511 | // `Ref.get` would let both racers slip past and emit duplicates. | 565 | |
| 512 | if (yield* Ref.getAndSet(context.stopped, true)) { | 566 | /** Emit content.delta and item.completed events for an assistant text part. */ |
| 513 | return; | 567 | const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( |
| 514 | } | 568 | context: OpenCodeSessionContext, |
| 515 | const turnId = context.activeTurnId; | 569 | part: Part, |
| 516 | sessions.delete(context.session.threadId); | 570 | turnId: TurnId | undefined, |
| 517 | // Emit lifecycle events BEFORE tearing down the scope. Both call sites | 571 | raw: unknown, |
| 518 | // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; | 572 | ) { |
| 519 | // closing that scope triggers the fiber-interrupt finalizer, so any | 573 | const text = textFromPart(part); |
| 520 | // subsequent yield point would unwind and silently drop these emits. | 574 | if (text === undefined) { |
| 575 | return; | ||
| 576 | } | ||
| 577 | const previousText = context.emittedTextByPartId.get(part.id); | ||
| 578 | const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); | ||
| 579 | context.emittedTextByPartId.set(part.id, latestText); | ||
| 580 | if (latestText !== text) { | ||
| 581 | context.partById.set( | ||
| 582 | part.id, | ||
| 583 | (part.type === "text" || part.type === "reasoning" | ||
| 584 | ? { ...part, text: latestText } | ||
| 585 | : part) satisfies Part, | ||
| 586 | ); | ||
| 587 | } | ||
| 588 | if (deltaToEmit.length > 0) { | ||
| 521 | yield* emit({ | 589 | yield* emit({ |
| 522 | ...buildEventBase({ threadId: context.session.threadId, turnId }), | 590 | ...(yield* buildEventBase({ |
| 523 | type: "runtime.error", | 591 | threadId: context.session.threadId, |
| 592 | turnId, | ||
| 593 | itemId: part.id, | ||
| 594 | createdAt: | ||
| 595 | part.type === "text" || part.type === "reasoning" | ||
| 596 | ? isoFromEpochMs(part.time?.start) | ||
| 597 | : undefined, | ||
| 598 | raw, | ||
| 599 | })), | ||
| 600 | type: "content.delta", | ||
| 524 | payload: { | 601 | payload: { |
| 525 | message, | 602 | streamKind: resolveTextStreamKind(part), |
| 526 | class: "transport_error", | 603 | delta: deltaToEmit, |
| 527 | }, | 604 | }, |
| 528 | }).pipe(Effect.ignore); | 605 | }); |
| 606 | } | ||
| 607 | |||
| 608 | if ( | ||
| 609 | part.type === "text" && | ||
| 610 | part.time?.end !== undefined && | ||
| 611 | !context.completedAssistantPartIds.has(part.id) | ||
| 612 | ) { | ||
| 613 | context.completedAssistantPartIds.add(part.id); | ||
| 529 | yield* emit({ | 614 | yield* emit({ |
| 530 | ...buildEventBase({ threadId: context.session.threadId, turnId }), | 615 | ...(yield* buildEventBase({ |
| 531 | type: "session.exited", | 616 | threadId: context.session.threadId, |
| 617 | turnId, | ||
| 618 | itemId: part.id, | ||
| 619 | createdAt: isoFromEpochMs(part.time.end), | ||
| 620 | raw, | ||
| 621 | })), | ||
| 622 | type: "item.completed", | ||
| 532 | payload: { | 623 | payload: { |
| 533 | reason: message, | 624 | itemType: "assistant_message", |
| 534 | recoverable: false, | 625 | status: "completed", |
| 535 | exitKind: "error", | 626 | title: "Assistant message", |
| 627 | ...(latestText.length > 0 ? { detail: latestText } : {}), | ||
| 536 | }, | 628 | }, |
| 537 | }).pipe(Effect.ignore); | 629 | }); |
| 538 | // Inline the teardown that `stopOpenCodeContext` would do; we can't | 630 | } |
| 539 | // delegate to it because our `getAndSet` above already flipped the | 631 | }); |
| 540 | // one-shot guard, so the call would no-op. | 632 | |
| 541 | yield* runOpenCodeSdk("session.abort", () => | 633 | const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( |
| 542 | context.client.session.abort({ sessionID: context.openCodeSessionId }), | 634 | context: OpenCodeSessionContext, |
| 543 | ).pipe(Effect.ignore({ log: true })); | 635 | event: OpenCodeSubscribedEvent, |
| 544 | yield* Scope.close(context.sessionScope, Exit.void); | 636 | ) { |
| 637 | const payloadSessionId = | ||
| 638 | "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined; | ||
| 639 | if (payloadSessionId !== context.openCodeSessionId) { | ||
| 640 | return; | ||
| 641 | } | ||
| 642 | |||
| 643 | const turnId = context.activeTurnId; | ||
| 644 | yield* writeNativeEventBestEffort(context.session.threadId, { | ||
| 645 | observedAt: nowIso(), | ||
| 646 | event: { | ||
| 647 | provider: PROVIDER, | ||
| 648 | threadId: context.session.threadId, | ||
| 649 | providerThreadId: context.openCodeSessionId, | ||
| 650 | type: event.type, | ||
| 651 | ...(turnId ? { turnId } : {}), | ||
| 652 | payload: event, | ||
| 653 | }, | ||
| 545 | }); | 654 | }); |
| 546 | 655 | ||
| 547 | /** Emit content.delta and item.completed events for an assistant text part. */ | 656 | switch (event.type) { |
| 548 | const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( | 657 | case "message.updated": { |
| 549 | context: OpenCodeSessionContext, | 658 | context.messageRoleById.set(event.properties.info.id, event.properties.info.role); |
| 550 | part: Part, | 659 | if (event.properties.info.role === "assistant") { |
| 551 | turnId: TurnId | undefined, | 660 | for (const part of context.partById.values()) { |
| 552 | raw: unknown, | 661 | if (part.messageID !== event.properties.info.id) { |
| 553 | ) { | 662 | continue; |
| 554 | const text = textFromPart(part); | 663 | } |
| 555 | if (text === undefined) { | 664 | yield* emitAssistantTextDelta(context, part, turnId, event); |
| 556 | return; | 665 | } |
| 666 | } | ||
| 667 | break; | ||
| 557 | } | 668 | } |
| 558 | const previousText = context.emittedTextByPartId.get(part.id); | 669 | |
| 559 | const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); | 670 | case "message.removed": { |
| 560 | context.emittedTextByPartId.set(part.id, latestText); | 671 | context.messageRoleById.delete(event.properties.messageID); |
| 561 | if (latestText !== text) { | 672 | break; |
| 562 | context.partById.set( | ||
| 563 | part.id, | ||
| 564 | (part.type === "text" || part.type === "reasoning" | ||
| 565 | ? { ...part, text: latestText } | ||
| 566 | : part) satisfies Part, | ||
| 567 | ); | ||
| 568 | } | 673 | } |
| 569 | if (deltaToEmit.length > 0) { | 674 | |
| 675 | case "message.part.delta": { | ||
| 676 | const existingPart = context.partById.get(event.properties.partID); | ||
| 677 | if (!existingPart) { | ||
| 678 | break; | ||
| 679 | } | ||
| 680 | const role = messageRoleForPart(context, existingPart); | ||
| 681 | if (role !== "assistant") { | ||
| 682 | break; | ||
| 683 | } | ||
| 684 | const streamKind = resolveTextStreamKind(existingPart); | ||
| 685 | const delta = event.properties.delta; | ||
| 686 | if (delta.length === 0) { | ||
| 687 | break; | ||
| 688 | } | ||
| 689 | const previousText = | ||
| 690 | context.emittedTextByPartId.get(event.properties.partID) ?? | ||
| 691 | textFromPart(existingPart) ?? | ||
| 692 | ""; | ||
| 693 | const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); | ||
| 694 | if (deltaToEmit.length === 0) { | ||
| 695 | break; | ||
| 696 | } | ||
| 697 | context.emittedTextByPartId.set(event.properties.partID, nextText); | ||
| 698 | if (existingPart.type === "text" || existingPart.type === "reasoning") { | ||
| 699 | context.partById.set(event.properties.partID, { | ||
| 700 | ...existingPart, | ||
| 701 | text: nextText, | ||
| 702 | }); | ||
| 703 | } | ||
| 570 | yield* emit({ | 704 | yield* emit({ |
| 571 | ...buildEventBase({ | 705 | ...(yield* buildEventBase({ |
| 572 | threadId: context.session.threadId, | 706 | threadId: context.session.threadId, |
| 573 | turnId, | 707 | turnId, |
| 574 | itemId: part.id, | 708 | itemId: event.properties.partID, |
| 575 | createdAt: | 709 | raw: event, |
| 576 | part.type === "text" || part.type === "reasoning" | 710 | })), |
| 577 | ? isoFromEpochMs(part.time?.start) | ||
| 578 | : undefined, | ||
| 579 | raw, | ||
| 580 | }), | ||
| 581 | type: "content.delta", | 711 | type: "content.delta", |
| 582 | payload: { | 712 | payload: { |
| 583 | streamKind: resolveTextStreamKind(part), | 713 | streamKind, |
| 584 | delta: deltaToEmit, | 714 | delta: deltaToEmit, |
| 585 | }, | 715 | }, |
| 586 | }); | 716 | }); |
| 717 | break; | ||
| 587 | } | 718 | } |
| 588 | 719 | ||
| 589 | if ( | 720 | case "message.part.updated": { |
| 590 | part.type === "text" && | 721 | const part = event.properties.part; |
| 591 | part.time?.end !== undefined && | 722 | context.partById.set(part.id, part); |
| 592 | !context.completedAssistantPartIds.has(part.id) | 723 | const messageRole = messageRoleForPart(context, part); |
| 593 | ) { | 724 | |
| 594 | context.completedAssistantPartIds.add(part.id); | 725 | if (messageRole === "assistant") { |
| 726 | yield* emitAssistantTextDelta(context, part, turnId, event); | ||
| 727 | } | ||
| 728 | |||
| 729 | if (part.type === "tool") { | ||
| 730 | const itemType = toToolLifecycleItemType(part.tool); | ||
| 731 | const title = | ||
| 732 | part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; | ||
| 733 | const detail = detailFromToolPart(part); | ||
| 734 | const payload = { | ||
| 735 | itemType, | ||
| 736 | ...(part.state.status === "error" | ||
| 737 | ? { status: "failed" as const } | ||
| 738 | : part.state.status === "completed" | ||
| 739 | ? { status: "completed" as const } | ||
| 740 | : { status: "inProgress" as const }), | ||
| 741 | ...(title ? { title } : {}), | ||
| 742 | ...(detail ? { detail } : {}), | ||
| 743 | data: { | ||
| 744 | tool: part.tool, | ||
| 745 | state: part.state, | ||
| 746 | }, | ||
| 747 | }; | ||
| 748 | const runtimeEvent: ProviderRuntimeEvent = { | ||
| 749 | ...(yield* buildEventBase({ | ||
| 750 | threadId: context.session.threadId, | ||
| 751 | turnId, | ||
| 752 | itemId: part.callID, | ||
| 753 | createdAt: toolStateCreatedAt(part), | ||
| 754 | raw: event, | ||
| 755 | })), | ||
| 756 | type: | ||
| 757 | part.state.status === "pending" | ||
| 758 | ? "item.started" | ||
| 759 | : part.state.status === "completed" || part.state.status === "error" | ||
| 760 | ? "item.completed" | ||
| 761 | : "item.updated", | ||
| 762 | payload, | ||
| 763 | }; | ||
| 764 | appendTurnItem(context, turnId, part); | ||
| 765 | yield* emit(runtimeEvent); | ||
| 766 | } | ||
| 767 | break; | ||
| 768 | } | ||
| 769 | |||
| 770 | case "permission.asked": { | ||
| 771 | context.pendingPermissions.set(event.properties.id, event.properties); | ||
| 595 | yield* emit({ | 772 | yield* emit({ |
| 596 | ...buildEventBase({ | 773 | ...(yield* buildEventBase({ |
| 597 | threadId: context.session.threadId, | 774 | threadId: context.session.threadId, |
| 598 | turnId, | 775 | turnId, |
| 599 | itemId: part.id, | 776 | requestId: event.properties.id, |
| 600 | createdAt: isoFromEpochMs(part.time.end), | 777 | raw: event, |
| 601 | raw, | 778 | })), |
| 602 | }), | 779 | type: "request.opened", |
| 603 | type: "item.completed", | ||
| 604 | payload: { | 780 | payload: { |
| 605 | itemType: "assistant_message", | 781 | requestType: mapPermissionToRequestType(event.properties.permission), |
| 606 | status: "completed", | 782 | detail: |
| 607 | title: "Assistant message", | 783 | event.properties.patterns.length > 0 |
| 608 | ...(latestText.length > 0 ? { detail: latestText } : {}), | 784 | ? event.properties.patterns.join("\n") |
| 785 | : event.properties.permission, | ||
| 786 | args: event.properties.metadata, | ||
| 609 | }, | 787 | }, |
| 610 | }); | 788 | }); |
| 789 | break; | ||
| 611 | } | 790 | } |
| 612 | }); | ||
| 613 | 791 | ||
| 614 | const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( | 792 | case "permission.replied": { |
| 615 | context: OpenCodeSessionContext, | 793 | context.pendingPermissions.delete(event.properties.requestID); |
| 616 | event: OpenCodeSubscribedEvent, | 794 | yield* emit({ |
| 617 | ) { | 795 | ...(yield* buildEventBase({ |
| 618 | const payloadSessionId = | 796 | threadId: context.session.threadId, |
| 619 | "properties" in event | 797 | turnId, |
| 620 | ? (event.properties as { sessionID?: unknown }).sessionID | 798 | requestId: event.properties.requestID, |
| 621 | : undefined; | 799 | raw: event, |
| 622 | if (payloadSessionId !== context.openCodeSessionId) { | 800 | })), |
| 623 | return; | 801 | type: "request.resolved", |
| 802 | payload: { | ||
| 803 | requestType: "unknown", | ||
| 804 | decision: mapPermissionDecision(event.properties.reply), | ||
| 805 | }, | ||
| 806 | }); | ||
| 807 | break; | ||
| 624 | } | 808 | } |
| 625 | 809 | ||
| 626 | const turnId = context.activeTurnId; | 810 | case "question.asked": { |
| 627 | yield* writeNativeEventBestEffort(context.session.threadId, { | 811 | context.pendingQuestions.set(event.properties.id, event.properties); |
| 628 | observedAt: nowIso(), | 812 | yield* emit({ |
| 629 | event: { | 813 | ...(yield* buildEventBase({ |
| 630 | provider: PROVIDER, | 814 | threadId: context.session.threadId, |
| 631 | threadId: context.session.threadId, | 815 | turnId, |
| 632 | providerThreadId: context.openCodeSessionId, | 816 | requestId: event.properties.id, |
| 633 | type: event.type, | 817 | raw: event, |
| 634 | ...(turnId ? { turnId } : {}), | 818 | })), |
| 635 | payload: event, | 819 | type: "user-input.requested", |
| 636 | }, | 820 | payload: { |
| 637 | }); | 821 | questions: normalizeQuestionRequest(event.properties), |
| 822 | }, | ||
| 823 | }); | ||
| 824 | break; | ||
| 825 | } | ||
| 638 | 826 | ||
| 639 | switch (event.type) { | 827 | case "question.replied": { |
| 640 | case "message.updated": { | 828 | const request = context.pendingQuestions.get(event.properties.requestID); |
| 641 | context.messageRoleById.set(event.properties.info.id, event.properties.info.role); | 829 | context.pendingQuestions.delete(event.properties.requestID); |
| 642 | if (event.properties.info.role === "assistant") { | 830 | const answers = Object.fromEntries( |
| 643 | for (const part of context.partById.values()) { | 831 | (request?.questions ?? []).map((question, index) => [ |
| 644 | if (part.messageID !== event.properties.info.id) { | 832 | openCodeQuestionId(index, question), |
| 645 | continue; | 833 | event.properties.answers[index]?.join(", ") ?? "", |
| 646 | } | 834 | ]), |
| 647 | yield* emitAssistantTextDelta(context, part, turnId, event); | 835 | ); |
| 648 | } | 836 | yield* emit({ |
| 649 | } | 837 | ...(yield* buildEventBase({ |
| 650 | break; | 838 | threadId: context.session.threadId, |
| 651 | } | 839 | turnId, |
| 840 | requestId: event.properties.requestID, | ||
| 841 | raw: event, | ||
| 842 | })), | ||
| 843 | type: "user-input.resolved", | ||
| 844 | payload: { answers }, | ||
| 845 | }); | ||
| 846 | break; | ||
| 847 | } | ||
| 652 | 848 | ||
| 653 | case "message.removed": { | 849 | case "question.rejected": { |
| 654 | context.messageRoleById.delete(event.properties.messageID); | 850 | context.pendingQuestions.delete(event.properties.requestID); |
| 655 | break; | 851 | yield* emit({ |
| 656 | } | 852 | ...(yield* buildEventBase({ |
| 853 | threadId: context.session.threadId, | ||
| 854 | turnId, | ||
| 855 | requestId: event.properties.requestID, | ||
| 856 | raw: event, | ||
| 857 | })), | ||
| 858 | type: "user-input.resolved", | ||
| 859 | payload: { answers: {} }, | ||
| 860 | }); | ||
| 861 | break; | ||
| 862 | } | ||
| 657 | 863 | ||
| 658 | case "message.part.delta": { | 864 | case "session.status": { |
| 659 | const existingPart = context.partById.get(event.properties.partID); | 865 | if (event.properties.status.type === "busy") { |
| 660 | if (!existingPart) { | 866 | updateProviderSession(context, { |
| 661 | break; | 867 | status: "running", |
| 662 | } | 868 | activeTurnId: turnId, |
| 663 | const role = messageRoleForPart(context, existingPart); | ||
| 664 | if (role !== "assistant") { | ||
| 665 | break; | ||
| 666 | } | ||
| 667 | const streamKind = resolveTextStreamKind(existingPart); | ||
| 668 | const delta = event.properties.delta; | ||
| 669 | if (delta.length === 0) { | ||
| 670 | break; | ||
| 671 | } | ||
| 672 | const previousText = | ||
| 673 | context.emittedTextByPartId.get(event.properties.partID) ?? | ||
| 674 | textFromPart(existingPart) ?? | ||
| 675 | ""; | ||
| 676 | const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); | ||
| 677 | if (deltaToEmit.length === 0) { | ||
| 678 | break; | ||
| 679 | } | ||
| 680 | context.emittedTextByPartId.set(event.properties.partID, nextText); | ||
| 681 | if (existingPart.type === "text" || existingPart.type === "reasoning") { | ||
| 682 | context.partById.set(event.properties.partID, { | ||
| 683 | ...existingPart, | ||
| 684 | text: nextText, | ||
| 685 | }); | ||
| 686 | } | ||
| 687 | yield* emit({ | ||
| 688 | ...buildEventBase({ | ||
| 689 | threadId: context.session.threadId, | ||
| 690 | turnId, | ||
| 691 | itemId: event.properties.partID, | ||
| 692 | raw: event, | ||
| 693 | }), | ||
| 694 | type: "content.delta", | ||
| 695 | payload: { | ||
| 696 | streamKind, | ||
| 697 | delta: deltaToEmit, | ||
| 698 | }, | ||
| 699 | }); | 869 | }); |
| 700 | break; | ||
| 701 | } | ||
| 702 | |||
| 703 | case "message.part.updated": { | ||
| 704 | const part = event.properties.part; | ||
| 705 | context.partById.set(part.id, part); | ||
| 706 | const messageRole = messageRoleForPart(context, part); | ||
| 707 | |||
| 708 | if (messageRole === "assistant") { | ||
| 709 | yield* emitAssistantTextDelta(context, part, turnId, event); | ||
| 710 | } | ||
| 711 | |||
| 712 | if (part.type === "tool") { | ||
| 713 | const itemType = toToolLifecycleItemType(part.tool); | ||
| 714 | const title = | ||
| 715 | part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; | ||
| 716 | const detail = detailFromToolPart(part); | ||
| 717 | const payload = { | ||
| 718 | itemType, | ||
| 719 | ...(part.state.status === "error" | ||
| 720 | ? { status: "failed" as const } | ||
| 721 | : part.state.status === "completed" | ||
| 722 | ? { status: "completed" as const } | ||
| 723 | : { status: "inProgress" as const }), | ||
| 724 | ...(title ? { title } : {}), | ||
| 725 | ...(detail ? { detail } : {}), | ||
| 726 | data: { | ||
| 727 | tool: part.tool, | ||
| 728 | state: part.state, | ||
| 729 | }, | ||
| 730 | }; | ||
| 731 | const runtimeEvent: ProviderRuntimeEvent = { | ||
| 732 | ...buildEventBase({ | ||
| 733 | threadId: context.session.threadId, | ||
| 734 | turnId, | ||
| 735 | itemId: part.callID, | ||
| 736 | createdAt: toolStateCreatedAt(part), | ||
| 737 | raw: event, | ||
| 738 | }), | ||
| 739 | type: | ||
| 740 | part.state.status === "pending" | ||
| 741 | ? "item.started" | ||
| 742 | : part.state.status === "completed" || part.state.status === "error" | ||
| 743 | ? "item.completed" | ||
| 744 | : "item.updated", | ||
| 745 | payload, | ||
| 746 | }; | ||
| 747 | appendTurnItem(context, turnId, part); | ||
| 748 | yield* emit(runtimeEvent); | ||
| 749 | } | ||
| 750 | break; | ||
| 751 | } | 870 | } |
| 752 | 871 | ||
| 753 | case "permission.asked": { | 872 | if (event.properties.status.type === "retry") { |
| 754 | context.pendingPermissions.set(event.properties.id, event.properties); | ||
| 755 | yield* emit({ | 873 | yield* emit({ |
| 756 | ...buildEventBase({ | 874 | ...(yield* buildEventBase({ |
| 757 | threadId: context.session.threadId, | 875 | threadId: context.session.threadId, |
| 758 | turnId, | 876 | turnId, |
| 759 | requestId: event.properties.id, | ||
| 760 | raw: event, | 877 | raw: event, |
| 761 | }), | 878 | })), |
| 762 | type: "request.opened", | 879 | type: "runtime.warning", |
| 763 | payload: { | 880 | payload: { |
| 764 | requestType: mapPermissionToRequestType(event.properties.permission), | 881 | message: event.properties.status.message, |
| 765 | detail: | 882 | detail: event.properties.status, |
| 766 | event.properties.patterns.length > 0 | ||
| 767 | ? event.properties.patterns.join("\n") | ||
| 768 | : event.properties.permission, | ||
| 769 | args: event.properties.metadata, | ||
| 770 | }, | 883 | }, |
| 771 | }); | 884 | }); |
| 772 | break; | 885 | break; |
| 773 | } | 886 | } |
| 774 | 887 | ||
| 775 | case "permission.replied": { | 888 | if (event.properties.status.type === "idle" && turnId) { |
| 776 | context.pendingPermissions.delete(event.properties.requestID); | 889 | context.activeTurnId = undefined; |
| 890 | updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); | ||
| 777 | yield* emit({ | 891 | yield* emit({ |
| 778 | ...buildEventBase({ | 892 | ...(yield* buildEventBase({ |
| 779 | threadId: context.session.threadId, | 893 | threadId: context.session.threadId, |
| 780 | turnId, | 894 | turnId, |
| 781 | requestId: event.properties.requestID, | ||
| 782 | raw: event, | 895 | raw: event, |
| 783 | }), | 896 | })), |
| 784 | type: "request.resolved", | 897 | type: "turn.completed", |
| 785 | payload: { | 898 | payload: { |
| 786 | requestType: "unknown", | 899 | state: "completed", |
| 787 | decision: mapPermissionDecision(event.properties.reply), | ||
| 788 | }, | 900 | }, |
| 789 | }); | 901 | }); |
| 790 | break; | ||
| 791 | } | 902 | } |
| 903 | break; | ||
| 904 | } | ||
| 792 | 905 | ||
| 793 | case "question.asked": { | 906 | case "session.error": { |
| 794 | context.pendingQuestions.set(event.properties.id, event.properties); | 907 | const message = sessionErrorMessage(event.properties.error); |
| 908 | const activeTurnId = context.activeTurnId; | ||
| 909 | context.activeTurnId = undefined; | ||
| 910 | updateProviderSession( | ||
| 911 | context, | ||
| 912 | { | ||
| 913 | status: "error", | ||
| 914 | lastError: message, | ||
| 915 | }, | ||
| 916 | { clearActiveTurnId: true }, | ||
| 917 | ); | ||
| 918 | if (activeTurnId) { | ||
| 795 | yield* emit({ | 919 | yield* emit({ |
| 796 | ...buildEventBase({ | 920 | ...(yield* buildEventBase({ |
| 797 | threadId: context.session.threadId, | 921 | threadId: context.session.threadId, |
| 798 | turnId, | 922 | turnId: activeTurnId, |
| 799 | requestId: event.properties.id, | ||
| 800 | raw: event, | 923 | raw: event, |
| 801 | }), | 924 | })), |
| 802 | type: "user-input.requested", | 925 | type: "turn.completed", |
| 803 | payload: { | 926 | payload: { |
| 804 | questions: normalizeQuestionRequest(event.properties), | 927 | state: "failed", |
| 928 | errorMessage: message, | ||
| 805 | }, | 929 | }, |
| 806 | }); | 930 | }); |
| 807 | break; | ||
| 808 | } | 931 | } |
| 932 | yield* emit({ | ||
| 933 | ...(yield* buildEventBase({ | ||
| 934 | threadId: context.session.threadId, | ||
| 935 | raw: event, | ||
| 936 | })), | ||
| 937 | type: "runtime.error", | ||
| 938 | payload: { | ||
| 939 | message, | ||
| 940 | class: "provider_error", | ||
| 941 | detail: event.properties.error, | ||
| 942 | }, | ||
| 943 | }); | ||
| 944 | break; | ||
| 945 | } | ||
| 809 | 946 | ||
| 810 | case "question.replied": { | 947 | default: |
| 811 | const request = context.pendingQuestions.get(event.properties.requestID); | 948 | break; |
| 812 | context.pendingQuestions.delete(event.properties.requestID); | 949 | } |
| 813 | const answers = Object.fromEntries( | 950 | }); |
| 814 | (request?.questions ?? []).map((question, index) => [ | 951 | |
| 815 | openCodeQuestionId(index, question), | 952 | const startEventPump = Effect.fn("startEventPump")(function* (context: OpenCodeSessionContext) { |
| 816 | event.properties.answers[index]?.join(", ") ?? "", | 953 | // One AbortController per session scope. The finalizer fires when |
| 817 | ]), | 954 | // the scope closes (explicit stop, unexpected exit, or layer |
| 818 | ); | 955 | // shutdown) and cancels the in-flight `event.subscribe` fetch so |
| 819 | yield* emit({ | 956 | // the async iterable unwinds cleanly. |
| 820 | ...buildEventBase({ | 957 | const eventsAbortController = new AbortController(); |
| 821 | threadId: context.session.threadId, | 958 | yield* Scope.addFinalizer( |
| 822 | turnId, | 959 | context.sessionScope, |
| 823 | requestId: event.properties.requestID, | 960 | Effect.sync(() => eventsAbortController.abort()), |
| 824 | raw: event, | 961 | ); |
| 825 | }), | ||
| 826 | type: "user-input.resolved", | ||
| 827 | payload: { answers }, | ||
| 828 | }); | ||
| 829 | break; | ||
| 830 | } | ||
| 831 | 962 | ||
| 832 | case "question.rejected": { | 963 | // Fibers forked into `context.sessionScope` are interrupted |
| 833 | context.pendingQuestions.delete(event.properties.requestID); | 964 | // automatically when the scope closes — no bookkeeping required. |
| 834 | yield* emit({ | 965 | yield* Effect.flatMap( |
| 835 | ...buildEventBase({ | 966 | runOpenCodeSdk("event.subscribe", () => |
| 836 | threadId: context.session.threadId, | 967 | context.client.event.subscribe(undefined, { |
| 837 | turnId, | 968 | signal: eventsAbortController.signal, |
| 838 | requestId: event.properties.requestID, | 969 | }), |
| 839 | raw: event, | 970 | ), |
| 971 | (subscription) => | ||
| 972 | Stream.fromAsyncIterable( | ||
| 973 | subscription.stream, | ||
| 974 | (cause) => | ||
| 975 | new OpenCodeRuntimeError({ | ||
| 976 | operation: "event.subscribe", | ||
| 977 | detail: openCodeRuntimeErrorDetail(cause), | ||
| 978 | cause, | ||
| 840 | }), | 979 | }), |
| 841 | type: "user-input.resolved", | 980 | ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), |
| 842 | payload: { answers: {} }, | 981 | ).pipe( |
| 843 | }); | 982 | Effect.exit, |
| 844 | break; | 983 | Effect.flatMap((exit) => |
| 845 | } | 984 | Effect.gen(function* () { |
| 846 | 985 | // Expected paths: caller aborted the fetch or the session | |
| 847 | case "session.status": { | 986 | // has already been marked stopped. Treat as a clean exit. |
| 848 | if (event.properties.status.type === "busy") { | 987 | if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { |
| 849 | updateProviderSession(context, { status: "running", activeTurnId: turnId }); | 988 | return; |
| 850 | } | 989 | } |
| 851 | 990 | if (Exit.isFailure(exit)) { | |
| 852 | if (event.properties.status.type === "retry") { | 991 | yield* emitUnexpectedExit( |
| 853 | yield* emit({ | 992 | context, |
| 854 | ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), | 993 | openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), |
| 855 | type: "runtime.warning", | 994 | ); |
| 856 | payload: { | ||
| 857 | message: event.properties.status.message, | ||
| 858 | detail: event.properties.status, | ||
| 859 | }, | ||
| 860 | }); | ||
| 861 | break; | ||
| 862 | } | ||
| 863 | |||
| 864 | if (event.properties.status.type === "idle" && turnId) { | ||
| 865 | context.activeTurnId = undefined; | ||
| 866 | updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); | ||
| 867 | yield* emit({ | ||
| 868 | ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), | ||
| 869 | type: "turn.completed", | ||
| 870 | payload: { | ||
| 871 | state: "completed", | ||
| 872 | }, | ||
| 873 | }); | ||
| 874 | } | ||
| 875 | break; | ||
| 876 | } | ||
| 877 | |||
| 878 | case "session.error": { | ||
| 879 | const message = sessionErrorMessage(event.properties.error); | ||
| 880 | const activeTurnId = context.activeTurnId; | ||
| 881 | context.activeTurnId = undefined; | ||
| 882 | updateProviderSession( | ||
| 883 | context, | ||
| 884 | { | ||
| 885 | status: "error", | ||
| 886 | lastError: message, | ||
| 887 | }, | ||
| 888 | { clearActiveTurnId: true }, | ||
| 889 | ); | ||
| 890 | if (activeTurnId) { | ||
| 891 | yield* emit({ | ||
| 892 | ...buildEventBase({ | ||
| 893 | threadId: context.session.threadId, | ||
| 894 | turnId: activeTurnId, | ||
| 895 | raw: event, | ||
| 896 | }), | ||
| 897 | type: "turn.completed", | ||
| 898 | payload: { | ||
| 899 | state: "failed", | ||
| 900 | errorMessage: message, | ||
| 901 | }, | ||
| 902 | }); | ||
| 903 | } | 995 | } |
| 904 | yield* emit({ | 996 | }), |
| 905 | ...buildEventBase({ threadId: context.session.threadId, raw: event }), | 997 | ), |
| 906 | type: "runtime.error", | 998 | Effect.forkIn(context.sessionScope), |
| 907 | payload: { | 999 | ); |
| 908 | message, | ||
| 909 | class: "provider_error", | ||
| 910 | detail: event.properties.error, | ||
| 911 | }, | ||
| 912 | }); | ||
| 913 | break; | ||
| 914 | } | ||
| 915 | |||
| 916 | default: | ||
| 917 | break; | ||
| 918 | } | ||
| 919 | }); | ||
| 920 | |||
| 921 | const startEventPump = Effect.fn("startEventPump")(function* ( | ||
| 922 | context: OpenCodeSessionContext, | ||
| 923 | ) { | ||
| 924 | // One AbortController per session scope. The finalizer fires when | ||
| 925 | // the scope closes (explicit stop, unexpected exit, or layer | ||
| 926 | // shutdown) and cancels the in-flight `event.subscribe` fetch so | ||
| 927 | // the async iterable unwinds cleanly. | ||
| 928 | const eventsAbortController = new AbortController(); | ||
| 929 | yield* Scope.addFinalizer( | ||
| 930 | context.sessionScope, | ||
| 931 | Effect.sync(() => eventsAbortController.abort()), | ||
| 932 | ); | ||
| 933 | 1000 | ||
| 934 | // Fibers forked into `context.sessionScope` are interrupted | 1001 | if (!context.server.external && context.server.exitCode !== null) { |
| 935 | // automatically when the scope closes — no bookkeeping required. | 1002 | yield* context.server.exitCode.pipe( |
| 936 | yield* Effect.flatMap( | 1003 | Effect.flatMap((code) => |
| 937 | runOpenCodeSdk("event.subscribe", () => | ||
| 938 | context.client.event.subscribe(undefined, { | ||
| 939 | signal: eventsAbortController.signal, | ||
| 940 | }), | ||
| 941 | ), | ||
| 942 | (subscription) => | ||
| 943 | Stream.fromAsyncIterable( | ||
| 944 | subscription.stream, | ||
| 945 | (cause) => | ||
| 946 | new OpenCodeRuntimeError({ | ||
| 947 | operation: "event.subscribe", | ||
| 948 | detail: openCodeRuntimeErrorDetail(cause), | ||
| 949 | cause, | ||
| 950 | }), | ||
| 951 | ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), | ||
| 952 | ).pipe( | ||
| 953 | Effect.exit, | ||
| 954 | Effect.flatMap((exit) => | ||
| 955 | Effect.gen(function* () { | 1004 | Effect.gen(function* () { |
| 956 | // Expected paths: caller aborted the fetch or the session | 1005 | if (yield* Ref.get(context.stopped)) { |
| 957 | // has already been marked stopped. Treat as a clean exit. | ||
| 958 | if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { | ||
| 959 | return; | 1006 | return; |
| 960 | } | 1007 | } |
| 961 | if (Exit.isFailure(exit)) { | 1008 | yield* emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`); |
| 962 | yield* emitUnexpectedExit( | ||
| 963 | context, | ||
| 964 | openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), | ||
| 965 | ); | ||
| 966 | } | ||
| 967 | }), | 1009 | }), |
| 968 | ), | 1010 | ), |
| 969 | Effect.forkIn(context.sessionScope), | 1011 | Effect.forkIn(context.sessionScope), |
| 970 | ); | 1012 | ); |
| 971 | 1013 | } | |
| 972 | if (!context.server.external && context.server.exitCode !== null) { | 1014 | }); |
| 973 | yield* context.server.exitCode.pipe( | 1015 | |
| 974 | Effect.flatMap((code) => | 1016 | const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( |
| 975 | Effect.gen(function* () { | 1017 | function* (input) { |
| 976 | if (yield* Ref.get(context.stopped)) { | 1018 | const binaryPath = openCodeSettings.binaryPath; |
| 977 | return; | 1019 | const serverUrl = openCodeSettings.serverUrl; |
| 978 | } | 1020 | const serverPassword = openCodeSettings.serverPassword; |
| 979 | yield* emitUnexpectedExit( | 1021 | const directory = input.cwd ?? serverConfig.cwd; |
| 980 | context, | 1022 | const existing = sessions.get(input.threadId); |
| 981 | `OpenCode server exited unexpectedly (${code}).`, | 1023 | if (existing) { |
| 982 | ); | 1024 | yield* stopOpenCodeContext(existing); |
| 983 | }), | 1025 | sessions.delete(input.threadId); |
| 984 | ), | ||
| 985 | Effect.forkIn(context.sessionScope), | ||
| 986 | ); | ||
| 987 | } | 1026 | } |
| 988 | }); | ||
| 989 | 1027 | ||
| 990 | const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( | 1028 | const started = yield* Effect.gen(function* () { |
| 991 | function* (input) { | 1029 | const sessionScope = yield* Scope.make(); |
| 992 | const settings = yield* serverSettings.getSettings.pipe( | 1030 | const startedExit = yield* Effect.exit( |
| 993 | Effect.mapError( | 1031 | Effect.gen(function* () { |
| 994 | (cause) => | 1032 | // The runtime binds the server's lifetime to the Scope.Scope |
| 995 | new ProviderAdapterProcessError({ | 1033 | // we provide below — closing `sessionScope` kills the child |
| 996 | provider: PROVIDER, | 1034 | // process automatically. No manual `server.close()` needed. |
| 997 | threadId: input.threadId, | 1035 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ |
| 998 | detail: "Failed to read OpenCode settings.", | 1036 | binaryPath, |
| 999 | cause, | 1037 | serverUrl, |
| 1038 | ...(options?.environment ? { environment: options.environment } : {}), | ||
| 1039 | }); | ||
| 1040 | const client = openCodeRuntime.createOpenCodeSdkClient({ | ||
| 1041 | baseUrl: server.url, | ||
| 1042 | directory, | ||
| 1043 | ...(server.external && serverPassword ? { serverPassword } : {}), | ||
| 1044 | }); | ||
| 1045 | const openCodeSession = yield* runOpenCodeSdk("session.create", () => | ||
| 1046 | client.session.create({ | ||
| 1047 | title: `T3 Code ${input.threadId}`, | ||
| 1048 | permission: buildOpenCodePermissionRules(input.runtimeMode), | ||
| 1000 | }), | 1049 | }), |
| 1001 | ), | 1050 | ); |
| 1002 | ); | 1051 | if (!openCodeSession.data) { |
| 1003 | const binaryPath = settings.providers.opencode.binaryPath; | 1052 | return yield* new OpenCodeRuntimeError({ |
| 1004 | const serverUrl = settings.providers.opencode.serverUrl; | 1053 | operation: "session.create", |
| 1005 | const serverPassword = settings.providers.opencode.serverPassword; | 1054 | detail: "OpenCode session.create returned no session payload.", |
| 1006 | const directory = input.cwd ?? serverConfig.cwd; | ||
| 1007 | const existing = sessions.get(input.threadId); | ||
| 1008 | if (existing) { | ||
| 1009 | yield* stopOpenCodeContext(existing); | ||
| 1010 | sessions.delete(input.threadId); | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | const started = yield* Effect.gen(function* () { | ||
| 1014 | const sessionScope = yield* Scope.make(); | ||
| 1015 | const startedExit = yield* Effect.exit( | ||
| 1016 | Effect.gen(function* () { | ||
| 1017 | // The runtime binds the server's lifetime to the Scope.Scope | ||
| 1018 | // we provide below — closing `sessionScope` kills the child | ||
| 1019 | // process automatically. No manual `server.close()` needed. | ||
| 1020 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | ||
| 1021 | binaryPath, | ||
| 1022 | serverUrl, | ||
| 1023 | }); | ||
| 1024 | const client = openCodeRuntime.createOpenCodeSdkClient({ | ||
| 1025 | baseUrl: server.url, | ||
| 1026 | directory, | ||
| 1027 | ...(server.external && serverPassword ? { serverPassword } : {}), | ||
| 1028 | }); | 1055 | }); |
| 1029 | const openCodeSession = yield* runOpenCodeSdk("session.create", () => | 1056 | } |
| 1030 | client.session.create({ | 1057 | return { |
| 1031 | title: `T3 Code ${input.threadId}`, | 1058 | sessionScope, |
| 1032 | permission: buildOpenCodePermissionRules(input.runtimeMode), | 1059 | server, |
| 1033 | }), | 1060 | client, |
| 1034 | ); | 1061 | openCodeSession: openCodeSession.data, |
| 1035 | if (!openCodeSession.data) { | 1062 | }; |
| 1036 | return yield* new OpenCodeRuntimeError({ | 1063 | }).pipe(Effect.provideService(Scope.Scope, sessionScope)), |
| 1037 | operation: "session.create", | 1064 | ); |
| 1038 | detail: "OpenCode session.create returned no session payload.", | 1065 | if (Exit.isFailure(startedExit)) { |
| 1039 | }); | 1066 | yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); |
| 1040 | } | 1067 | return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); |
| 1041 | return { sessionScope, server, client, openCodeSession: openCodeSession.data }; | ||
| 1042 | }).pipe(Effect.provideService(Scope.Scope, sessionScope)), | ||
| 1043 | ); | ||
| 1044 | if (Exit.isFailure(startedExit)) { | ||
| 1045 | yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); | ||
| 1046 | return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); | ||
| 1047 | } | ||
| 1048 | return startedExit.value; | ||
| 1049 | }); | ||
| 1050 | |||
| 1051 | // Guard against a concurrent startSession call that may have raced | ||
| 1052 | // and already inserted a session while we were awaiting async work. | ||
| 1053 | const raceWinner = sessions.get(input.threadId); | ||
| 1054 | if (raceWinner) { | ||
| 1055 | // Another call won the race – clean up the session we just created | ||
| 1056 | // (including the remote SDK session) and return the existing one. | ||
| 1057 | yield* runOpenCodeSdk("session.abort", () => | ||
| 1058 | started.client.session.abort({ sessionID: started.openCodeSession.id }), | ||
| 1059 | ).pipe(Effect.ignore); | ||
| 1060 | yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); | ||
| 1061 | return raceWinner.session; | ||
| 1062 | } | 1068 | } |
| 1069 | return startedExit.value; | ||
| 1070 | }); | ||
| 1063 | 1071 | ||
| 1064 | const createdAt = nowIso(); | 1072 | // Guard against a concurrent startSession call that may have raced |
| 1065 | const session: ProviderSession = { | 1073 | // and already inserted a session while we were awaiting async work. |
| 1066 | provider: PROVIDER, | 1074 | const raceWinner = sessions.get(input.threadId); |
| 1067 | status: "ready", | 1075 | if (raceWinner) { |
| 1068 | runtimeMode: input.runtimeMode, | 1076 | // Another call won the race – clean up the session we just created |
| 1069 | cwd: directory, | 1077 | // (including the remote SDK session) and return the existing one. |
| 1070 | ...(input.modelSelection ? { model: input.modelSelection.model } : {}), | 1078 | yield* runOpenCodeSdk("session.abort", () => |
| 1071 | threadId: input.threadId, | 1079 | started.client.session.abort({ |
| 1072 | createdAt, | 1080 | sessionID: started.openCodeSession.id, |
| 1073 | updatedAt: createdAt, | 1081 | }), |
| 1074 | }; | 1082 | ).pipe(Effect.ignore); |
| 1075 | 1083 | yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); | |
| 1076 | const context: OpenCodeSessionContext = { | 1084 | return raceWinner.session; |
| 1077 | session, | ||
| 1078 | client: started.client, | ||
| 1079 | server: started.server, | ||
| 1080 | directory, | ||
| 1081 | openCodeSessionId: started.openCodeSession.id, | ||
| 1082 | pendingPermissions: new Map(), | ||
| 1083 | pendingQuestions: new Map(), | ||
| 1084 | partById: new Map(), | ||
| 1085 | emittedTextByPartId: new Map(), | ||
| 1086 | messageRoleById: new Map(), | ||
| 1087 | completedAssistantPartIds: new Set(), | ||
| 1088 | turns: [], | ||
| 1089 | activeTurnId: undefined, | ||
| 1090 | activeAgent: undefined, | ||
| 1091 | activeVariant: undefined, | ||
| 1092 | stopped: yield* Ref.make(false), | ||
| 1093 | sessionScope: started.sessionScope, | ||
| 1094 | }; | ||
| 1095 | sessions.set(input.threadId, context); | ||
| 1096 | yield* startEventPump(context); | ||
| 1097 | |||
| 1098 | yield* emit({ | ||
| 1099 | ...buildEventBase({ threadId: input.threadId }), | ||
| 1100 | type: "session.started", | ||
| 1101 | payload: { | ||
| 1102 | message: "OpenCode session started", | ||
| 1103 | }, | ||
| 1104 | }); | ||
| 1105 | yield* emit({ | ||
| 1106 | ...buildEventBase({ threadId: input.threadId }), | ||
| 1107 | type: "thread.started", | ||
| 1108 | payload: { | ||
| 1109 | providerThreadId: started.openCodeSession.id, | ||
| 1110 | }, | ||
| 1111 | }); | ||
| 1112 | |||
| 1113 | return session; | ||
| 1114 | }, | ||
| 1115 | ); | ||
| 1116 | |||
| 1117 | const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { | ||
| 1118 | const context = ensureSessionContext(sessions, input.threadId); | ||
| 1119 | const turnId = TurnId.make(`opencode-turn-${randomUUID()}`); | ||
| 1120 | const modelSelection = | ||
| 1121 | input.modelSelection ?? | ||
| 1122 | (context.session.model | ||
| 1123 | ? { provider: PROVIDER, model: context.session.model } | ||
| 1124 | : undefined); | ||
| 1125 | const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); | ||
| 1126 | if (!parsedModel) { | ||
| 1127 | return yield* new ProviderAdapterValidationError({ | ||
| 1128 | provider: PROVIDER, | ||
| 1129 | operation: "sendTurn", | ||
| 1130 | issue: "OpenCode model selection must use the 'provider/model' format.", | ||
| 1131 | }); | ||
| 1132 | } | 1085 | } |
| 1133 | 1086 | ||
| 1134 | const text = input.input?.trim(); | 1087 | const createdAt = nowIso(); |
| 1135 | const fileParts = toOpenCodeFileParts({ | 1088 | const session: ProviderSession = { |
| 1136 | attachments: input.attachments, | 1089 | provider: PROVIDER, |
| 1137 | resolveAttachmentPath: (attachment) => | 1090 | providerInstanceId: boundInstanceId, |
| 1138 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | 1091 | status: "ready", |
| 1139 | }); | 1092 | runtimeMode: input.runtimeMode, |
| 1140 | if ((!text || text.length === 0) && fileParts.length === 0) { | 1093 | cwd: directory, |
| 1141 | return yield* new ProviderAdapterValidationError({ | 1094 | ...(input.modelSelection ? { model: input.modelSelection.model } : {}), |
| 1142 | provider: PROVIDER, | 1095 | threadId: input.threadId, |
| 1143 | operation: "sendTurn", | 1096 | createdAt, |
| 1144 | issue: "OpenCode turns require text input or at least one attachment.", | 1097 | updatedAt: createdAt, |
| 1145 | }); | 1098 | }; |
| 1146 | } | ||
| 1147 | 1099 | ||
| 1148 | const agent = | 1100 | const context: OpenCodeSessionContext = { |
| 1149 | input.modelSelection?.provider === PROVIDER | 1101 | session, |
| 1150 | ? getModelSelectionStringOptionValue(input.modelSelection, "agent") | 1102 | client: started.client, |
| 1151 | : undefined; | 1103 | server: started.server, |
| 1152 | const variant = | 1104 | directory, |
| 1153 | input.modelSelection?.provider === PROVIDER | 1105 | openCodeSessionId: started.openCodeSession.id, |
| 1154 | ? getModelSelectionStringOptionValue(input.modelSelection, "variant") | 1106 | pendingPermissions: new Map(), |
| 1155 | : undefined; | 1107 | pendingQuestions: new Map(), |
| 1156 | 1108 | partById: new Map(), | |
| 1157 | context.activeTurnId = turnId; | 1109 | emittedTextByPartId: new Map(), |
| 1158 | context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); | 1110 | messageRoleById: new Map(), |
| 1159 | context.activeVariant = variant; | 1111 | completedAssistantPartIds: new Set(), |
| 1160 | updateProviderSession( | 1112 | turns: [], |
| 1161 | context, | 1113 | activeTurnId: undefined, |
| 1162 | { | 1114 | activeAgent: undefined, |
| 1163 | status: "running", | 1115 | activeVariant: undefined, |
| 1164 | activeTurnId: turnId, | 1116 | stopped: yield* Ref.make(false), |
| 1165 | model: modelSelection?.model ?? context.session.model, | 1117 | sessionScope: started.sessionScope, |
| 1166 | }, | 1118 | }; |
| 1167 | { clearLastError: true }, | 1119 | sessions.set(input.threadId, context); |
| 1168 | ); | 1120 | yield* startEventPump(context); |
| 1169 | 1121 | ||
| 1170 | yield* emit({ | 1122 | yield* emit({ |
| 1171 | ...buildEventBase({ threadId: input.threadId, turnId }), | 1123 | ...(yield* buildEventBase({ threadId: input.threadId })), |
| 1172 | type: "turn.started", | 1124 | type: "session.started", |
| 1173 | payload: { | 1125 | payload: { |
| 1174 | model: modelSelection?.model ?? context.session.model, | 1126 | message: "OpenCode session started", |
| 1175 | ...(variant ? { effort: variant } : {}), | 1127 | }, |
| 1128 | }); | ||
| 1129 | yield* emit({ | ||
| 1130 | ...(yield* buildEventBase({ threadId: input.threadId })), | ||
| 1131 | type: "thread.started", | ||
| 1132 | payload: { | ||
| 1133 | providerThreadId: started.openCodeSession.id, | ||
| 1176 | }, | 1134 | }, |
| 1177 | }); | 1135 | }); |
| 1178 | 1136 | ||
| 1179 | yield* runOpenCodeSdk("session.promptAsync", () => | 1137 | return session; |
| 1180 | context.client.session.promptAsync({ | 1138 | }, |
| 1181 | sessionID: context.openCodeSessionId, | 1139 | ); |
| 1182 | model: parsedModel, | 1140 | |
| 1183 | ...(context.activeAgent ? { agent: context.activeAgent } : {}), | 1141 | const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { |
| 1184 | ...(context.activeVariant ? { variant: context.activeVariant } : {}), | 1142 | const context = ensureSessionContext(sessions, input.threadId); |
| 1185 | parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], | 1143 | const turnId = TurnId.make(`opencode-turn-${yield* Random.nextUUIDv4}`); |
| 1144 | const modelSelection = | ||
| 1145 | input.modelSelection ?? | ||
| 1146 | (context.session.model | ||
| 1147 | ? { instanceId: boundInstanceId, model: context.session.model } | ||
| 1148 | : undefined); | ||
| 1149 | if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { | ||
| 1150 | return yield* new ProviderAdapterValidationError({ | ||
| 1151 | provider: PROVIDER, | ||
| 1152 | operation: "sendTurn", | ||
| 1153 | issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, | ||
| 1154 | }); | ||
| 1155 | } | ||
| 1156 | const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); | ||
| 1157 | if (!parsedModel) { | ||
| 1158 | return yield* new ProviderAdapterValidationError({ | ||
| 1159 | provider: PROVIDER, | ||
| 1160 | operation: "sendTurn", | ||
| 1161 | issue: "OpenCode model selection must use the 'provider/model' format.", | ||
| 1162 | }); | ||
| 1163 | } | ||
| 1164 | |||
| 1165 | const text = input.input?.trim(); | ||
| 1166 | const fileParts = toOpenCodeFileParts({ | ||
| 1167 | attachments: input.attachments, | ||
| 1168 | resolveAttachmentPath: (attachment) => | ||
| 1169 | resolveAttachmentPath({ | ||
| 1170 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 1171 | attachment, | ||
| 1186 | }), | 1172 | }), |
| 1187 | ).pipe( | 1173 | }); |
| 1188 | Effect.mapError(toRequestError), | 1174 | if ((!text || text.length === 0) && fileParts.length === 0) { |
| 1189 | // On failure: clear active-turn state, flip the session back to ready | 1175 | return yield* new ProviderAdapterValidationError({ |
| 1190 | // with lastError set, emit turn.aborted, then let the typed error | 1176 | provider: PROVIDER, |
| 1191 | // propagate. We don't need to rebuild the error here — `toRequestError` | 1177 | operation: "sendTurn", |
| 1192 | // already produced the right shape. | 1178 | issue: "OpenCode turns require text input or at least one attachment.", |
| 1193 | Effect.tapError((requestError) => | 1179 | }); |
| 1194 | Effect.gen(function* () { | 1180 | } |
| 1195 | context.activeTurnId = undefined; | 1181 | |
| 1196 | context.activeAgent = undefined; | 1182 | const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); |
| 1197 | context.activeVariant = undefined; | 1183 | const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); |
| 1198 | updateProviderSession( | 1184 | |
| 1199 | context, | 1185 | context.activeTurnId = turnId; |
| 1200 | { | 1186 | context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); |
| 1201 | status: "ready", | 1187 | context.activeVariant = variant; |
| 1202 | model: modelSelection?.model ?? context.session.model, | 1188 | updateProviderSession( |
| 1203 | lastError: requestError.detail, | 1189 | context, |
| 1204 | }, | 1190 | { |
| 1205 | { clearActiveTurnId: true }, | 1191 | status: "running", |
| 1206 | ); | 1192 | activeTurnId: turnId, |
| 1207 | yield* emit({ | 1193 | model: modelSelection?.model ?? context.session.model, |
| 1208 | ...buildEventBase({ threadId: input.threadId, turnId }), | 1194 | }, |
| 1209 | type: "turn.aborted", | 1195 | { clearLastError: true }, |
| 1210 | payload: { | 1196 | ); |
| 1211 | reason: requestError.detail, | ||
| 1212 | }, | ||
| 1213 | }); | ||
| 1214 | }), | ||
| 1215 | ), | ||
| 1216 | ); | ||
| 1217 | 1197 | ||
| 1218 | return { | 1198 | yield* emit({ |
| 1219 | threadId: input.threadId, | 1199 | ...(yield* buildEventBase({ threadId: input.threadId, turnId })), |
| 1220 | turnId, | 1200 | type: "turn.started", |
| 1221 | }; | 1201 | payload: { |
| 1202 | model: modelSelection?.model ?? context.session.model, | ||
| 1203 | ...(variant ? { effort: variant } : {}), | ||
| 1204 | }, | ||
| 1222 | }); | 1205 | }); |
| 1223 | 1206 | ||
| 1224 | const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( | 1207 | yield* runOpenCodeSdk("session.promptAsync", () => |
| 1225 | function* (threadId, turnId) { | 1208 | context.client.session.promptAsync({ |
| 1226 | const context = ensureSessionContext(sessions, threadId); | 1209 | sessionID: context.openCodeSessionId, |
| 1227 | yield* runOpenCodeSdk("session.abort", () => | 1210 | model: parsedModel, |
| 1228 | context.client.session.abort({ sessionID: context.openCodeSessionId }), | 1211 | ...(context.activeAgent ? { agent: context.activeAgent } : {}), |
| 1229 | ).pipe(Effect.mapError(toRequestError)); | 1212 | ...(context.activeVariant ? { variant: context.activeVariant } : {}), |
| 1230 | if (turnId ?? context.activeTurnId) { | 1213 | parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], |
| 1214 | }), | ||
| 1215 | ).pipe( | ||
| 1216 | Effect.mapError(toRequestError), | ||
| 1217 | // On failure: clear active-turn state, flip the session back to ready | ||
| 1218 | // with lastError set, emit turn.aborted, then let the typed error | ||
| 1219 | // propagate. We don't need to rebuild the error here — `toRequestError` | ||
| 1220 | // already produced the right shape. | ||
| 1221 | Effect.tapError((requestError) => | ||
| 1222 | Effect.gen(function* () { | ||
| 1223 | context.activeTurnId = undefined; | ||
| 1224 | context.activeAgent = undefined; | ||
| 1225 | context.activeVariant = undefined; | ||
| 1226 | updateProviderSession( | ||
| 1227 | context, | ||
| 1228 | { | ||
| 1229 | status: "ready", | ||
| 1230 | model: modelSelection?.model ?? context.session.model, | ||
| 1231 | lastError: requestError.detail, | ||
| 1232 | }, | ||
| 1233 | { clearActiveTurnId: true }, | ||
| 1234 | ); | ||
| 1231 | yield* emit({ | 1235 | yield* emit({ |
| 1232 | ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), | 1236 | ...(yield* buildEventBase({ |
| 1237 | threadId: input.threadId, | ||
| 1238 | turnId, | ||
| 1239 | })), | ||
| 1233 | type: "turn.aborted", | 1240 | type: "turn.aborted", |
| 1234 | payload: { | 1241 | payload: { |
| 1235 | reason: "Interrupted by user.", | 1242 | reason: requestError.detail, |
| 1236 | }, | 1243 | }, |
| 1237 | }); | 1244 | }); |
| 1238 | } | 1245 | }), |
| 1239 | }, | 1246 | ), |
| 1240 | ); | 1247 | ); |
| 1241 | 1248 | ||
| 1242 | const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( | 1249 | return { |
| 1243 | "respondToRequest", | 1250 | threadId: input.threadId, |
| 1244 | )(function* (threadId, requestId, decision) { | 1251 | turnId, |
| 1245 | const context = ensureSessionContext(sessions, threadId); | 1252 | }; |
| 1246 | if (!context.pendingPermissions.has(requestId)) { | 1253 | }); |
| 1247 | return yield* new ProviderAdapterRequestError({ | ||
| 1248 | provider: PROVIDER, | ||
| 1249 | method: "permission.reply", | ||
| 1250 | detail: `Unknown pending permission request: ${requestId}`, | ||
| 1251 | }); | ||
| 1252 | } | ||
| 1253 | |||
| 1254 | yield* runOpenCodeSdk("permission.reply", () => | ||
| 1255 | context.client.permission.reply({ | ||
| 1256 | requestID: requestId, | ||
| 1257 | reply: toOpenCodePermissionReply(decision), | ||
| 1258 | }), | ||
| 1259 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1260 | }); | ||
| 1261 | 1254 | ||
| 1262 | const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( | 1255 | const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 1263 | "respondToUserInput", | 1256 | function* (threadId, turnId) { |
| 1264 | )(function* (threadId, requestId, answers) { | ||
| 1265 | const context = ensureSessionContext(sessions, threadId); | 1257 | const context = ensureSessionContext(sessions, threadId); |
| 1266 | const request = context.pendingQuestions.get(requestId); | 1258 | yield* runOpenCodeSdk("session.abort", () => |
| 1267 | if (!request) { | 1259 | context.client.session.abort({ sessionID: context.openCodeSessionId }), |
| 1268 | return yield* new ProviderAdapterRequestError({ | ||
| 1269 | provider: PROVIDER, | ||
| 1270 | method: "question.reply", | ||
| 1271 | detail: `Unknown pending user-input request: ${requestId}`, | ||
| 1272 | }); | ||
| 1273 | } | ||
| 1274 | |||
| 1275 | yield* runOpenCodeSdk("question.reply", () => | ||
| 1276 | context.client.question.reply({ | ||
| 1277 | requestID: requestId, | ||
| 1278 | answers: toOpenCodeQuestionAnswers(request, answers), | ||
| 1279 | }), | ||
| 1280 | ).pipe(Effect.mapError(toRequestError)); | 1260 | ).pipe(Effect.mapError(toRequestError)); |
| 1281 | }); | 1261 | if (turnId ?? context.activeTurnId) { |
| 1282 | |||
| 1283 | const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( | ||
| 1284 | function* (threadId) { | ||
| 1285 | const context = ensureSessionContext(sessions, threadId); | ||
| 1286 | yield* stopOpenCodeContext(context); | ||
| 1287 | sessions.delete(threadId); | ||
| 1288 | yield* emit({ | 1262 | yield* emit({ |
| 1289 | ...buildEventBase({ threadId }), | 1263 | ...(yield* buildEventBase({ |
| 1290 | type: "session.exited", | 1264 | threadId, |
| 1265 | turnId: turnId ?? context.activeTurnId, | ||
| 1266 | })), | ||
| 1267 | type: "turn.aborted", | ||
| 1291 | payload: { | 1268 | payload: { |
| 1292 | reason: "Session stopped.", | 1269 | reason: "Interrupted by user.", |
| 1293 | recoverable: false, | ||
| 1294 | exitKind: "graceful", | ||
| 1295 | }, | 1270 | }, |
| 1296 | }); | 1271 | }); |
| 1297 | }, | 1272 | } |
| 1298 | ); | 1273 | }, |
| 1274 | ); | ||
| 1275 | |||
| 1276 | const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( | ||
| 1277 | "respondToRequest", | ||
| 1278 | )(function* (threadId, requestId, decision) { | ||
| 1279 | const context = ensureSessionContext(sessions, threadId); | ||
| 1280 | if (!context.pendingPermissions.has(requestId)) { | ||
| 1281 | return yield* new ProviderAdapterRequestError({ | ||
| 1282 | provider: PROVIDER, | ||
| 1283 | method: "permission.reply", | ||
| 1284 | detail: `Unknown pending permission request: ${requestId}`, | ||
| 1285 | }); | ||
| 1286 | } | ||
| 1287 | |||
| 1288 | yield* runOpenCodeSdk("permission.reply", () => | ||
| 1289 | context.client.permission.reply({ | ||
| 1290 | requestID: requestId, | ||
| 1291 | reply: toOpenCodePermissionReply(decision), | ||
| 1292 | }), | ||
| 1293 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1294 | }); | ||
| 1295 | |||
| 1296 | const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( | ||
| 1297 | "respondToUserInput", | ||
| 1298 | )(function* (threadId, requestId, answers) { | ||
| 1299 | const context = ensureSessionContext(sessions, threadId); | ||
| 1300 | const request = context.pendingQuestions.get(requestId); | ||
| 1301 | if (!request) { | ||
| 1302 | return yield* new ProviderAdapterRequestError({ | ||
| 1303 | provider: PROVIDER, | ||
| 1304 | method: "question.reply", | ||
| 1305 | detail: `Unknown pending user-input request: ${requestId}`, | ||
| 1306 | }); | ||
| 1307 | } | ||
| 1299 | 1308 | ||
| 1300 | const listSessions: OpenCodeAdapterShape["listSessions"] = () => | 1309 | yield* runOpenCodeSdk("question.reply", () => |
| 1301 | Effect.sync(() => [...sessions.values()].map((context) => context.session)); | 1310 | context.client.question.reply({ |
| 1311 | requestID: requestId, | ||
| 1312 | answers: toOpenCodeQuestionAnswers(request, answers), | ||
| 1313 | }), | ||
| 1314 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1315 | }); | ||
| 1316 | |||
| 1317 | const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( | ||
| 1318 | function* (threadId) { | ||
| 1319 | const context = sessions.get(threadId); | ||
| 1320 | if (!context) { | ||
| 1321 | throw new ProviderAdapterSessionNotFoundError({ | ||
| 1322 | provider: PROVIDER, | ||
| 1323 | threadId, | ||
| 1324 | }); | ||
| 1325 | } | ||
| 1326 | const stopped = yield* stopOpenCodeContext(context); | ||
| 1327 | sessions.delete(threadId); | ||
| 1328 | if (!stopped) { | ||
| 1329 | return; | ||
| 1330 | } | ||
| 1331 | yield* emit({ | ||
| 1332 | ...(yield* buildEventBase({ threadId })), | ||
| 1333 | type: "session.exited", | ||
| 1334 | payload: { | ||
| 1335 | reason: "Session stopped.", | ||
| 1336 | recoverable: false, | ||
| 1337 | exitKind: "graceful", | ||
| 1338 | }, | ||
| 1339 | }); | ||
| 1340 | }, | ||
| 1341 | ); | ||
| 1302 | 1342 | ||
| 1303 | const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => | 1343 | const listSessions: OpenCodeAdapterShape["listSessions"] = () => |
| 1304 | Effect.sync(() => sessions.has(threadId)); | 1344 | Effect.sync(() => [...sessions.values()].map((context) => context.session)); |
| 1305 | 1345 | ||
| 1306 | const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( | 1346 | const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => |
| 1307 | function* (threadId) { | 1347 | Effect.sync(() => sessions.has(threadId)); |
| 1308 | const context = ensureSessionContext(sessions, threadId); | ||
| 1309 | const messages = yield* runOpenCodeSdk("session.messages", () => | ||
| 1310 | context.client.session.messages({ sessionID: context.openCodeSessionId }), | ||
| 1311 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1312 | 1348 | ||
| 1313 | const turns = (messages.data ?? []) | 1349 | const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( |
| 1314 | .filter((entry) => entry.info.role === "assistant") | 1350 | function* (threadId) { |
| 1315 | .map((entry) => ({ | 1351 | const context = ensureSessionContext(sessions, threadId); |
| 1316 | id: TurnId.make(entry.info.id), | 1352 | const messages = yield* runOpenCodeSdk("session.messages", () => |
| 1317 | items: [entry.info, ...entry.parts], | 1353 | context.client.session.messages({ |
| 1318 | })); | 1354 | sessionID: context.openCodeSessionId, |
| 1355 | }), | ||
| 1356 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1319 | 1357 | ||
| 1320 | return { | 1358 | const turns = (messages.data ?? []) |
| 1321 | threadId, | 1359 | .filter((entry) => entry.info.role === "assistant") |
| 1322 | turns, | 1360 | .map((entry) => ({ |
| 1323 | }; | 1361 | id: TurnId.make(entry.info.id), |
| 1324 | }, | 1362 | items: [entry.info, ...entry.parts], |
| 1325 | ); | 1363 | })); |
| 1326 | 1364 | ||
| 1327 | const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( | 1365 | return { |
| 1328 | function* (threadId, numTurns) { | 1366 | threadId, |
| 1329 | const context = ensureSessionContext(sessions, threadId); | 1367 | turns, |
| 1330 | const messages = yield* runOpenCodeSdk("session.messages", () => | 1368 | }; |
| 1331 | context.client.session.messages({ sessionID: context.openCodeSessionId }), | 1369 | }, |
| 1332 | ).pipe(Effect.mapError(toRequestError)); | 1370 | ); |
| 1333 | 1371 | ||
| 1334 | const assistantMessages = (messages.data ?? []).filter( | 1372 | const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( |
| 1335 | (entry) => entry.info.role === "assistant", | 1373 | function* (threadId, numTurns) { |
| 1336 | ); | 1374 | const context = ensureSessionContext(sessions, threadId); |
| 1337 | const targetIndex = assistantMessages.length - numTurns - 1; | 1375 | const messages = yield* runOpenCodeSdk("session.messages", () => |
| 1338 | const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; | 1376 | context.client.session.messages({ |
| 1339 | yield* runOpenCodeSdk("session.revert", () => | 1377 | sessionID: context.openCodeSessionId, |
| 1340 | context.client.session.revert({ | 1378 | }), |
| 1341 | sessionID: context.openCodeSessionId, | 1379 | ).pipe(Effect.mapError(toRequestError)); |
| 1342 | ...(target ? { messageID: target.info.id } : {}), | ||
| 1343 | }), | ||
| 1344 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1345 | 1380 | ||
| 1346 | return yield* readThread(threadId); | 1381 | const assistantMessages = (messages.data ?? []).filter( |
| 1347 | }, | 1382 | (entry) => entry.info.role === "assistant", |
| 1348 | ); | 1383 | ); |
| 1384 | const targetIndex = assistantMessages.length - numTurns - 1; | ||
| 1385 | const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; | ||
| 1386 | yield* runOpenCodeSdk("session.revert", () => | ||
| 1387 | context.client.session.revert({ | ||
| 1388 | sessionID: context.openCodeSessionId, | ||
| 1389 | ...(target ? { messageID: target.info.id } : {}), | ||
| 1390 | }), | ||
| 1391 | ).pipe(Effect.mapError(toRequestError)); | ||
| 1349 | 1392 | ||
| 1350 | const stopAll: OpenCodeAdapterShape["stopAll"] = () => | 1393 | return yield* readThread(threadId); |
| 1351 | Effect.gen(function* () { | 1394 | }, |
| 1352 | const contexts = [...sessions.values()]; | 1395 | ); |
| 1353 | sessions.clear(); | 1396 | |
| 1354 | // `stopOpenCodeContext` is typed as never-failing — SDK aborts are | 1397 | const stopAll: OpenCodeAdapterShape["stopAll"] = () => |
| 1355 | // already `Effect.ignore`'d inside it. `ignoreCause` here also | 1398 | Effect.gen(function* () { |
| 1356 | // swallows defects from throwing finalizers so one bad close can't | 1399 | const contexts = [...sessions.values()]; |
| 1357 | // interrupt the sibling fibers. Same pattern as the layer finalizer. | 1400 | sessions.clear(); |
| 1358 | yield* Effect.forEach( | 1401 | // `stopOpenCodeContext` is typed as never-failing — SDK aborts are |
| 1359 | contexts, | 1402 | // already `Effect.ignore`'d inside it. `ignoreCause` here also |
| 1360 | (context) => Effect.ignoreCause(stopOpenCodeContext(context)), | 1403 | // swallows defects from throwing finalizers so one bad close can't |
| 1361 | { concurrency: "unbounded", discard: true }, | 1404 | // interrupt the sibling fibers. Same pattern as the layer finalizer. |
| 1362 | ); | 1405 | yield* Effect.forEach( |
| 1363 | }); | 1406 | contexts, |
| 1407 | (context) => Effect.ignoreCause(stopOpenCodeContext(context)), | ||
| 1408 | { concurrency: "unbounded", discard: true }, | ||
| 1409 | ); | ||
| 1410 | }); | ||
| 1364 | 1411 | ||
| 1365 | return { | 1412 | return { |
| 1366 | provider: PROVIDER, | 1413 | provider: PROVIDER, |
| 1367 | capabilities: { | 1414 | capabilities: { |
| 1368 | sessionModelSwitch: "in-session", | 1415 | sessionModelSwitch: "in-session", |
| 1369 | }, | 1416 | }, |
| 1370 | startSession, | 1417 | startSession, |
| 1371 | sendTurn, | 1418 | sendTurn, |
| 1372 | interruptTurn, | 1419 | interruptTurn, |
| 1373 | respondToRequest, | 1420 | respondToRequest, |
| 1374 | respondToUserInput, | 1421 | respondToUserInput, |
| 1375 | stopSession, | 1422 | stopSession, |
| 1376 | listSessions, | 1423 | listSessions, |
| 1377 | hasSession, | 1424 | hasSession, |
| 1378 | readThread, | 1425 | readThread, |
| 1379 | rollbackThread, | 1426 | rollbackThread, |
| 1380 | stopAll, | 1427 | stopAll, |
| 1381 | get streamEvents() { | 1428 | get streamEvents() { |
| 1382 | return Stream.fromQueue(runtimeEvents); | 1429 | return Stream.fromQueue(runtimeEvents); |
| 1383 | }, | 1430 | }, |
| 1384 | } satisfies OpenCodeAdapterShape; | 1431 | } satisfies OpenCodeAdapterShape; |
| 1385 | }), | 1432 | }); |
| 1386 | ); | ||
| 1387 | } | 1433 | } |
| 1388 | |||
| 1389 | export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+55-64| ... | @@ -2,22 +2,30 @@ import assert from "node:assert/strict"; | ... | @@ -2,22 +2,30 @@ import assert from "node:assert/strict"; |
| 2 | 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 4 | import { it } from "@effect/vitest"; |
| 5 | import { Effect, Layer } from "effect"; | 5 | import { Effect, Layer, Schema } from "effect"; |
| 6 | import { beforeEach } from "vitest"; | 6 | import { beforeEach } from "vitest"; |
| 7 | 7 | ||
| 8 | import { OpenCodeSettings } from "@t3tools/contracts"; | ||
| 8 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 9 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 10 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | ||
| 11 | import { | 10 | import { |
| 12 | OpenCodeRuntime, | 11 | OpenCodeRuntime, |
| 13 | OpenCodeRuntimeError, | 12 | OpenCodeRuntimeError, |
| 14 | type OpenCodeRuntimeShape, | 13 | type OpenCodeRuntimeShape, |
| 15 | } from "../opencodeRuntime.ts"; | 14 | } from "../opencodeRuntime.ts"; |
| 16 | import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; | 15 | import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; |
| 17 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; | 16 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; |
| 18 | 17 | ||
| 19 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | 18 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; |
| 20 | 19 | ||
| 20 | /** | ||
| 21 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag | ||
| 22 | * are deleted. The snapshot-producing logic they wrapped now lives in the | ||
| 23 | * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which | ||
| 24 | * drivers call directly when building their per-instance snapshot | ||
| 25 | * `ServerProviderShape`. Tests mirror that shape: build a settings payload, | ||
| 26 | * invoke the check, assert on the returned snapshot. | ||
| 27 | */ | ||
| 28 | |||
| 21 | const runtimeMock = { | 29 | const runtimeMock = { |
| 22 | state: { | 30 | state: { |
| 23 | runVersionError: null as Error | null, | 31 | runVersionError: null as Error | null, |
| ... | @@ -90,20 +98,26 @@ beforeEach(() => { | ... | @@ -90,20 +98,26 @@ beforeEach(() => { |
| 90 | runtimeMock.reset(); | 98 | runtimeMock.reset(); |
| 91 | }); | 99 | }); |
| 92 | 100 | ||
| 93 | const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) => | 101 | const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( |
| 94 | OpenCodeProviderLive.pipe( | 102 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 95 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), | 103 | Layer.provideMerge(NodeServices.layer), |
| 96 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 104 | ); |
| 97 | Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), | 105 | |
| 98 | Layer.provideMerge(NodeServices.layer), | 106 | const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings => |
| 99 | ); | 107 | Schema.decodeSync(OpenCodeSettings)({ |
| 100 | 108 | enabled: true, | |
| 101 | it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | 109 | binaryPath: "opencode", |
| 110 | serverUrl: "", | ||
| 111 | serverPassword: "", | ||
| 112 | customModels: [], | ||
| 113 | ...overrides, | ||
| 114 | }); | ||
| 115 | |||
| 116 | it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { | ||
| 102 | it.effect("shows a codex-style missing binary message", () => | 117 | it.effect("shows a codex-style missing binary message", () => |
| 103 | Effect.gen(function* () { | 118 | Effect.gen(function* () { |
| 104 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); | 119 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); |
| 105 | const provider = yield* OpenCodeProvider; | 120 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); |
| 106 | const snapshot = yield* provider.refresh; | ||
| 107 | 121 | ||
| 108 | assert.equal(snapshot.status, "error"); | 122 | assert.equal(snapshot.status, "error"); |
| 109 | assert.equal(snapshot.installed, false); | 123 | assert.equal(snapshot.installed, false); |
| ... | @@ -114,8 +128,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | ... | @@ -114,8 +128,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 114 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => | 128 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => |
| 115 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 116 | runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); | 130 | runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); |
| 117 | const provider = yield* OpenCodeProvider; | 131 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); |
| 118 | const snapshot = yield* provider.refresh; | ||
| 119 | 132 | ||
| 120 | assert.equal(snapshot.status, "error"); | 133 | assert.equal(snapshot.status, "error"); |
| 121 | assert.equal(snapshot.installed, true); | 134 | assert.equal(snapshot.installed, true); |
| ... | @@ -123,33 +136,6 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | ... | @@ -123,33 +136,6 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 123 | }), | 136 | }), |
| 124 | ); | 137 | ); |
| 125 | 138 | ||
| 126 | it.effect("refuses to probe when opencode is older than the required minimum", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | runtimeMock.state.versionStdout = "opencode 1.4.7\n"; | ||
| 129 | const provider = yield* OpenCodeProvider; | ||
| 130 | const snapshot = yield* provider.refresh; | ||
| 131 | |||
| 132 | assert.equal(snapshot.status, "error"); | ||
| 133 | assert.equal(snapshot.installed, true); | ||
| 134 | assert.equal(snapshot.version, "1.4.7"); | ||
| 135 | assert.ok(snapshot.message?.includes("1.14.19")); | ||
| 136 | assert.ok(snapshot.message?.toLowerCase().includes("upgrade")); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 140 | it.effect("refuses to probe when opencode --version output is unparseable", () => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | runtimeMock.state.versionStdout = "garbled binary output\n"; | ||
| 143 | const provider = yield* OpenCodeProvider; | ||
| 144 | const snapshot = yield* provider.refresh; | ||
| 145 | |||
| 146 | assert.equal(snapshot.status, "error"); | ||
| 147 | assert.equal(snapshot.installed, true); | ||
| 148 | assert.equal(snapshot.version, null); | ||
| 149 | assert.ok(snapshot.message?.includes("1.14.19")); | ||
| 150 | }), | ||
| 151 | ); | ||
| 152 | |||
| 153 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => | 139 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => |
| 154 | Effect.gen(function* () { | 140 | Effect.gen(function* () { |
| 155 | runtimeMock.state.inventory = { | 141 | runtimeMock.state.inventory = { |
| ... | @@ -182,8 +168,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | ... | @@ -182,8 +168,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 182 | ], | 168 | ], |
| 183 | }; | 169 | }; |
| 184 | 170 | ||
| 185 | const provider = yield* OpenCodeProvider; | 171 | const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); |
| 186 | const snapshot = yield* provider.refresh; | ||
| 187 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); | 172 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); |
| 188 | 173 | ||
| 189 | assert.ok(model); | 174 | assert.ok(model); |
| ... | @@ -191,40 +176,41 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | ... | @@ -191,40 +176,41 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 191 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", | 176 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", |
| 192 | ); | 177 | ); |
| 193 | assert.ok(variantDescriptor && variantDescriptor.type === "select"); | 178 | assert.ok(variantDescriptor && variantDescriptor.type === "select"); |
| 194 | assert.equal(variantDescriptor.options.find((option) => option.isDefault)?.id, "medium"); | 179 | assert.equal( |
| 180 | variantDescriptor.options.find((option) => option.isDefault === true)?.id, | ||
| 181 | "medium", | ||
| 182 | ); | ||
| 195 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( | 183 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( |
| 196 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", | 184 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", |
| 197 | ); | 185 | ); |
| 198 | assert.ok(agentDescriptor && agentDescriptor.type === "select"); | 186 | assert.ok(agentDescriptor && agentDescriptor.type === "select"); |
| 199 | assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); | 187 | assert.equal( |
| 188 | agentDescriptor.options.find((option) => option.isDefault === true)?.id, | ||
| 189 | "build", | ||
| 190 | ); | ||
| 200 | }), | 191 | }), |
| 201 | ); | 192 | ); |
| 202 | 193 | ||
| 203 | it.effect("closes the local OpenCode server scope after provider refresh", () => | 194 | it.effect("closes the local OpenCode server scope after provider refresh", () => |
| 204 | Effect.gen(function* () { | 195 | Effect.gen(function* () { |
| 205 | const provider = yield* OpenCodeProvider; | 196 | yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); |
| 206 | yield* provider.refresh; | ||
| 207 | 197 | ||
| 208 | assert.equal(runtimeMock.state.closeCalls, 1); | 198 | assert.equal(runtimeMock.state.closeCalls, 1); |
| 209 | }), | 199 | }), |
| 210 | ); | 200 | ); |
| 211 | }); | 201 | }); |
| 212 | 202 | ||
| 213 | it.layer( | 203 | it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { |
| 214 | makeTestLayer({ | ||
| 215 | providers: { | ||
| 216 | opencode: { | ||
| 217 | serverUrl: "http://127.0.0.1:9999", | ||
| 218 | serverPassword: "secret-password", | ||
| 219 | }, | ||
| 220 | }, | ||
| 221 | }), | ||
| 222 | )("OpenCodeProviderLive with configured server URL", (it) => { | ||
| 223 | it.effect("surfaces a friendly auth error for configured servers", () => | 204 | it.effect("surfaces a friendly auth error for configured servers", () => |
| 224 | Effect.gen(function* () { | 205 | Effect.gen(function* () { |
| 225 | runtimeMock.state.inventoryError = new Error("401 Unauthorized"); | 206 | runtimeMock.state.inventoryError = new Error("401 Unauthorized"); |
| 226 | const provider = yield* OpenCodeProvider; | 207 | const snapshot = yield* checkOpenCodeProviderStatus( |
| 227 | const snapshot = yield* provider.refresh; | 208 | makeOpenCodeSettings({ |
| 209 | serverUrl: "http://127.0.0.1:9999", | ||
| 210 | serverPassword: "secret-password", | ||
| 211 | }), | ||
| 212 | process.cwd(), | ||
| 213 | ); | ||
| 228 | 214 | ||
| 229 | assert.equal(snapshot.status, "error"); | 215 | assert.equal(snapshot.status, "error"); |
| 230 | assert.equal(snapshot.installed, true); | 216 | assert.equal(snapshot.installed, true); |
| ... | @@ -240,8 +226,13 @@ it.layer( | ... | @@ -240,8 +226,13 @@ it.layer( |
| 240 | runtimeMock.state.inventoryError = new Error( | 226 | runtimeMock.state.inventoryError = new Error( |
| 241 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", | 227 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", |
| 242 | ); | 228 | ); |
| 243 | const provider = yield* OpenCodeProvider; | 229 | const snapshot = yield* checkOpenCodeProviderStatus( |
| 244 | const snapshot = yield* provider.refresh; | 230 | makeOpenCodeSettings({ |
| 231 | serverUrl: "http://127.0.0.1:9999", | ||
| 232 | serverPassword: "secret-password", | ||
| 233 | }), | ||
| 234 | process.cwd(), | ||
| 235 | ); | ||
| 245 | 236 | ||
| 246 | assert.equal(snapshot.status, "error"); | 237 | assert.equal(snapshot.status, "error"); |
| 247 | assert.equal(snapshot.installed, true); | 238 | assert.equal(snapshot.installed, true); |
apps/server/src/provider/Layers/OpenCodeProvider.ts+172-206| ... | @@ -1,24 +1,21 @@ | ... | @@ -1,24 +1,21 @@ |
| 1 | import type { | 1 | import { |
| 2 | ModelCapabilities, | 2 | ProviderDriverKind, |
| 3 | OpenCodeSettings, | 3 | type ModelCapabilities, |
| 4 | ServerProvider, | 4 | type OpenCodeSettings, |
| 5 | ServerProviderModel, | 5 | type ServerProviderModel, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; | 7 | import { Cause, Data, Effect } from "effect"; |
| 8 | 8 | ||
| 9 | import { createModelCapabilities } from "@t3tools/shared/model"; | 9 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 10 | 10 | ||
| 11 | import { ServerConfig } from "../../config.ts"; | ||
| 12 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 13 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 14 | import { | 11 | import { |
| 15 | buildServerProvider, | 12 | buildServerProvider, |
| 16 | nonEmptyTrimmed, | 13 | nonEmptyTrimmed, |
| 17 | parseGenericCliVersion, | 14 | parseGenericCliVersion, |
| 18 | providerModelsFromSettings, | 15 | providerModelsFromSettings, |
| 16 | type ServerProviderDraft, | ||
| 19 | } from "../providerSnapshot.ts"; | 17 | } from "../providerSnapshot.ts"; |
| 20 | import { compareCliVersions } from "../cliVersion.ts"; | 18 | import { compareCliVersions } from "../cliVersion.ts"; |
| 21 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | ||
| 22 | import { | 19 | import { |
| 23 | OpenCodeRuntime, | 20 | OpenCodeRuntime, |
| 24 | openCodeRuntimeErrorDetail, | 21 | openCodeRuntimeErrorDetail, |
| ... | @@ -26,7 +23,7 @@ import { | ... | @@ -26,7 +23,7 @@ import { |
| 26 | } from "../opencodeRuntime.ts"; | 23 | } from "../opencodeRuntime.ts"; |
| 27 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | 24 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; |
| 28 | 25 | ||
| 29 | const PROVIDER = "opencode" as const; | 26 | const PROVIDER = ProviderDriverKind.make("opencode"); |
| 30 | const OPENCODE_PRESENTATION = { | 27 | const OPENCODE_PRESENTATION = { |
| 31 | displayName: "OpenCode", | 28 | displayName: "OpenCode", |
| 32 | showInteractionModeToggle: false, | 29 | showInteractionModeToggle: false, |
| ... | @@ -251,7 +248,9 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr | ... | @@ -251,7 +248,9 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr |
| 251 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); | 248 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); |
| 252 | } | 249 | } |
| 253 | 250 | ||
| 254 | const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { | 251 | export const makePendingOpenCodeProvider = ( |
| 252 | openCodeSettings: OpenCodeSettings, | ||
| 253 | ): ServerProviderDraft => { | ||
| 255 | const checkedAt = new Date().toISOString(); | 254 | const checkedAt = new Date().toISOString(); |
| 256 | const models = providerModelsFromSettings( | 255 | const models = providerModelsFromSettings( |
| 257 | [], | 256 | [], |
| ... | @@ -262,7 +261,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server | ... | @@ -262,7 +261,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server |
| 262 | 261 | ||
| 263 | if (!openCodeSettings.enabled) { | 262 | if (!openCodeSettings.enabled) { |
| 264 | return buildServerProvider({ | 263 | return buildServerProvider({ |
| 265 | provider: PROVIDER, | ||
| 266 | presentation: OPENCODE_PRESENTATION, | 264 | presentation: OPENCODE_PRESENTATION, |
| 267 | enabled: false, | 265 | enabled: false, |
| 268 | checkedAt, | 266 | checkedAt, |
| ... | @@ -281,7 +279,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server | ... | @@ -281,7 +279,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server |
| 281 | } | 279 | } |
| 282 | 280 | ||
| 283 | return buildServerProvider({ | 281 | return buildServerProvider({ |
| 284 | provider: PROVIDER, | ||
| 285 | presentation: OPENCODE_PRESENTATION, | 282 | presentation: OPENCODE_PRESENTATION, |
| 286 | enabled: true, | 283 | enabled: true, |
| 287 | checkedAt, | 284 | checkedAt, |
| ... | @@ -296,210 +293,179 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server | ... | @@ -296,210 +293,179 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server |
| 296 | }); | 293 | }); |
| 297 | }; | 294 | }; |
| 298 | 295 | ||
| 299 | export const OpenCodeProviderLive = Layer.effect( | 296 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( |
| 300 | OpenCodeProvider, | 297 | openCodeSettings: OpenCodeSettings, |
| 301 | Effect.gen(function* () { | 298 | cwd: string, |
| 302 | const serverSettings = yield* ServerSettingsService; | 299 | environment: NodeJS.ProcessEnv = process.env, |
| 303 | const serverConfig = yield* ServerConfig; | 300 | ): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> { |
| 304 | const openCodeRuntime = yield* OpenCodeRuntime; | 301 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 305 | 302 | const checkedAt = new Date().toISOString(); | |
| 306 | const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (input: { | 303 | const customModels = openCodeSettings.customModels; |
| 307 | readonly settings: OpenCodeSettings; | 304 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; |
| 308 | readonly cwd: string; | 305 | |
| 309 | }): Effect.fn.Return<ServerProvider, never> { | 306 | const fallback = (cause: unknown, version: string | null = null) => { |
| 310 | const checkedAt = new Date().toISOString(); | 307 | const failure = formatOpenCodeProbeError({ |
| 311 | const customModels = input.settings.customModels; | 308 | cause, |
| 312 | const isExternalServer = input.settings.serverUrl.trim().length > 0; | 309 | isExternalServer, |
| 313 | 310 | serverUrl: openCodeSettings.serverUrl, | |
| 314 | const fallback = (cause: unknown, version: string | null = null) => { | 311 | }); |
| 315 | const failure = formatOpenCodeProbeError({ | 312 | return buildServerProvider({ |
| 316 | cause, | 313 | presentation: OPENCODE_PRESENTATION, |
| 317 | isExternalServer, | 314 | enabled: openCodeSettings.enabled, |
| 318 | serverUrl: input.settings.serverUrl, | 315 | checkedAt, |
| 319 | }); | 316 | models: providerModelsFromSettings( |
| 320 | return buildServerProvider({ | 317 | [], |
| 321 | provider: PROVIDER, | 318 | PROVIDER, |
| 322 | presentation: OPENCODE_PRESENTATION, | 319 | customModels, |
| 323 | enabled: input.settings.enabled, | 320 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 324 | checkedAt, | 321 | ), |
| 325 | models: providerModelsFromSettings( | 322 | probe: { |
| 326 | [], | 323 | installed: failure.installed, |
| 327 | PROVIDER, | 324 | version, |
| 328 | customModels, | 325 | status: "error", |
| 329 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | 326 | auth: { status: "unknown" }, |
| 330 | ), | 327 | message: failure.message, |
| 331 | probe: { | 328 | }, |
| 332 | installed: failure.installed, | 329 | }); |
| 333 | version, | 330 | }; |
| 334 | status: "error", | ||
| 335 | auth: { status: "unknown" }, | ||
| 336 | message: failure.message, | ||
| 337 | }, | ||
| 338 | }); | ||
| 339 | }; | ||
| 340 | |||
| 341 | if (!input.settings.enabled) { | ||
| 342 | return buildServerProvider({ | ||
| 343 | provider: PROVIDER, | ||
| 344 | presentation: OPENCODE_PRESENTATION, | ||
| 345 | enabled: false, | ||
| 346 | checkedAt, | ||
| 347 | models: providerModelsFromSettings( | ||
| 348 | [], | ||
| 349 | PROVIDER, | ||
| 350 | customModels, | ||
| 351 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 352 | ), | ||
| 353 | probe: { | ||
| 354 | installed: false, | ||
| 355 | version: null, | ||
| 356 | status: "warning", | ||
| 357 | auth: { status: "unknown" }, | ||
| 358 | message: isExternalServer | ||
| 359 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | ||
| 360 | : "OpenCode is disabled in T3 Code settings.", | ||
| 361 | }, | ||
| 362 | }); | ||
| 363 | } | ||
| 364 | |||
| 365 | let version: string | null = null; | ||
| 366 | if (!isExternalServer) { | ||
| 367 | const versionExit = yield* Effect.exit( | ||
| 368 | openCodeRuntime | ||
| 369 | .runOpenCodeCommand({ | ||
| 370 | binaryPath: input.settings.binaryPath, | ||
| 371 | args: ["--version"], | ||
| 372 | }) | ||
| 373 | .pipe( | ||
| 374 | Effect.mapError( | ||
| 375 | (cause) => | ||
| 376 | new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 377 | ), | ||
| 378 | ), | ||
| 379 | ); | ||
| 380 | if (versionExit._tag === "Failure") { | ||
| 381 | return fallback(Cause.squash(versionExit.cause)); | ||
| 382 | } | ||
| 383 | version = parseGenericCliVersion(versionExit.value.stdout) ?? null; | ||
| 384 | |||
| 385 | if (!version) { | ||
| 386 | return fallback( | ||
| 387 | new Error( | ||
| 388 | `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 389 | ), | ||
| 390 | null, | ||
| 391 | ); | ||
| 392 | } | ||
| 393 | if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { | ||
| 394 | return buildServerProvider({ | ||
| 395 | provider: PROVIDER, | ||
| 396 | presentation: OPENCODE_PRESENTATION, | ||
| 397 | enabled: input.settings.enabled, | ||
| 398 | checkedAt, | ||
| 399 | models: providerModelsFromSettings( | ||
| 400 | [], | ||
| 401 | PROVIDER, | ||
| 402 | customModels, | ||
| 403 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 404 | ), | ||
| 405 | probe: { | ||
| 406 | installed: true, | ||
| 407 | version, | ||
| 408 | status: "error", | ||
| 409 | auth: { status: "unknown" }, | ||
| 410 | message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 411 | }, | ||
| 412 | }); | ||
| 413 | } | ||
| 414 | } | ||
| 415 | |||
| 416 | const inventoryExit = yield* Effect.exit( | ||
| 417 | Effect.scoped( | ||
| 418 | Effect.gen(function* () { | ||
| 419 | const server = yield* openCodeRuntime | ||
| 420 | .connectToOpenCodeServer({ | ||
| 421 | binaryPath: input.settings.binaryPath, | ||
| 422 | serverUrl: input.settings.serverUrl, | ||
| 423 | }) | ||
| 424 | .pipe( | ||
| 425 | Effect.mapError( | ||
| 426 | (cause) => | ||
| 427 | new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 428 | ), | ||
| 429 | ); | ||
| 430 | return yield* openCodeRuntime | ||
| 431 | .loadOpenCodeInventory( | ||
| 432 | openCodeRuntime.createOpenCodeSdkClient({ | ||
| 433 | baseUrl: server.url, | ||
| 434 | directory: input.cwd, | ||
| 435 | ...(isExternalServer && input.settings.serverPassword | ||
| 436 | ? { serverPassword: input.settings.serverPassword } | ||
| 437 | : {}), | ||
| 438 | }), | ||
| 439 | ) | ||
| 440 | .pipe( | ||
| 441 | Effect.mapError( | ||
| 442 | (cause) => | ||
| 443 | new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 444 | ), | ||
| 445 | ); | ||
| 446 | }), | ||
| 447 | ), | ||
| 448 | ); | ||
| 449 | if (inventoryExit._tag === "Failure") { | ||
| 450 | return fallback(Cause.squash(inventoryExit.cause), version); | ||
| 451 | } | ||
| 452 | 331 | ||
| 453 | const models = providerModelsFromSettings( | 332 | if (!openCodeSettings.enabled) { |
| 454 | flattenOpenCodeModels(inventoryExit.value), | 333 | return buildServerProvider({ |
| 334 | presentation: OPENCODE_PRESENTATION, | ||
| 335 | enabled: false, | ||
| 336 | checkedAt, | ||
| 337 | models: providerModelsFromSettings( | ||
| 338 | [], | ||
| 455 | PROVIDER, | 339 | PROVIDER, |
| 456 | customModels, | 340 | customModels, |
| 457 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | 341 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 342 | ), | ||
| 343 | probe: { | ||
| 344 | installed: false, | ||
| 345 | version: null, | ||
| 346 | status: "warning", | ||
| 347 | auth: { status: "unknown" }, | ||
| 348 | message: isExternalServer | ||
| 349 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | ||
| 350 | : "OpenCode is disabled in T3 Code settings.", | ||
| 351 | }, | ||
| 352 | }); | ||
| 353 | } | ||
| 354 | |||
| 355 | let version: string | null = null; | ||
| 356 | if (!isExternalServer) { | ||
| 357 | const versionExit = yield* Effect.exit( | ||
| 358 | openCodeRuntime | ||
| 359 | .runOpenCodeCommand({ | ||
| 360 | binaryPath: openCodeSettings.binaryPath, | ||
| 361 | args: ["--version"], | ||
| 362 | environment, | ||
| 363 | }) | ||
| 364 | .pipe( | ||
| 365 | Effect.mapError( | ||
| 366 | (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 367 | ), | ||
| 368 | ), | ||
| 369 | ); | ||
| 370 | if (versionExit._tag === "Failure") { | ||
| 371 | return fallback(Cause.squash(versionExit.cause)); | ||
| 372 | } | ||
| 373 | version = parseGenericCliVersion(versionExit.value.stdout) ?? null; | ||
| 374 | |||
| 375 | if (!version) { | ||
| 376 | return fallback( | ||
| 377 | new Error( | ||
| 378 | `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 379 | ), | ||
| 380 | null, | ||
| 458 | ); | 381 | ); |
| 459 | const connectedCount = inventoryExit.value.providerList.connected.length; | 382 | } |
| 383 | if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { | ||
| 460 | return buildServerProvider({ | 384 | return buildServerProvider({ |
| 461 | provider: PROVIDER, | ||
| 462 | presentation: OPENCODE_PRESENTATION, | 385 | presentation: OPENCODE_PRESENTATION, |
| 463 | enabled: true, | 386 | enabled: openCodeSettings.enabled, |
| 464 | checkedAt, | 387 | checkedAt, |
| 465 | models, | 388 | models: providerModelsFromSettings( |
| 389 | [], | ||
| 390 | PROVIDER, | ||
| 391 | customModels, | ||
| 392 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 393 | ), | ||
| 466 | probe: { | 394 | probe: { |
| 467 | installed: true, | 395 | installed: true, |
| 468 | version, | 396 | version, |
| 469 | status: connectedCount > 0 ? "ready" : "warning", | 397 | status: "error", |
| 470 | auth: { | 398 | auth: { status: "unknown" }, |
| 471 | status: connectedCount > 0 ? "authenticated" : "unknown", | 399 | message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, |
| 472 | type: "opencode", | ||
| 473 | }, | ||
| 474 | message: | ||
| 475 | connectedCount > 0 | ||
| 476 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | ||
| 477 | : isExternalServer | ||
| 478 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | ||
| 479 | : "OpenCode is available, but it did not report any connected upstream providers.", | ||
| 480 | }, | 400 | }, |
| 481 | }); | 401 | }); |
| 482 | }); | 402 | } |
| 403 | } | ||
| 483 | 404 | ||
| 484 | const getProviderSettings = serverSettings.getSettings.pipe( | 405 | const inventoryExit = yield* Effect.exit( |
| 485 | Effect.map((settings) => settings.providers.opencode), | 406 | Effect.scoped( |
| 486 | ); | 407 | Effect.gen(function* () { |
| 408 | const server = yield* openCodeRuntime | ||
| 409 | .connectToOpenCodeServer({ | ||
| 410 | binaryPath: openCodeSettings.binaryPath, | ||
| 411 | serverUrl: openCodeSettings.serverUrl, | ||
| 412 | environment, | ||
| 413 | }) | ||
| 414 | .pipe( | ||
| 415 | Effect.mapError( | ||
| 416 | (cause) => | ||
| 417 | new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 418 | ), | ||
| 419 | ); | ||
| 420 | return yield* openCodeRuntime | ||
| 421 | .loadOpenCodeInventory( | ||
| 422 | openCodeRuntime.createOpenCodeSdkClient({ | ||
| 423 | baseUrl: server.url, | ||
| 424 | directory: cwd, | ||
| 425 | ...(isExternalServer && openCodeSettings.serverPassword | ||
| 426 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 427 | : {}), | ||
| 428 | }), | ||
| 429 | ) | ||
| 430 | .pipe( | ||
| 431 | Effect.mapError( | ||
| 432 | (cause) => | ||
| 433 | new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), | ||
| 434 | ), | ||
| 435 | ); | ||
| 436 | }), | ||
| 437 | ), | ||
| 438 | ); | ||
| 439 | if (inventoryExit._tag === "Failure") { | ||
| 440 | return fallback(Cause.squash(inventoryExit.cause), version); | ||
| 441 | } | ||
| 487 | 442 | ||
| 488 | return yield* makeManagedServerProvider<OpenCodeSettings>({ | 443 | const models = providerModelsFromSettings( |
| 489 | getSettings: getProviderSettings.pipe(Effect.orDie), | 444 | flattenOpenCodeModels(inventoryExit.value), |
| 490 | streamSettings: serverSettings.streamChanges.pipe( | 445 | PROVIDER, |
| 491 | Stream.map((settings) => settings.providers.opencode), | 446 | customModels, |
| 492 | ), | 447 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 493 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | 448 | ); |
| 494 | initialSnapshot: makePendingOpenCodeProvider, | 449 | const connectedCount = inventoryExit.value.providerList.connected.length; |
| 495 | checkProvider: getProviderSettings.pipe( | 450 | return buildServerProvider({ |
| 496 | Effect.flatMap((settings) => | 451 | presentation: OPENCODE_PRESENTATION, |
| 497 | checkOpenCodeProviderStatus({ | 452 | enabled: true, |
| 498 | settings, | 453 | checkedAt, |
| 499 | cwd: serverConfig.cwd, | 454 | models, |
| 500 | }), | 455 | probe: { |
| 501 | ), | 456 | installed: true, |
| 502 | ), | 457 | version, |
| 503 | }); | 458 | status: connectedCount > 0 ? "ready" : "warning", |
| 504 | }), | 459 | auth: { |
| 505 | ); | 460 | status: connectedCount > 0 ? "authenticated" : "unknown", |
| 461 | type: "opencode", | ||
| 462 | }, | ||
| 463 | message: | ||
| 464 | connectedCount > 0 | ||
| 465 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | ||
| 466 | : isExternalServer | ||
| 467 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | ||
| 468 | : "OpenCode is available, but it did not report any connected upstream providers.", | ||
| 469 | }, | ||
| 470 | }); | ||
| 471 | }); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+102-46| ... | @@ -1,24 +1,30 @@ | ... | @@ -1,24 +1,30 @@ |
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | 1 | import { |
| 2 | defaultInstanceIdForDriver, | ||
| 3 | ProviderDriverKind, | ||
| 4 | type ServerProvider, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { it, assert, vi } from "@effect/vitest"; | 6 | import { it, assert, vi } from "@effect/vitest"; |
| 3 | import { assertFailure } from "@effect/vitest/utils"; | ||
| 4 | 7 | ||
| 5 | import { Effect, Layer, Stream } from "effect"; | 8 | import { Effect, Layer, PubSub, Stream } from "effect"; |
| 6 | 9 | ||
| 7 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | ||
| 8 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; | 10 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 9 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | ||
| 10 | import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; | 11 | import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 11 | import { CursorAdapter } from "../Services/CursorAdapter.ts"; | ||
| 12 | import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; | 12 | import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; |
| 13 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | ||
| 14 | import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | 13 | import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 15 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 14 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 15 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | ||
| 16 | import type { ProviderInstance } from "../ProviderDriver.ts"; | ||
| 17 | import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; | ||
| 16 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; | 18 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| 17 | import { ProviderUnsupportedError } from "../Errors.ts"; | ||
| 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 19 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 19 | 20 | ||
| 21 | const CODEX_DRIVER = ProviderDriverKind.make("codex"); | ||
| 22 | const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); | ||
| 23 | const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); | ||
| 24 | const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); | ||
| 25 | |||
| 20 | const fakeCodexAdapter: CodexAdapterShape = { | 26 | const fakeCodexAdapter: CodexAdapterShape = { |
| 21 | provider: "codex", | 27 | provider: CODEX_DRIVER, |
| 22 | capabilities: { sessionModelSwitch: "in-session" }, | 28 | capabilities: { sessionModelSwitch: "in-session" }, |
| 23 | startSession: vi.fn(), | 29 | startSession: vi.fn(), |
| 24 | sendTurn: vi.fn(), | 30 | sendTurn: vi.fn(), |
| ... | @@ -35,7 +41,7 @@ const fakeCodexAdapter: CodexAdapterShape = { | ... | @@ -35,7 +41,7 @@ const fakeCodexAdapter: CodexAdapterShape = { |
| 35 | }; | 41 | }; |
| 36 | 42 | ||
| 37 | const fakeClaudeAdapter: ClaudeAdapterShape = { | 43 | const fakeClaudeAdapter: ClaudeAdapterShape = { |
| 38 | provider: "claudeAgent", | 44 | provider: CLAUDE_AGENT_DRIVER, |
| 39 | capabilities: { sessionModelSwitch: "in-session" }, | 45 | capabilities: { sessionModelSwitch: "in-session" }, |
| 40 | startSession: vi.fn(), | 46 | startSession: vi.fn(), |
| 41 | sendTurn: vi.fn(), | 47 | sendTurn: vi.fn(), |
| ... | @@ -52,7 +58,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { | ... | @@ -52,7 +58,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { |
| 52 | }; | 58 | }; |
| 53 | 59 | ||
| 54 | const fakeOpenCodeAdapter: OpenCodeAdapterShape = { | 60 | const fakeOpenCodeAdapter: OpenCodeAdapterShape = { |
| 55 | provider: "opencode", | 61 | provider: OPENCODE_DRIVER, |
| 56 | capabilities: { sessionModelSwitch: "in-session" }, | 62 | capabilities: { sessionModelSwitch: "in-session" }, |
| 57 | startSession: vi.fn(), | 63 | startSession: vi.fn(), |
| 58 | sendTurn: vi.fn(), | 64 | sendTurn: vi.fn(), |
| ... | @@ -69,7 +75,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { | ... | @@ -69,7 +75,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { |
| 69 | }; | 75 | }; |
| 70 | 76 | ||
| 71 | const fakeCursorAdapter: CursorAdapterShape = { | 77 | const fakeCursorAdapter: CursorAdapterShape = { |
| 72 | provider: "cursor", | 78 | provider: CURSOR_DRIVER, |
| 73 | capabilities: { sessionModelSwitch: "in-session" }, | 79 | capabilities: { sessionModelSwitch: "in-session" }, |
| 74 | startSession: vi.fn(), | 80 | startSession: vi.fn(), |
| 75 | sendTurn: vi.fn(), | 81 | sendTurn: vi.fn(), |
| ... | @@ -85,44 +91,94 @@ const fakeCursorAdapter: CursorAdapterShape = { | ... | @@ -85,44 +91,94 @@ const fakeCursorAdapter: CursorAdapterShape = { |
| 85 | streamEvents: Stream.empty, | 91 | streamEvents: Stream.empty, |
| 86 | }; | 92 | }; |
| 87 | 93 | ||
| 88 | const layer = it.layer( | 94 | // ProviderAdapterRegistryLive is now a facade over ProviderInstanceRegistry — |
| 89 | Layer.mergeAll( | 95 | // it walks `listInstances` once at boot and surfaces the default-instance |
| 90 | Layer.provide( | 96 | // adapter keyed by its driver kind. To test the facade we supply four fake |
| 91 | ProviderAdapterRegistryLive, | 97 | // instances whose `instanceId === defaultInstanceIdForDriver(driverKind)` so |
| 92 | Layer.mergeAll( | 98 | // they pass the default-instance filter. |
| 93 | Layer.succeed(CodexAdapter, fakeCodexAdapter), | 99 | const makeFakeInstance = ( |
| 94 | Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), | 100 | driverKindString: "codex" | "claudeAgent" | "cursor" | "opencode", |
| 95 | Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), | 101 | adapter: ProviderInstance["adapter"], |
| 96 | Layer.succeed(CursorAdapter, fakeCursorAdapter), | 102 | ): ProviderInstance => { |
| 97 | ), | 103 | const driverKind = ProviderDriverKind.make(driverKindString); |
| 98 | ), | 104 | return { |
| 99 | NodeServices.layer, | 105 | instanceId: defaultInstanceIdForDriver(driverKind), |
| 100 | ), | 106 | driverKind, |
| 107 | continuationIdentity: { | ||
| 108 | driverKind, | ||
| 109 | continuationKey: `${driverKind}:instance:${defaultInstanceIdForDriver(driverKind)}`, | ||
| 110 | }, | ||
| 111 | displayName: undefined, | ||
| 112 | enabled: true, | ||
| 113 | snapshot: { | ||
| 114 | getSnapshot: Effect.succeed({} as unknown as ServerProvider), | ||
| 115 | refresh: Effect.succeed({} as unknown as ServerProvider), | ||
| 116 | streamChanges: Stream.empty, | ||
| 117 | }, | ||
| 118 | adapter, | ||
| 119 | textGeneration: {} as unknown as TextGenerationShape, | ||
| 120 | }; | ||
| 121 | }; | ||
| 122 | |||
| 123 | const fakeInstances: ReadonlyArray<ProviderInstance> = [ | ||
| 124 | makeFakeInstance("codex", fakeCodexAdapter), | ||
| 125 | makeFakeInstance("claudeAgent", fakeClaudeAdapter), | ||
| 126 | makeFakeInstance("opencode", fakeOpenCodeAdapter), | ||
| 127 | makeFakeInstance("cursor", fakeCursorAdapter), | ||
| 128 | ]; | ||
| 129 | |||
| 130 | const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { | ||
| 131 | getInstance: (instanceId) => | ||
| 132 | Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)), | ||
| 133 | listInstances: Effect.succeed(fakeInstances), | ||
| 134 | listUnavailable: Effect.succeed([]), | ||
| 135 | streamChanges: Stream.empty, | ||
| 136 | // Tests never drive changes through this fake; acquire a throwaway | ||
| 137 | // subscription on an unused PubSub so the shape is satisfied. | ||
| 138 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => PubSub.subscribe(pubsub)), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const layer = Layer.mergeAll( | ||
| 142 | Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer), | ||
| 143 | NodeServices.layer, | ||
| 101 | ); | 144 | ); |
| 102 | 145 | ||
| 103 | layer("ProviderAdapterRegistryLive", (it) => { | 146 | it.layer(layer)("ProviderAdapterRegistryLive", (it) => { |
| 104 | it.effect("resolves a registered provider adapter", () => | 147 | it("resolves adapters and routing metadata from provider instances", () => |
| 105 | Effect.gen(function* () { | 148 | Effect.gen(function* () { |
| 106 | const registry = yield* ProviderAdapterRegistry; | 149 | const registry = yield* ProviderAdapterRegistry; |
| 107 | const codex = yield* registry.getByProvider("codex"); | 150 | const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER); |
| 108 | const claude = yield* registry.getByProvider("claudeAgent"); | ||
| 109 | const openCode = yield* registry.getByProvider("opencode"); | ||
| 110 | const cursor = yield* registry.getByProvider("cursor"); | ||
| 111 | assert.equal(codex, fakeCodexAdapter); | ||
| 112 | assert.equal(claude, fakeClaudeAdapter); | ||
| 113 | assert.equal(openCode, fakeOpenCodeAdapter); | ||
| 114 | assert.equal(cursor, fakeCursorAdapter); | ||
| 115 | 151 | ||
| 116 | const providers = yield* registry.listProviders(); | 152 | const adapter = yield* registry.getByInstance(claudeInstanceId); |
| 117 | assert.deepEqual(providers, ["codex", "claudeAgent", "opencode", "cursor"]); | 153 | assert.strictEqual(adapter, fakeClaudeAdapter); |
| 118 | }), | ||
| 119 | ); | ||
| 120 | 154 | ||
| 121 | it.effect("fails with ProviderUnsupportedError for unknown providers", () => | 155 | const info = yield* registry.getInstanceInfo(claudeInstanceId); |
| 122 | Effect.gen(function* () { | 156 | assert.deepStrictEqual(info, { |
| 123 | const registry = yield* ProviderAdapterRegistry; | 157 | instanceId: claudeInstanceId, |
| 124 | const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); | 158 | driverKind: CLAUDE_AGENT_DRIVER, |
| 125 | assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); | 159 | displayName: undefined, |
| 126 | }), | 160 | accentColor: undefined, |
| 127 | ); | 161 | enabled: true, |
| 162 | continuationIdentity: { | ||
| 163 | driverKind: CLAUDE_AGENT_DRIVER, | ||
| 164 | continuationKey: "claudeAgent:instance:claudeAgent", | ||
| 165 | }, | ||
| 166 | }); | ||
| 167 | |||
| 168 | const instances = yield* registry.listInstances(); | ||
| 169 | assert.deepStrictEqual(instances, [ | ||
| 170 | defaultInstanceIdForDriver(CODEX_DRIVER), | ||
| 171 | claudeInstanceId, | ||
| 172 | defaultInstanceIdForDriver(OPENCODE_DRIVER), | ||
| 173 | defaultInstanceIdForDriver(CURSOR_DRIVER), | ||
| 174 | ]); | ||
| 175 | |||
| 176 | const providers = yield* registry.listProviders(); | ||
| 177 | assert.deepStrictEqual(providers, [ | ||
| 178 | CODEX_DRIVER, | ||
| 179 | CLAUDE_AGENT_DRIVER, | ||
| 180 | OPENCODE_DRIVER, | ||
| 181 | CURSOR_DRIVER, | ||
| 182 | ]); | ||
| 183 | })); | ||
| 128 | }); | 184 | }); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+93-40| ... | @@ -1,59 +1,101 @@ | ... | @@ -1,59 +1,101 @@ |
| 1 | /** | 1 | /** |
| 2 | * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. | 2 | * ProviderAdapterRegistryLive — facade over `ProviderInstanceRegistry`. |
| 3 | * | 3 | * |
| 4 | * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services. | 4 | * `ProviderAdapterRegistry` historically mapped one `ProviderDriverKind` to one |
| 5 | * This layer only performs adapter lookup; it does not route session-scoped | 5 | * adapter via the four `<X>AdapterLive` singleton Layers. The per-instance |
| 6 | * calls or own provider lifecycle workflows. | 6 | * refactor moved adapter construction inside each `ProviderDriver.create()`: |
| 7 | * adapters are now bundled on the `ProviderInstance` that the | ||
| 8 | * `ProviderInstanceRegistry` owns. | ||
| 9 | * | ||
| 10 | * This facade fulfills the `ProviderAdapterRegistryShape` contract by doing | ||
| 11 | * dynamic look-ups against `ProviderInstanceRegistry` on every call. That | ||
| 12 | * means settings-driven hot-reload shows up here automatically — adding a | ||
| 13 | * new instance via settings makes `getByInstance` resolve immediately | ||
| 14 | * without rebuilding the facade. | ||
| 7 | * | 15 | * |
| 8 | * @module ProviderAdapterRegistryLive | 16 | * @module ProviderAdapterRegistryLive |
| 9 | */ | 17 | */ |
| 18 | import { | ||
| 19 | defaultInstanceIdForDriver, | ||
| 20 | ProviderInstanceId, | ||
| 21 | type ProviderDriverKind, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 10 | import { Effect, Layer } from "effect"; | 23 | import { Effect, Layer } from "effect"; |
| 11 | 24 | ||
| 12 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; | 25 | import { ProviderUnsupportedError } from "../Errors.ts"; |
| 13 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | 26 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 14 | import { | 27 | import { |
| 15 | ProviderAdapterRegistry, | 28 | ProviderAdapterRegistry, |
| 16 | type ProviderAdapterRegistryShape, | 29 | type ProviderAdapterRegistryShape, |
| 17 | } from "../Services/ProviderAdapterRegistry.ts"; | 30 | } from "../Services/ProviderAdapterRegistry.ts"; |
| 18 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | 31 | |
| 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | 32 | const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { |
| 20 | import { CursorAdapter } from "../Services/CursorAdapter.ts"; | 33 | const registry = yield* ProviderInstanceRegistry; |
| 21 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | 34 | |
| 22 | import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts"; | 35 | const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => |
| 23 | 36 | registry.getInstance(instanceId).pipe( | |
| 24 | export interface ProviderAdapterRegistryLiveOptions { | 37 | Effect.flatMap((instance) => |
| 25 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; | 38 | instance === undefined |
| 26 | } | 39 | ? Effect.fail( |
| 27 | 40 | new ProviderUnsupportedError({ | |
| 28 | const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* ( | 41 | provider: instanceId, |
| 29 | options?: ProviderAdapterRegistryLiveOptions, | 42 | }), |
| 30 | ) { | 43 | ) |
| 31 | const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter); | 44 | : Effect.succeed(instance.adapter), |
| 32 | const adapters = | 45 | ), |
| 33 | options?.adapters !== undefined | 46 | ); |
| 34 | ? options.adapters | 47 | |
| 35 | : createBuiltInAdapterList({ | 48 | const getInstanceInfo: ProviderAdapterRegistryShape["getInstanceInfo"] = (instanceId) => |
| 36 | codex: yield* CodexAdapter, | 49 | registry.getInstance(instanceId).pipe( |
| 37 | claudeAgent: yield* ClaudeAdapter, | 50 | Effect.flatMap((instance) => |
| 38 | opencode: yield* OpenCodeAdapter, | 51 | instance === undefined |
| 39 | ...(cursorAdapterOption._tag === "Some" ? { cursor: cursorAdapterOption.value } : {}), | 52 | ? Effect.fail( |
| 40 | }); | 53 | new ProviderUnsupportedError({ |
| 41 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); | 54 | provider: instanceId, |
| 42 | 55 | }), | |
| 43 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { | 56 | ) |
| 44 | const adapter = byProvider.get(provider); | 57 | : Effect.succeed({ |
| 45 | if (!adapter) { | 58 | instanceId: instance.instanceId, |
| 46 | return Effect.fail(new ProviderUnsupportedError({ provider })); | 59 | driverKind: instance.driverKind, |
| 47 | } | 60 | displayName: instance.displayName, |
| 48 | return Effect.succeed(adapter); | 61 | accentColor: instance.accentColor, |
| 49 | }; | 62 | enabled: instance.enabled, |
| 63 | continuationIdentity: instance.continuationIdentity, | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ); | ||
| 67 | |||
| 68 | const listInstances: ProviderAdapterRegistryShape["listInstances"] = () => | ||
| 69 | registry.listInstances.pipe( | ||
| 70 | Effect.map((instances) => instances.map((instance) => instance.instanceId)), | ||
| 71 | ); | ||
| 50 | 72 | ||
| 51 | const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => | 73 | const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => |
| 52 | Effect.sync(() => Array.from(byProvider.keys())); | 74 | registry.listInstances.pipe( |
| 75 | Effect.map((instances) => { | ||
| 76 | const kinds = new Set<ProviderDriverKind>(); | ||
| 77 | for (const instance of instances) { | ||
| 78 | const defaultId = defaultInstanceIdForDriver(instance.driverKind); | ||
| 79 | if (instance.instanceId === defaultId) { | ||
| 80 | // Only the default-instance rows show up through the legacy | ||
| 81 | // shim — custom instances like `codex_personal` have no | ||
| 82 | // `ProviderDriverKind` equivalent. | ||
| 83 | kinds.add(instance.driverKind); | ||
| 84 | } | ||
| 85 | } | ||
| 86 | return Array.from(kinds); | ||
| 87 | }), | ||
| 88 | ); | ||
| 53 | 89 | ||
| 54 | return { | 90 | return { |
| 55 | getByProvider, | 91 | getByInstance, |
| 92 | getInstanceInfo, | ||
| 93 | listInstances, | ||
| 56 | listProviders, | 94 | listProviders, |
| 95 | // Proxy directly — the facade has no state of its own; the instance | ||
| 96 | // registry already coalesces adds/removes/rebuilds into one emission. | ||
| 97 | streamChanges: registry.streamChanges, | ||
| 98 | subscribeChanges: registry.subscribeChanges, | ||
| 57 | } satisfies ProviderAdapterRegistryShape; | 99 | } satisfies ProviderAdapterRegistryShape; |
| 58 | }); | 100 | }); |
| 59 | 101 | ||
| ... | @@ -61,3 +103,14 @@ export const ProviderAdapterRegistryLive = Layer.effect( | ... | @@ -61,3 +103,14 @@ export const ProviderAdapterRegistryLive = Layer.effect( |
| 61 | ProviderAdapterRegistry, | 103 | ProviderAdapterRegistry, |
| 62 | makeProviderAdapterRegistry(), | 104 | makeProviderAdapterRegistry(), |
| 63 | ); | 105 | ); |
| 106 | |||
| 107 | // Exposed for tests that want to build a facade over a pre-assembled | ||
| 108 | // `ProviderInstanceRegistry` without pulling in the whole boot graph. | ||
| 109 | export { makeProviderAdapterRegistry }; | ||
| 110 | |||
| 111 | // Re-export for consumers that need the accessor shape. The service tag | ||
| 112 | // itself lives in `Services/ProviderAdapterRegistry.ts`. | ||
| 113 | export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | ||
| 114 | // Re-export for consumers (including tests) that construct a | ||
| 115 | // `ProviderInstanceId` before calling `getByInstance`. | ||
| 116 | export { ProviderInstanceId }; |
apps/server/src/provider/Layers/ProviderEventLoggers.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | /** | ||
| 2 | * ProviderEventLoggers — single observability service that owns the two | ||
| 3 | * shared NDJSON streams the provider runtime writes: | ||
| 4 | * | ||
| 5 | * - `native` — provider-protocol events as the SDK emits them, written | ||
| 6 | * from inside each `<X>Adapter` factory. | ||
| 7 | * - `canonical` — runtime events after `ProviderService` has normalized | ||
| 8 | * them onto `ProviderRuntimeEvent`. | ||
| 9 | * | ||
| 10 | * Why a service tag and not constructor options? | ||
| 11 | * | ||
| 12 | * - Adapters are now constructed *inside* drivers (`<X>Driver.create()`), | ||
| 13 | * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)` | ||
| 14 | * call site where we can hand an `EventNdjsonLogger` in by hand. | ||
| 15 | * - Multiple driver instances per kind (`codex_personal`, `codex_work`) | ||
| 16 | * should share one underlying log writer per stream — opening N writers | ||
| 17 | * against the same rotating file would race the rotation logic. Owning | ||
| 18 | * the loggers on a single tag keeps that invariant intact. | ||
| 19 | * - Tests can swap one (or both) loggers with in-memory recorders by | ||
| 20 | * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of | ||
| 21 | * juggling per-Layer option threading. | ||
| 22 | * | ||
| 23 | * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when | ||
| 24 | * the target directory cannot be created; we forward that as `undefined` | ||
| 25 | * rather than failing the boot Layer, matching the previous best-effort | ||
| 26 | * behavior of `server.ts`. | ||
| 27 | * | ||
| 28 | * @module provider/Layers/ProviderEventLoggers | ||
| 29 | */ | ||
| 30 | import { Context, Effect, Layer } from "effect"; | ||
| 31 | |||
| 32 | import { ServerConfig } from "../../config.ts"; | ||
| 33 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | ||
| 34 | |||
| 35 | export interface ProviderEventLoggersShape { | ||
| 36 | readonly native: EventNdjsonLogger | undefined; | ||
| 37 | readonly canonical: EventNdjsonLogger | undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Shared logger pair for native + canonical provider event streams. | ||
| 42 | * | ||
| 43 | * Service value is intentionally a struct of two optional loggers rather | ||
| 44 | * than two parallel tags. Construction site is one place | ||
| 45 | * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read | ||
| 46 | * one tag and pluck the field they need. | ||
| 47 | */ | ||
| 48 | export class ProviderEventLoggers extends Context.Service< | ||
| 49 | ProviderEventLoggers, | ||
| 50 | ProviderEventLoggersShape | ||
| 51 | >()("t3/provider/ProviderEventLoggers") {} | ||
| 52 | |||
| 53 | /** | ||
| 54 | * Constant value used by tests / boot layers that want to opt out of native | ||
| 55 | * + canonical logging entirely. Keeps the tag non-optional in the type | ||
| 56 | * system while letting the runtime treat absence as a no-op. | ||
| 57 | */ | ||
| 58 | export const NoOpProviderEventLoggers: ProviderEventLoggersShape = { | ||
| 59 | native: undefined, | ||
| 60 | canonical: undefined, | ||
| 61 | }; | ||
| 62 | |||
| 63 | /** | ||
| 64 | * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`. | ||
| 65 | * If the directory create fails for either stream, the corresponding field | ||
| 66 | * is `undefined` and writes from that stream become no-ops downstream. | ||
| 67 | */ | ||
| 68 | export const ProviderEventLoggersLive = Layer.effect( | ||
| 69 | ProviderEventLoggers, | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const { providerEventLogPath } = yield* ServerConfig; | ||
| 72 | const native = yield* makeEventNdjsonLogger(providerEventLogPath, { | ||
| 73 | stream: "native", | ||
| 74 | }); | ||
| 75 | const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, { | ||
| 76 | stream: "canonical", | ||
| 77 | }); | ||
| 78 | return { | ||
| 79 | native, | ||
| 80 | canonical, | ||
| 81 | } satisfies ProviderEventLoggersShape; | ||
| 82 | }), | ||
| 83 | ); | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts created+176| ... | @@ -0,0 +1,176 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` | ||
| 3 | * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. | ||
| 4 | * | ||
| 5 | * The server still reads two shapes: | ||
| 6 | * | ||
| 7 | * 1. `settings.providerInstances` — the new driver-agnostic map the | ||
| 8 | * registry expects. Keyed by `ProviderInstanceId`, values are | ||
| 9 | * `ProviderInstanceConfig` envelopes. | ||
| 10 | * 2. `settings.providers.<kind>` — the legacy single-instance-per-driver | ||
| 11 | * fields (`providers.codex`, `providers.claudeAgent`, …). These are | ||
| 12 | * the source of truth for every deployment that hasn't been migrated | ||
| 13 | * yet to an explicit `providerInstances` entry. | ||
| 14 | * | ||
| 15 | * This module bridges (2) into (1) and wires the resulting map into a | ||
| 16 | * mutable registry. For every built-in driver whose id is not already | ||
| 17 | * present in `providerInstances` (keyed on | ||
| 18 | * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a | ||
| 19 | * routing slug), we synthesize an envelope from the legacy field. The | ||
| 20 | * registry decodes both flavours through the same `configSchema` and ends | ||
| 21 | * up with one uniform `ProviderInstance` per entry. | ||
| 22 | * | ||
| 23 | * Explicit `providerInstances` entries always win — users can already | ||
| 24 | * override the legacy `providers.<kind>` blob by authoring a | ||
| 25 | * `providerInstances.codex` entry with a matching driver, and we don't | ||
| 26 | * want the synthesized envelope to silently stomp their config. | ||
| 27 | * | ||
| 28 | * Hot-reload | ||
| 29 | * ---------- | ||
| 30 | * On layer build we: | ||
| 31 | * 1. Read the current `ServerSettings` once and use it to seed the | ||
| 32 | * registry's initial state via `ProviderInstanceRegistryMutableLayer`. | ||
| 33 | * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that | ||
| 34 | * subscribes to `ServerSettingsService.streamChanges` and calls | ||
| 35 | * `ProviderInstanceRegistryMutator.reconcile` on every emission. | ||
| 36 | * | ||
| 37 | * Failures inside the watcher are logged and swallowed so a single bad | ||
| 38 | * settings emission cannot kill the registry. Unknown drivers and invalid | ||
| 39 | * configs already round-trip through the registry's own "unavailable" | ||
| 40 | * shadow bucket. | ||
| 41 | * | ||
| 42 | * @module provider/Layers/ProviderInstanceRegistryHydration | ||
| 43 | */ | ||
| 44 | import { | ||
| 45 | defaultInstanceIdForDriver, | ||
| 46 | type ProviderInstanceConfig, | ||
| 47 | type ProviderInstanceConfigMap, | ||
| 48 | ServerSettings, | ||
| 49 | } from "@t3tools/contracts"; | ||
| 50 | import { Effect, Layer, Stream } from "effect"; | ||
| 51 | |||
| 52 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 53 | import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; | ||
| 54 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | ||
| 55 | import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts"; | ||
| 56 | import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistryLive.ts"; | ||
| 57 | |||
| 58 | /** | ||
| 59 | * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. | ||
| 60 | * | ||
| 61 | * Strategy: | ||
| 62 | * 1. Copy all explicit `settings.providerInstances` entries verbatim. | ||
| 63 | * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key | ||
| 64 | * is *not* already in the explicit map, synthesize an entry from the | ||
| 65 | * matching legacy `settings.providers.<kind>` blob. | ||
| 66 | * | ||
| 67 | * The returned map is the input the registry consumes; pure & exported | ||
| 68 | * separately so the hydration logic can be exercised by unit tests | ||
| 69 | * without layering. | ||
| 70 | */ | ||
| 71 | export const deriveProviderInstanceConfigMap = ( | ||
| 72 | settings: ServerSettings, | ||
| 73 | ): ProviderInstanceConfigMap => { | ||
| 74 | const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances }; | ||
| 75 | |||
| 76 | for (const driver of BUILT_IN_DRIVERS) { | ||
| 77 | const instanceId = defaultInstanceIdForDriver(driver.driverKind); | ||
| 78 | if (instanceId in merged) { | ||
| 79 | // Explicit `providerInstances` entry for this slot — user-authored | ||
| 80 | // config always wins over the legacy mirror. | ||
| 81 | continue; | ||
| 82 | } | ||
| 83 | |||
| 84 | // Only built-in drivers have a legacy mirror; the registry's | ||
| 85 | // `providers` struct is keyed on the same literal slug as | ||
| 86 | // `driverKind`. Access is dynamic (the driver kind is a branded string), | ||
| 87 | // but it's constrained to `keyof settings.providers` by the union of | ||
| 88 | // built-in driver kinds. | ||
| 89 | const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; | ||
| 90 | const legacyConfig = settings.providers[legacyKey]; | ||
| 91 | if (legacyConfig === undefined) { | ||
| 92 | continue; | ||
| 93 | } | ||
| 94 | |||
| 95 | merged[instanceId] = { | ||
| 96 | driver: driver.driverKind, | ||
| 97 | config: legacyConfig, | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | |||
| 101 | return merged as ProviderInstanceConfigMap; | ||
| 102 | }; | ||
| 103 | |||
| 104 | /** | ||
| 105 | * Layer that consumes `ProviderInstanceRegistryMutator` and forks a | ||
| 106 | * settings-watcher fiber. The fiber's lifetime is tied to the enclosing | ||
| 107 | * layer scope (process lifetime in production), so it is interrupted on | ||
| 108 | * shutdown without leaking. | ||
| 109 | * | ||
| 110 | * Errors inside the watcher are logged and swallowed — the registry's own | ||
| 111 | * "unavailable" bucket already absorbs unknown drivers and invalid | ||
| 112 | * configs, so the only way the watcher could fail is a settings stream | ||
| 113 | * tear-down, which logs and exits cleanly. | ||
| 114 | */ | ||
| 115 | const SettingsWatcherLive: Layer.Layer< | ||
| 116 | never, | ||
| 117 | never, | ||
| 118 | ProviderInstanceRegistryMutator | ServerSettingsService | ||
| 119 | > = Layer.effectDiscard( | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const mutator = yield* ProviderInstanceRegistryMutator; | ||
| 122 | const serverSettings = yield* ServerSettingsService; | ||
| 123 | yield* serverSettings.streamChanges.pipe( | ||
| 124 | Stream.runForEach((next) => | ||
| 125 | mutator | ||
| 126 | .reconcile(deriveProviderInstanceConfigMap(next)) | ||
| 127 | .pipe( | ||
| 128 | Effect.catchCause((cause) => | ||
| 129 | Effect.logError("ProviderInstanceRegistry reconcile failed", cause), | ||
| 130 | ), | ||
| 131 | ), | ||
| 132 | ), | ||
| 133 | Effect.forkScoped, | ||
| 134 | ); | ||
| 135 | }), | ||
| 136 | ); | ||
| 137 | |||
| 138 | /** | ||
| 139 | * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in | ||
| 140 | * sync with subsequent `streamChanges` emissions. | ||
| 141 | * | ||
| 142 | * The Layer's two halves: | ||
| 143 | * - `ProviderInstanceRegistryMutableLayer` produces the registry + | ||
| 144 | * mutator from the initial config map. Its scope owns every | ||
| 145 | * per-instance child scope created during reconcile. | ||
| 146 | * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber | ||
| 147 | * in the same scope. | ||
| 148 | * | ||
| 149 | * Composing via `Layer.provideMerge` makes the watcher's deps available | ||
| 150 | * from the mutable layer while still surfacing the registry as an output. | ||
| 151 | * The mutator tag is technically also exposed; only this module imports | ||
| 152 | * it, so the visibility leak is harmless in practice. | ||
| 153 | */ | ||
| 154 | export const ProviderInstanceRegistryHydrationLive: Layer.Layer< | ||
| 155 | ProviderInstanceRegistry, | ||
| 156 | never, | ||
| 157 | BuiltInDriversEnv | ServerSettingsService | ||
| 158 | > = Layer.unwrap( | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const serverSettings = yield* ServerSettingsService; | ||
| 161 | const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( | ||
| 162 | Effect.orElseSucceed(() => undefined), | ||
| 163 | ); | ||
| 164 | const initialConfigMap = | ||
| 165 | initialSettings === undefined | ||
| 166 | ? ({} as ProviderInstanceConfigMap) | ||
| 167 | : deriveProviderInstanceConfigMap(initialSettings); | ||
| 168 | |||
| 169 | const mutableLayer = ProviderInstanceRegistryMutableLayer({ | ||
| 170 | drivers: BUILT_IN_DRIVERS, | ||
| 171 | configMap: initialConfigMap, | ||
| 172 | }); | ||
| 173 | |||
| 174 | return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); | ||
| 175 | }), | ||
| 176 | ) as Layer.Layer<ProviderInstanceRegistry, never, BuiltInDriversEnv | ServerSettingsService>; | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts created+357| ... | @@ -0,0 +1,357 @@ | ||
| 1 | /** | ||
| 2 | * Multi-instance validation slices for `ProviderInstanceRegistryLive`. | ||
| 3 | * | ||
| 4 | * Two axes of the driver/registry refactor are exercised here: | ||
| 5 | * | ||
| 6 | * 1. **Same driver, many instances** — the "multi-instance codex slice" | ||
| 7 | * describe block below configures two independent `codex` instances and | ||
| 8 | * asserts each gets its own closures and identity. This is the | ||
| 9 | * multi-codex capability the refactor exists to unlock. | ||
| 10 | * | ||
| 11 | * 2. **Many drivers, one registry** — the "all drivers slice" describe | ||
| 12 | * block below configures one instance of every shipped driver | ||
| 13 | * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single | ||
| 14 | * `ProviderInstanceConfigMap` and asserts the registry boots them all | ||
| 15 | * without cross-contamination. This proves the driver SPI is uniform | ||
| 16 | * across every provider — any driver plugs into the registry through | ||
| 17 | * the same `ProviderDriver` value contract. | ||
| 18 | * | ||
| 19 | * Every instance in these tests is configured with `enabled: false` so the | ||
| 20 | * provider-status checks short-circuit to pending/disabled snapshots | ||
| 21 | * without trying to spawn real `codex` / `claude` / `agent` / `opencode` | ||
| 22 | * binaries. That keeps the assertions focused on registry routing | ||
| 23 | * behaviour rather than the runtime details of each provider. | ||
| 24 | */ | ||
| 25 | import { describe, expect, it } from "@effect/vitest"; | ||
| 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 27 | import { | ||
| 28 | type ClaudeSettings, | ||
| 29 | type CodexSettings, | ||
| 30 | type CursorSettings, | ||
| 31 | type OpenCodeSettings, | ||
| 32 | ProviderDriverKind, | ||
| 33 | type ProviderInstanceConfigMap, | ||
| 34 | ProviderInstanceId, | ||
| 35 | } from "@t3tools/contracts"; | ||
| 36 | import { Effect, Layer } from "effect"; | ||
| 37 | |||
| 38 | import { ServerConfig } from "../../config.ts"; | ||
| 39 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | ||
| 40 | import { CodexDriver } from "../Drivers/CodexDriver.ts"; | ||
| 41 | import { CursorDriver } from "../Drivers/CursorDriver.ts"; | ||
| 42 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; | ||
| 43 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | ||
| 44 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | ||
| 45 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; | ||
| 46 | |||
| 47 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ | ||
| 48 | enabled: false, | ||
| 49 | binaryPath: "codex", | ||
| 50 | homePath: "", | ||
| 51 | shadowHomePath: "", | ||
| 52 | customModels: [], | ||
| 53 | ...overrides, | ||
| 54 | }); | ||
| 55 | |||
| 56 | const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => ({ | ||
| 57 | enabled: false, | ||
| 58 | binaryPath: "claude", | ||
| 59 | homePath: "", | ||
| 60 | customModels: [], | ||
| 61 | launchArgs: "", | ||
| 62 | ...overrides, | ||
| 63 | }); | ||
| 64 | |||
| 65 | const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => ({ | ||
| 66 | enabled: false, | ||
| 67 | binaryPath: "agent", | ||
| 68 | apiEndpoint: "", | ||
| 69 | customModels: [], | ||
| 70 | ...overrides, | ||
| 71 | }); | ||
| 72 | |||
| 73 | const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({ | ||
| 74 | enabled: false, | ||
| 75 | binaryPath: "opencode", | ||
| 76 | serverUrl: "", | ||
| 77 | serverPassword: "", | ||
| 78 | customModels: [], | ||
| 79 | ...overrides, | ||
| 80 | }); | ||
| 81 | |||
| 82 | describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ||
| 83 | // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch | ||
| 84 | // directory. `Layer.merge` just unions requirements, so we have to push | ||
| 85 | // `NodeServices.layer` through `Layer.provideMerge` to satisfy that | ||
| 86 | // dependency while still surfacing NodeServices to the test body (the | ||
| 87 | // codex driver's `create` yields `ChildProcessSpawner` directly). | ||
| 88 | const testLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 89 | prefix: "provider-instance-registry-test", | ||
| 90 | }).pipe( | ||
| 91 | Layer.provideMerge(NodeServices.layer), | ||
| 92 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 93 | ); | ||
| 94 | |||
| 95 | it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 98 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 99 | const codexDriverKind = ProviderDriverKind.make("codex"); | ||
| 100 | |||
| 101 | const configMap: ProviderInstanceConfigMap = { | ||
| 102 | [personalId]: { | ||
| 103 | driver: codexDriverKind, | ||
| 104 | displayName: "Codex (personal)", | ||
| 105 | enabled: false, | ||
| 106 | config: makeCodexConfig({ | ||
| 107 | binaryPath: "/opt/codex-personal/bin/codex", | ||
| 108 | homePath: "/home/julius/.codex_personal", | ||
| 109 | customModels: ["personal-preview"], | ||
| 110 | }), | ||
| 111 | }, | ||
| 112 | [workId]: { | ||
| 113 | driver: codexDriverKind, | ||
| 114 | displayName: "Codex (work)", | ||
| 115 | enabled: false, | ||
| 116 | config: makeCodexConfig({ | ||
| 117 | binaryPath: "/opt/codex-work/bin/codex", | ||
| 118 | homePath: "/home/julius/.codex", | ||
| 119 | customModels: ["work-preview"], | ||
| 120 | }), | ||
| 121 | }, | ||
| 122 | }; | ||
| 123 | |||
| 124 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 125 | drivers: [CodexDriver], | ||
| 126 | configMap, | ||
| 127 | }); | ||
| 128 | |||
| 129 | const instances = yield* registry.listInstances; | ||
| 130 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | ||
| 131 | [personalId, workId].toSorted(), | ||
| 132 | ); | ||
| 133 | expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); | ||
| 134 | expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( | ||
| 135 | ["Codex (personal)", "Codex (work)"].toSorted(), | ||
| 136 | ); | ||
| 137 | |||
| 138 | // Each instance must be retrievable by id and carry its *own* closures. | ||
| 139 | const personal = yield* registry.getInstance(personalId); | ||
| 140 | const work = yield* registry.getInstance(workId); | ||
| 141 | expect(personal).toBeDefined(); | ||
| 142 | expect(work).toBeDefined(); | ||
| 143 | expect(personal!.adapter).not.toBe(work!.adapter); | ||
| 144 | expect(personal!.textGeneration).not.toBe(work!.textGeneration); | ||
| 145 | expect(personal!.snapshot).not.toBe(work!.snapshot); | ||
| 146 | |||
| 147 | // Snapshots identify themselves by instanceId + driver — this is | ||
| 148 | // what makes per-instance routing distinguishable downstream. | ||
| 149 | const personalSnapshot = yield* personal!.snapshot.getSnapshot; | ||
| 150 | expect(personalSnapshot.instanceId).toBe(personalId); | ||
| 151 | expect(personalSnapshot.driver).toBe(codexDriverKind); | ||
| 152 | expect(personalSnapshot.enabled).toBe(false); | ||
| 153 | expect(personalSnapshot.continuation?.groupKey).toBe( | ||
| 154 | "codex:home:/home/julius/.codex_personal", | ||
| 155 | ); | ||
| 156 | |||
| 157 | const workSnapshot = yield* work!.snapshot.getSnapshot; | ||
| 158 | expect(workSnapshot.instanceId).toBe(workId); | ||
| 159 | expect(workSnapshot.driver).toBe(codexDriverKind); | ||
| 160 | expect(workSnapshot.enabled).toBe(false); | ||
| 161 | expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); | ||
| 162 | |||
| 163 | // Nothing goes to the unavailable bucket — both drivers are registered. | ||
| 164 | const unavailable = yield* registry.listUnavailable; | ||
| 165 | expect(unavailable).toEqual([]); | ||
| 166 | }).pipe(Effect.provide(testLayer)), | ||
| 167 | ); | ||
| 168 | |||
| 169 | it.live( | ||
| 170 | "shadows instances whose driver is not registered in this build without failing boot", | ||
| 171 | () => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const codexId = ProviderInstanceId.make("codex_main"); | ||
| 174 | const ghostId = ProviderInstanceId.make("ghost_main"); | ||
| 175 | |||
| 176 | const configMap: ProviderInstanceConfigMap = { | ||
| 177 | [codexId]: { | ||
| 178 | driver: ProviderDriverKind.make("codex"), | ||
| 179 | enabled: false, | ||
| 180 | config: makeCodexConfig({}), | ||
| 181 | }, | ||
| 182 | [ghostId]: { | ||
| 183 | driver: ProviderDriverKind.make("ghostDriver"), | ||
| 184 | displayName: "A fork-only driver we don't ship", | ||
| 185 | enabled: false, | ||
| 186 | config: { arbitrary: "payload", preserved: true }, | ||
| 187 | }, | ||
| 188 | }; | ||
| 189 | |||
| 190 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 191 | drivers: [CodexDriver], | ||
| 192 | configMap, | ||
| 193 | }); | ||
| 194 | |||
| 195 | const instances = yield* registry.listInstances; | ||
| 196 | expect(instances).toHaveLength(1); | ||
| 197 | expect(instances[0]!.instanceId).toBe(codexId); | ||
| 198 | |||
| 199 | const unavailable = yield* registry.listUnavailable; | ||
| 200 | expect(unavailable).toHaveLength(1); | ||
| 201 | const ghost = unavailable[0]!; | ||
| 202 | expect(ghost.instanceId).toBe(ghostId); | ||
| 203 | expect(ghost.driver).toBe("ghostDriver"); | ||
| 204 | expect(ghost.availability).toBe("unavailable"); | ||
| 205 | expect(ghost.unavailableReason).toMatch(/ghostDriver/); | ||
| 206 | }).pipe(Effect.provide(testLayer)), | ||
| 207 | ); | ||
| 208 | }); | ||
| 209 | |||
| 210 | describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ||
| 211 | // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + | ||
| 212 | // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` | ||
| 213 | // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. | ||
| 214 | // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via | ||
| 215 | // `Layer.provide`, so the only external requirement it still exposes is | ||
| 216 | // `ChildProcessSpawner` — resolved here by piping it through | ||
| 217 | // `provideMerge(NodeServices.layer)`. | ||
| 218 | // | ||
| 219 | // The nested `provideMerge`s read bottom-up: `NodeServices.layer` | ||
| 220 | // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs | ||
| 221 | // surfaced; that merged layer then provides `ServerConfig.layerTest`'s | ||
| 222 | // `FileSystem` dep while keeping everything else surfaced to the test. | ||
| 223 | const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 224 | const testLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 225 | prefix: "provider-instance-registry-all-drivers-test", | ||
| 226 | }).pipe( | ||
| 227 | Layer.provideMerge(infraLayer), | ||
| 228 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.live("boots one instance of every shipped driver from a single config map", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const codexId = ProviderInstanceId.make("codex_default"); | ||
| 234 | const claudeId = ProviderInstanceId.make("claude_default"); | ||
| 235 | const cursorId = ProviderInstanceId.make("cursor_default"); | ||
| 236 | const openCodeId = ProviderInstanceId.make("opencode_default"); | ||
| 237 | |||
| 238 | const codexDriverKind = ProviderDriverKind.make("codex"); | ||
| 239 | const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); | ||
| 240 | const cursorDriverKind = ProviderDriverKind.make("cursor"); | ||
| 241 | const openCodeDriverKind = ProviderDriverKind.make("opencode"); | ||
| 242 | |||
| 243 | const configMap: ProviderInstanceConfigMap = { | ||
| 244 | [codexId]: { | ||
| 245 | driver: codexDriverKind, | ||
| 246 | displayName: "Codex", | ||
| 247 | enabled: false, | ||
| 248 | config: makeCodexConfig({ homePath: "/home/julius/.codex" }), | ||
| 249 | }, | ||
| 250 | [claudeId]: { | ||
| 251 | driver: claudeDriverKind, | ||
| 252 | displayName: "Claude", | ||
| 253 | enabled: false, | ||
| 254 | config: makeClaudeConfig({ | ||
| 255 | homePath: "/home/julius/.claude-work", | ||
| 256 | launchArgs: "--verbose", | ||
| 257 | }), | ||
| 258 | }, | ||
| 259 | [cursorId]: { | ||
| 260 | driver: cursorDriverKind, | ||
| 261 | displayName: "Cursor", | ||
| 262 | enabled: false, | ||
| 263 | config: makeCursorConfig({}), | ||
| 264 | }, | ||
| 265 | [openCodeId]: { | ||
| 266 | driver: openCodeDriverKind, | ||
| 267 | displayName: "OpenCode", | ||
| 268 | enabled: false, | ||
| 269 | config: makeOpenCodeConfig({}), | ||
| 270 | }, | ||
| 271 | }; | ||
| 272 | |||
| 273 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 274 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], | ||
| 275 | configMap, | ||
| 276 | }); | ||
| 277 | |||
| 278 | // Every configured instance must materialize — none downgraded to a | ||
| 279 | // shadow snapshot, because every driver in the map is registered. | ||
| 280 | const unavailable = yield* registry.listUnavailable; | ||
| 281 | expect(unavailable).toEqual([]); | ||
| 282 | |||
| 283 | const instances = yield* registry.listInstances; | ||
| 284 | expect(instances).toHaveLength(4); | ||
| 285 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | ||
| 286 | [codexId, claudeId, cursorId, openCodeId].toSorted(), | ||
| 287 | ); | ||
| 288 | |||
| 289 | // Instance lookup by id resolves each instance to its own bundle — | ||
| 290 | // this is how rest-of-server routes turn/session calls in the new | ||
| 291 | // model. Each driver's bundle carries its advertised `driverKind`. | ||
| 292 | const codex = yield* registry.getInstance(codexId); | ||
| 293 | const claude = yield* registry.getInstance(claudeId); | ||
| 294 | const cursor = yield* registry.getInstance(cursorId); | ||
| 295 | const openCode = yield* registry.getInstance(openCodeId); | ||
| 296 | expect(codex?.driverKind).toBe(codexDriverKind); | ||
| 297 | expect(claude?.driverKind).toBe(claudeDriverKind); | ||
| 298 | expect(cursor?.driverKind).toBe(cursorDriverKind); | ||
| 299 | expect(openCode?.driverKind).toBe(openCodeDriverKind); | ||
| 300 | expect(codex?.displayName).toBe("Codex"); | ||
| 301 | expect(claude?.displayName).toBe("Claude"); | ||
| 302 | expect(cursor?.displayName).toBe("Cursor"); | ||
| 303 | expect(openCode?.displayName).toBe("OpenCode"); | ||
| 304 | |||
| 305 | // Every instance owns its own set of closures — no sharing across | ||
| 306 | // drivers. `adapter` / `textGeneration` / `snapshot` are all | ||
| 307 | // distinct references even when two instances happen to share a | ||
| 308 | // trait (e.g. Cursor + others all use a stub-or-real | ||
| 309 | // `textGeneration`; they must still be different object values). | ||
| 310 | const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; | ||
| 311 | expect(new Set(adapters).size).toBe(adapters.length); | ||
| 312 | const textGenerations = [ | ||
| 313 | codex!.textGeneration, | ||
| 314 | claude!.textGeneration, | ||
| 315 | cursor!.textGeneration, | ||
| 316 | openCode!.textGeneration, | ||
| 317 | ]; | ||
| 318 | expect(new Set(textGenerations).size).toBe(textGenerations.length); | ||
| 319 | const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; | ||
| 320 | expect(new Set(snapshots).size).toBe(snapshots.length); | ||
| 321 | |||
| 322 | // Snapshots identify themselves by `instanceId` + `driver` so | ||
| 323 | // downstream aggregation in `ProviderRegistry` can tell instances | ||
| 324 | // apart even when two share a driver. With `enabled: false`, the | ||
| 325 | // check short-circuits and we get a disabled/pending snapshot back | ||
| 326 | // — that's enough signal to validate the stamping wrapper without | ||
| 327 | // spawning real binaries. | ||
| 328 | const codexSnapshot = yield* codex!.snapshot.getSnapshot; | ||
| 329 | expect(codexSnapshot.instanceId).toBe(codexId); | ||
| 330 | expect(codexSnapshot.driver).toBe(codexDriverKind); | ||
| 331 | expect(codexSnapshot.enabled).toBe(false); | ||
| 332 | expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); | ||
| 333 | |||
| 334 | const claudeSnapshot = yield* claude!.snapshot.getSnapshot; | ||
| 335 | expect(claudeSnapshot.instanceId).toBe(claudeId); | ||
| 336 | expect(claudeSnapshot.driver).toBe(claudeDriverKind); | ||
| 337 | expect(claudeSnapshot.enabled).toBe(false); | ||
| 338 | expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); | ||
| 339 | |||
| 340 | const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; | ||
| 341 | expect(cursorSnapshot.instanceId).toBe(cursorId); | ||
| 342 | expect(cursorSnapshot.driver).toBe(cursorDriverKind); | ||
| 343 | expect(cursorSnapshot.enabled).toBe(false); | ||
| 344 | expect(cursorSnapshot.continuation?.groupKey).toBe( | ||
| 345 | `${cursorDriverKind}:instance:${cursorId}`, | ||
| 346 | ); | ||
| 347 | |||
| 348 | const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; | ||
| 349 | expect(openCodeSnapshot.instanceId).toBe(openCodeId); | ||
| 350 | expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); | ||
| 351 | expect(openCodeSnapshot.enabled).toBe(false); | ||
| 352 | expect(openCodeSnapshot.continuation?.groupKey).toBe( | ||
| 353 | `${openCodeDriverKind}:instance:${openCodeId}`, | ||
| 354 | ); | ||
| 355 | }).pipe(Effect.provide(testLayer)), | ||
| 356 | ); | ||
| 357 | }); | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts created+434| ... | @@ -0,0 +1,434 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryLive — runtime implementation of | ||
| 3 | * `ProviderInstanceRegistry` plus its sibling mutator. | ||
| 4 | * | ||
| 5 | * Materializes every entry in a `ProviderInstanceConfigMap`: | ||
| 6 | * | ||
| 7 | * - When the entry's `driver` matches a registered driver, the registry | ||
| 8 | * decodes the opaque `config` envelope through `driver.configSchema` | ||
| 9 | * and calls `driver.create()` inside a fresh child scope. The | ||
| 10 | * resulting `ProviderInstance` is stored keyed by instance id, | ||
| 11 | * alongside its scope so the entry can be torn down independently. | ||
| 12 | * - When the entry's `driver` is unknown to this build (fork, rollback, | ||
| 13 | * in-flight PR branch), the registry emits an `"unavailable"` shadow | ||
| 14 | * `ServerProvider` snapshot instead of failing. This is what makes | ||
| 15 | * downgrades and fork-hopping safe per the | ||
| 16 | * `forward/backward compatibility invariant` in | ||
| 17 | * `packages/contracts/src/providerInstance.ts`. | ||
| 18 | * - When the entry's config fails schema decode, the registry logs and | ||
| 19 | * emits a shadow snapshot with the schema detail — same bucket as an | ||
| 20 | * unknown driver. | ||
| 21 | * | ||
| 22 | * Unlike the pre-Slice-D layer, the registry now holds mutable state | ||
| 23 | * (`Ref`s + `PubSub`) and exposes an internal mutator | ||
| 24 | * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a | ||
| 25 | * fresh config map against the live state, tearing down removed instances | ||
| 26 | * and building new ones without disturbing unaffected instances. | ||
| 27 | * | ||
| 28 | * Every live instance runs inside its own child `Scope`. The registry's | ||
| 29 | * own scope owns all child scopes via finalizers, so closing the registry | ||
| 30 | * tears every instance down in reverse order; closing a single instance | ||
| 31 | * (via `reconcile` removing it) leaves the rest untouched. | ||
| 32 | * | ||
| 33 | * @module provider/Layers/ProviderInstanceRegistryLive | ||
| 34 | */ | ||
| 35 | import { | ||
| 36 | defaultInstanceIdForDriver, | ||
| 37 | ProviderInstanceId, | ||
| 38 | type ProviderInstanceConfig, | ||
| 39 | type ProviderInstanceConfigMap, | ||
| 40 | type ProviderDriverKind, | ||
| 41 | type ServerProvider, | ||
| 42 | } from "@t3tools/contracts"; | ||
| 43 | import { Context, Effect, Equal, Exit, Layer, PubSub, Ref, Schema, Scope, Stream } from "effect"; | ||
| 44 | |||
| 45 | import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; | ||
| 46 | import { | ||
| 47 | ProviderInstanceRegistry, | ||
| 48 | type ProviderInstanceRegistryShape, | ||
| 49 | } from "../Services/ProviderInstanceRegistry.ts"; | ||
| 50 | import { | ||
| 51 | ProviderInstanceRegistryMutator, | ||
| 52 | type ProviderInstanceRegistryMutatorShape, | ||
| 53 | } from "../Services/ProviderInstanceRegistryMutator.ts"; | ||
| 54 | import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; | ||
| 55 | |||
| 56 | /** | ||
| 57 | * Live registry entry: the materialized `ProviderInstance` + the fresh | ||
| 58 | * child scope its `create` effect ran in + the original `entry` envelope | ||
| 59 | * so `reconcile` can cheaply detect "no-op" updates. | ||
| 60 | */ | ||
| 61 | interface LiveEntry { | ||
| 62 | readonly instance: ProviderInstance; | ||
| 63 | readonly scope: Scope.Closeable; | ||
| 64 | readonly entry: ProviderInstanceConfig; | ||
| 65 | } | ||
| 66 | |||
| 67 | /** | ||
| 68 | * Internal state shared between the public registry service and the | ||
| 69 | * mutator service. Both services are thin shells around these refs. | ||
| 70 | */ | ||
| 71 | interface RegistryState { | ||
| 72 | readonly entries: Ref.Ref<ReadonlyMap<ProviderInstanceId, LiveEntry>>; | ||
| 73 | readonly unavailable: Ref.Ref<ReadonlyMap<ProviderInstanceId, ServerProvider>>; | ||
| 74 | readonly changes: PubSub.PubSub<void>; | ||
| 75 | } | ||
| 76 | |||
| 77 | /** | ||
| 78 | * Structural equality on `ProviderInstanceConfig` envelopes. Used by | ||
| 79 | * `reconcile` to skip rebuilds when settings arrive unchanged. Config | ||
| 80 | * payloads are opaque `unknown` at the envelope layer; `Equal.equals` | ||
| 81 | * falls back to structural equality for plain records, which matches how | ||
| 82 | * the schema decode output is constructed. | ||
| 83 | */ | ||
| 84 | const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => | ||
| 85 | Equal.equals(a, b); | ||
| 86 | |||
| 87 | const decodedConfigEnabled = (config: unknown): boolean | undefined => { | ||
| 88 | if (!config || typeof config !== "object" || globalThis.Array.isArray(config)) { | ||
| 89 | return undefined; | ||
| 90 | } | ||
| 91 | const enabled = (config as { readonly enabled?: unknown }).enabled; | ||
| 92 | return typeof enabled === "boolean" ? enabled : undefined; | ||
| 93 | }; | ||
| 94 | |||
| 95 | /** | ||
| 96 | * Build one live entry from a raw config envelope. Returns either a | ||
| 97 | * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and | ||
| 98 | * undefined entry — callers dispatch to the appropriate Ref bucket. | ||
| 99 | */ | ||
| 100 | const buildEntry = <R>(input: { | ||
| 101 | readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>; | ||
| 102 | readonly parentScope: Scope.Scope; | ||
| 103 | readonly instanceId: ProviderInstanceId; | ||
| 104 | readonly rawInstanceId: string; | ||
| 105 | readonly entry: ProviderInstanceConfig; | ||
| 106 | }): Effect.Effect< | ||
| 107 | | { readonly kind: "live"; readonly live: LiveEntry } | ||
| 108 | | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, | ||
| 109 | never, | ||
| 110 | R | ||
| 111 | > => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; | ||
| 114 | const driver = driversById.get(entry.driver); | ||
| 115 | if (!driver) { | ||
| 116 | return { | ||
| 117 | kind: "unavailable" as const, | ||
| 118 | snapshot: buildUnavailableProviderSnapshot({ | ||
| 119 | driverKind: entry.driver, | ||
| 120 | instanceId, | ||
| 121 | displayName: entry.displayName, | ||
| 122 | accentColor: entry.accentColor, | ||
| 123 | reason: `Driver '${entry.driver}' is not registered in this build.`, | ||
| 124 | }), | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | const decoder = Schema.decodeUnknownEffect(driver.configSchema); | ||
| 129 | const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); | ||
| 130 | if (decodeResult._tag === "Failure") { | ||
| 131 | const issue = decodeResult.failure; | ||
| 132 | const detail = issue.message ?? String(issue); | ||
| 133 | yield* Effect.logError("Failed to decode provider instance config", { | ||
| 134 | instanceId: rawInstanceId, | ||
| 135 | driver: entry.driver, | ||
| 136 | detail, | ||
| 137 | }); | ||
| 138 | return { | ||
| 139 | kind: "unavailable" as const, | ||
| 140 | snapshot: buildUnavailableProviderSnapshot({ | ||
| 141 | driverKind: entry.driver, | ||
| 142 | instanceId, | ||
| 143 | displayName: entry.displayName, | ||
| 144 | accentColor: entry.accentColor, | ||
| 145 | reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, | ||
| 146 | }), | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | const typedConfig = decodeResult.success; | ||
| 151 | const childScope = yield* Scope.make(); | ||
| 152 | // Attach the child scope to the registry's parent scope: if the | ||
| 153 | // registry scope closes, each surviving instance's child scope is | ||
| 154 | // closed through this finalizer. `reconcile` manually closes the | ||
| 155 | // child scope on remove/replace; subsequent close via the parent's | ||
| 156 | // finalizer is a no-op because `Scope.close` is idempotent. | ||
| 157 | yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); | ||
| 158 | |||
| 159 | const createResult = yield* driver | ||
| 160 | .create({ | ||
| 161 | instanceId, | ||
| 162 | displayName: entry.displayName, | ||
| 163 | accentColor: entry.accentColor, | ||
| 164 | environment: entry.environment ?? [], | ||
| 165 | enabled: entry.enabled ?? decodedConfigEnabled(typedConfig) ?? true, | ||
| 166 | config: typedConfig, | ||
| 167 | }) | ||
| 168 | .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); | ||
| 169 | if (createResult._tag === "Failure") { | ||
| 170 | yield* Effect.logError("Failed to create provider instance", { | ||
| 171 | instanceId: rawInstanceId, | ||
| 172 | driver: entry.driver, | ||
| 173 | detail: createResult.failure.detail, | ||
| 174 | }); | ||
| 175 | yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); | ||
| 176 | return { | ||
| 177 | kind: "unavailable" as const, | ||
| 178 | snapshot: buildUnavailableProviderSnapshot({ | ||
| 179 | driverKind: entry.driver, | ||
| 180 | instanceId, | ||
| 181 | displayName: entry.displayName, | ||
| 182 | accentColor: entry.accentColor, | ||
| 183 | reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, | ||
| 184 | }), | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | return { | ||
| 189 | kind: "live" as const, | ||
| 190 | live: { | ||
| 191 | instance: createResult.success, | ||
| 192 | scope: childScope, | ||
| 193 | entry, | ||
| 194 | }, | ||
| 195 | }; | ||
| 196 | }); | ||
| 197 | |||
| 198 | /** | ||
| 199 | * Reconcile-only implementation of the mutator. Exposed to the hydration | ||
| 200 | * layer; never called directly by the rest of the server. | ||
| 201 | */ | ||
| 202 | const makeReconcile = <R>(input: { | ||
| 203 | readonly state: RegistryState; | ||
| 204 | readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>; | ||
| 205 | readonly parentScope: Scope.Scope; | ||
| 206 | }): ((configMap: ProviderInstanceConfigMap) => Effect.Effect<void, never, R>) => { | ||
| 207 | const { state, driversById, parentScope } = input; | ||
| 208 | return (configMap: ProviderInstanceConfigMap) => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const previousEntries = yield* Ref.get(state.entries); | ||
| 211 | const previousUnavailable = yield* Ref.get(state.unavailable); | ||
| 212 | const nextRaw = Object.entries(configMap); | ||
| 213 | const nextKeys = new Set<ProviderInstanceId>( | ||
| 214 | nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), | ||
| 215 | ); | ||
| 216 | |||
| 217 | // 1. Close scopes for instances that disappeared or whose config | ||
| 218 | // changed. Do this BEFORE creating replacements so ids map 1-to-1 | ||
| 219 | // to live scopes at all times. | ||
| 220 | const removedIds: Array<ProviderInstanceId> = []; | ||
| 221 | const replacedIds = new Set<ProviderInstanceId>(); | ||
| 222 | for (const [instanceId, live] of previousEntries) { | ||
| 223 | if (!nextKeys.has(instanceId)) { | ||
| 224 | removedIds.push(instanceId); | ||
| 225 | continue; | ||
| 226 | } | ||
| 227 | const nextEntry = configMap[instanceId]; | ||
| 228 | if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { | ||
| 229 | replacedIds.add(instanceId); | ||
| 230 | } | ||
| 231 | } | ||
| 232 | for (const id of [...removedIds, ...replacedIds]) { | ||
| 233 | const live = previousEntries.get(id); | ||
| 234 | if (live) { | ||
| 235 | yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | // 2. Build additions and replacements. Walk `nextRaw` so the final | ||
| 240 | // entry order follows settings-author order. | ||
| 241 | const builtEntries = new Map<ProviderInstanceId, LiveEntry>(); | ||
| 242 | const builtUnavailable = new Map<ProviderInstanceId, ServerProvider>(); | ||
| 243 | let orderChanged = false; | ||
| 244 | const previousOrder = [...previousEntries.keys()]; | ||
| 245 | const nextOrder: Array<ProviderInstanceId> = []; | ||
| 246 | |||
| 247 | for (const [rawInstanceId, entry] of nextRaw) { | ||
| 248 | const instanceId = ProviderInstanceId.make(rawInstanceId); | ||
| 249 | nextOrder.push(instanceId); | ||
| 250 | |||
| 251 | const existing = previousEntries.get(instanceId); | ||
| 252 | if (existing !== undefined && !replacedIds.has(instanceId)) { | ||
| 253 | // No-op update: keep the existing live entry and scope. | ||
| 254 | builtEntries.set(instanceId, existing); | ||
| 255 | continue; | ||
| 256 | } | ||
| 257 | |||
| 258 | const result = yield* buildEntry({ | ||
| 259 | driversById, | ||
| 260 | parentScope, | ||
| 261 | instanceId, | ||
| 262 | rawInstanceId, | ||
| 263 | entry, | ||
| 264 | }); | ||
| 265 | if (result.kind === "live") { | ||
| 266 | builtEntries.set(instanceId, result.live); | ||
| 267 | } else { | ||
| 268 | builtUnavailable.set(instanceId, result.snapshot); | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | if (previousOrder.length === nextOrder.length) { | ||
| 273 | for (let i = 0; i < previousOrder.length; i++) { | ||
| 274 | if (previousOrder[i] !== nextOrder[i]) { | ||
| 275 | orderChanged = true; | ||
| 276 | break; | ||
| 277 | } | ||
| 278 | } | ||
| 279 | } else { | ||
| 280 | orderChanged = true; | ||
| 281 | } | ||
| 282 | |||
| 283 | const entriesChanged = | ||
| 284 | orderChanged || | ||
| 285 | removedIds.length > 0 || | ||
| 286 | replacedIds.size > 0 || | ||
| 287 | builtEntries.size !== previousEntries.size; | ||
| 288 | const unavailableChanged = | ||
| 289 | builtUnavailable.size !== previousUnavailable.size || | ||
| 290 | [...builtUnavailable].some(([id, snapshot]) => { | ||
| 291 | const prev = previousUnavailable.get(id); | ||
| 292 | return prev === undefined || !Equal.equals(prev, snapshot); | ||
| 293 | }) || | ||
| 294 | [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); | ||
| 295 | |||
| 296 | yield* Ref.set(state.entries, builtEntries); | ||
| 297 | yield* Ref.set(state.unavailable, builtUnavailable); | ||
| 298 | |||
| 299 | if (entriesChanged || unavailableChanged) { | ||
| 300 | yield* PubSub.publish(state.changes, undefined); | ||
| 301 | } | ||
| 302 | }); | ||
| 303 | }; | ||
| 304 | |||
| 305 | /** | ||
| 306 | * Build the registry's runtime state from a concrete configMap. Returns a | ||
| 307 | * record containing: | ||
| 308 | * | ||
| 309 | * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose | ||
| 310 | * under `ProviderInstanceRegistry`. | ||
| 311 | * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose | ||
| 312 | * under `ProviderInstanceRegistryMutator`. | ||
| 313 | * - `reconcile`: the raw reconcile function, provided for convenience so | ||
| 314 | * boot-time layers can hydrate an initial map before publishing the | ||
| 315 | * services. | ||
| 316 | * | ||
| 317 | * The scope that this effect runs in owns every per-instance child scope | ||
| 318 | * created during `reconcile`. Closing that scope closes every live | ||
| 319 | * instance. | ||
| 320 | */ | ||
| 321 | export const makeProviderInstanceRegistry = <R>(input: { | ||
| 322 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 323 | readonly configMap: ProviderInstanceConfigMap; | ||
| 324 | }): Effect.Effect< | ||
| 325 | { | ||
| 326 | readonly registry: ProviderInstanceRegistryShape; | ||
| 327 | readonly mutator: ProviderInstanceRegistryMutatorShape; | ||
| 328 | }, | ||
| 329 | never, | ||
| 330 | R | Scope.Scope | ||
| 331 | > => | ||
| 332 | Effect.gen(function* () { | ||
| 333 | const driversById = new Map<ProviderDriverKind, AnyProviderDriver<R>>( | ||
| 334 | input.drivers.map((driver) => [driver.driverKind, driver]), | ||
| 335 | ); | ||
| 336 | |||
| 337 | // Capture the enclosing scope so per-instance child scopes can be | ||
| 338 | // attached to it at `reconcile` time. Without this, `reconcile` | ||
| 339 | // called later (e.g. from the hydration layer) would attach child | ||
| 340 | // scopes to the *caller's* scope instead of the registry's. | ||
| 341 | const parentScope = yield* Scope.Scope; | ||
| 342 | |||
| 343 | // Capture the driver R context at construction time so `reconcile` | ||
| 344 | // can be invoked later without re-providing driver dependencies. | ||
| 345 | // The service tag's declared `reconcile: Effect<void>` hides R from | ||
| 346 | // consumers — we materialize that here. | ||
| 347 | const driverContext = yield* Effect.context<R>(); | ||
| 348 | |||
| 349 | const entries = yield* Ref.make<ReadonlyMap<ProviderInstanceId, LiveEntry>>(new Map()); | ||
| 350 | const unavailable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ServerProvider>>(new Map()); | ||
| 351 | const changes = yield* PubSub.unbounded<void>(); | ||
| 352 | yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); | ||
| 353 | |||
| 354 | const state: RegistryState = { entries, unavailable, changes }; | ||
| 355 | const reconcileWithR = makeReconcile({ state, driversById, parentScope }); | ||
| 356 | const reconcile: ProviderInstanceRegistryMutatorShape["reconcile"] = (configMap) => | ||
| 357 | reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); | ||
| 358 | |||
| 359 | // Hydrate the initial configMap synchronously so callers can read | ||
| 360 | // `listInstances` immediately after this effect completes. | ||
| 361 | yield* reconcile(input.configMap); | ||
| 362 | |||
| 363 | const registry: ProviderInstanceRegistryShape = { | ||
| 364 | getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), | ||
| 365 | listInstances: Ref.get(entries).pipe( | ||
| 366 | Effect.map( | ||
| 367 | (map) => | ||
| 368 | Array.from(map.values(), (live) => live.instance) as ReadonlyArray<ProviderInstance>, | ||
| 369 | ), | ||
| 370 | ), | ||
| 371 | listUnavailable: Ref.get(unavailable).pipe( | ||
| 372 | Effect.map((map) => Array.from(map.values()) as ReadonlyArray<ServerProvider>), | ||
| 373 | ), | ||
| 374 | // Getters: each read constructs a fresh Stream / Effect descriptor | ||
| 375 | // so multiple consumers don't share a single already-started | ||
| 376 | // Channel or subscription. Matches the pattern `ProviderRegistry` | ||
| 377 | // uses for its own `streamChanges`. | ||
| 378 | get streamChanges() { | ||
| 379 | return Stream.fromPubSub(changes); | ||
| 380 | }, | ||
| 381 | // Synchronous subscribe — callers that need to consume changes | ||
| 382 | // from a forked fibre must acquire the subscription in their own | ||
| 383 | // fibre first (via `yield* registry.subscribeChanges`) and only | ||
| 384 | // then fork a consumer loop on `Stream.fromSubscription(...)` / | ||
| 385 | // `PubSub.take(...)`. See the shape docs for the race this avoids. | ||
| 386 | get subscribeChanges() { | ||
| 387 | return PubSub.subscribe(changes); | ||
| 388 | }, | ||
| 389 | }; | ||
| 390 | |||
| 391 | const mutator: ProviderInstanceRegistryMutatorShape = { reconcile }; | ||
| 392 | |||
| 393 | return { registry, mutator }; | ||
| 394 | }); | ||
| 395 | |||
| 396 | /** | ||
| 397 | * Assemble a `ProviderInstanceRegistry` Layer bound to a fixed set of | ||
| 398 | * drivers and a pre-resolved `ProviderInstanceConfigMap`. Used by tests | ||
| 399 | * that want explicit control over the registry's source-of-truth without | ||
| 400 | * wiring up the settings watcher. | ||
| 401 | * | ||
| 402 | * Only exposes the public registry tag — hot-reload consumers should use | ||
| 403 | * `ProviderInstanceRegistryMutableLayer` (below) or the hydration layer. | ||
| 404 | */ | ||
| 405 | export const ProviderInstanceRegistryLayer = <R>(input: { | ||
| 406 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 407 | readonly configMap: ProviderInstanceConfigMap; | ||
| 408 | }): Layer.Layer<ProviderInstanceRegistry, never, R> => | ||
| 409 | Layer.effect( | ||
| 410 | ProviderInstanceRegistry, | ||
| 411 | makeProviderInstanceRegistry(input).pipe(Effect.map((built) => built.registry)), | ||
| 412 | ) as Layer.Layer<ProviderInstanceRegistry, never, R>; | ||
| 413 | |||
| 414 | /** | ||
| 415 | * Layer variant that also exposes the mutator tag. Consumed by | ||
| 416 | * `ProviderInstanceRegistryHydrationLive` to reconcile on settings | ||
| 417 | * changes. Tests that exercise the mutator directly can pair this Layer | ||
| 418 | * with a test-local `ServerSettingsService`. | ||
| 419 | */ | ||
| 420 | export const ProviderInstanceRegistryMutableLayer = <R>(input: { | ||
| 421 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 422 | readonly configMap: ProviderInstanceConfigMap; | ||
| 423 | }): Layer.Layer<ProviderInstanceRegistry | ProviderInstanceRegistryMutator, never, R> => | ||
| 424 | Layer.effectContext( | ||
| 425 | makeProviderInstanceRegistry(input).pipe( | ||
| 426 | Effect.map(({ registry, mutator }) => | ||
| 427 | Context.make(ProviderInstanceRegistry, registry).pipe( | ||
| 428 | Context.add(ProviderInstanceRegistryMutator, mutator), | ||
| 429 | ), | ||
| 430 | ), | ||
| 431 | ), | ||
| 432 | ) as Layer.Layer<ProviderInstanceRegistry | ProviderInstanceRegistryMutator, never, R>; | ||
| 433 | |||
| 434 | export { defaultInstanceIdForDriver }; | ||
apps/server/src/provider/Layers/ProviderRegistry.test.ts+681-169| ... | @@ -1,11 +1,16 @@ | ... | @@ -1,11 +1,16 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, it, assert } from "@effect/vitest"; | 2 | import { describe, it, assert, live } from "@effect/vitest"; |
| 3 | import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect"; | 3 | import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect"; |
| 4 | import * as CodexErrors from "effect-codex-app-server/errors"; | 4 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 5 | import { | 5 | import { |
| 6 | ClaudeSettings, | ||
| 7 | CodexSettings, | ||
| 6 | DEFAULT_SERVER_SETTINGS, | 8 | DEFAULT_SERVER_SETTINGS, |
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 7 | ServerSettings, | 11 | ServerSettings, |
| 8 | type ServerProvider, | 12 | type ServerProvider, |
| 13 | type ServerProviderSlashCommand, | ||
| 9 | type ServerSettings as ContractServerSettings, | 14 | type ServerSettings as ContractServerSettings, |
| 10 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 11 | import * as PlatformError from "effect/PlatformError"; | 16 | import * as PlatformError from "effect/PlatformError"; |
| ... | @@ -14,7 +19,10 @@ import { deepMerge } from "@t3tools/shared/Struct"; | ... | @@ -14,7 +19,10 @@ import { deepMerge } from "@t3tools/shared/Struct"; |
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; | 19 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 15 | 20 | ||
| 16 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | 21 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 17 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; | 22 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| 23 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | ||
| 24 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | ||
| 25 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; | ||
| 18 | import { | 26 | import { |
| 19 | haveProvidersChanged, | 27 | haveProvidersChanged, |
| 20 | mergeProviderSnapshot, | 28 | mergeProviderSnapshot, |
| ... | @@ -22,8 +30,16 @@ import { | ... | @@ -22,8 +30,16 @@ import { |
| 22 | } from "./ProviderRegistry.ts"; | 30 | } from "./ProviderRegistry.ts"; |
| 23 | import { ServerConfig } from "../../config.ts"; | 31 | import { ServerConfig } from "../../config.ts"; |
| 24 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; | 32 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; |
| 33 | import type { ProviderInstance } from "../ProviderDriver.ts"; | ||
| 34 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | ||
| 25 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; | 35 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| 26 | 36 | ||
| 37 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | ||
| 38 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | ||
| 39 | const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ | ||
| 40 | enabled: false, | ||
| 41 | }); | ||
| 42 | |||
| 27 | process.env.T3CODE_CURSOR_ENABLED = "1"; | 43 | process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 28 | 44 | ||
| 29 | // ── Test helpers ──────────────────────────────────────────────────── | 45 | // ── Test helpers ──────────────────────────────────────────────────── |
| ... | @@ -54,6 +70,27 @@ function booleanDescriptor(id: string, label: string) { | ... | @@ -54,6 +70,27 @@ function booleanDescriptor(id: string, label: string) { |
| 54 | }; | 70 | }; |
| 55 | } | 71 | } |
| 56 | 72 | ||
| 73 | type TestClaudeCapabilities = { | ||
| 74 | readonly email: string | undefined; | ||
| 75 | readonly subscriptionType: string | undefined; | ||
| 76 | readonly tokenSource: string | undefined; | ||
| 77 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | ||
| 78 | }; | ||
| 79 | |||
| 80 | function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) { | ||
| 81 | return () => | ||
| 82 | Effect.succeed({ | ||
| 83 | email: undefined, | ||
| 84 | subscriptionType: undefined, | ||
| 85 | tokenSource: undefined, | ||
| 86 | slashCommands: [], | ||
| 87 | ...overrides, | ||
| 88 | }); | ||
| 89 | } | ||
| 90 | |||
| 91 | const noClaudeCapabilities = () => | ||
| 92 | Effect.sync(() => undefined as TestClaudeCapabilities | undefined); | ||
| 93 | |||
| 57 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { | 94 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { |
| 58 | return ChildProcessSpawner.makeHandle({ | 95 | return ChildProcessSpawner.makeHandle({ |
| 59 | pid: ChildProcessSpawner.ProcessId(1), | 96 | pid: ChildProcessSpawner.ProcessId(1), |
| ... | @@ -71,7 +108,11 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { | ... | @@ -71,7 +108,11 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { |
| 71 | } | 108 | } |
| 72 | 109 | ||
| 73 | function mockSpawnerLayer( | 110 | function mockSpawnerLayer( |
| 74 | handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number }, | 111 | handler: (args: ReadonlyArray<string>) => { |
| 112 | stdout: string; | ||
| 113 | stderr: string; | ||
| 114 | code: number; | ||
| 115 | }, | ||
| 75 | ) { | 116 | ) { |
| 76 | return Layer.succeed( | 117 | return Layer.succeed( |
| 77 | ChildProcessSpawner.ChildProcessSpawner, | 118 | ChildProcessSpawner.ChildProcessSpawner, |
| ... | @@ -82,6 +123,33 @@ function mockSpawnerLayer( | ... | @@ -82,6 +123,33 @@ function mockSpawnerLayer( |
| 82 | ); | 123 | ); |
| 83 | } | 124 | } |
| 84 | 125 | ||
| 126 | function recordingMockSpawnerLayer( | ||
| 127 | handler: (args: ReadonlyArray<string>) => { | ||
| 128 | stdout: string; | ||
| 129 | stderr: string; | ||
| 130 | code: number; | ||
| 131 | }, | ||
| 132 | ) { | ||
| 133 | const commands: Array<{ | ||
| 134 | readonly args: ReadonlyArray<string>; | ||
| 135 | readonly env: NodeJS.ProcessEnv | undefined; | ||
| 136 | }> = []; | ||
| 137 | const layer = Layer.succeed( | ||
| 138 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 139 | ChildProcessSpawner.make((command) => { | ||
| 140 | const cmd = command as unknown as { | ||
| 141 | args: ReadonlyArray<string>; | ||
| 142 | options?: { | ||
| 143 | readonly env?: NodeJS.ProcessEnv; | ||
| 144 | }; | ||
| 145 | }; | ||
| 146 | commands.push({ args: cmd.args, env: cmd.options?.env }); | ||
| 147 | return Effect.succeed(mockHandle(handler(cmd.args))); | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | return { layer, commands }; | ||
| 151 | } | ||
| 152 | |||
| 85 | function mockCommandSpawnerLayer( | 153 | function mockCommandSpawnerLayer( |
| 86 | handler: ( | 154 | handler: ( |
| 87 | command: string, | 155 | command: string, |
| ... | @@ -91,7 +159,10 @@ function mockCommandSpawnerLayer( | ... | @@ -91,7 +159,10 @@ function mockCommandSpawnerLayer( |
| 91 | return Layer.succeed( | 159 | return Layer.succeed( |
| 92 | ChildProcessSpawner.ChildProcessSpawner, | 160 | ChildProcessSpawner.ChildProcessSpawner, |
| 93 | ChildProcessSpawner.make((command) => { | 161 | ChildProcessSpawner.make((command) => { |
| 94 | const cmd = command as unknown as { command: string; args: ReadonlyArray<string> }; | 162 | const cmd = command as unknown as { |
| 163 | command: string; | ||
| 164 | args: ReadonlyArray<string>; | ||
| 165 | }; | ||
| 95 | return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); | 166 | return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); |
| 96 | }), | 167 | }), |
| 97 | ); | 168 | ); |
| ... | @@ -181,7 +252,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -181,7 +252,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 181 | describe("checkCodexProviderStatus", () => { | 252 | describe("checkCodexProviderStatus", () => { |
| 182 | it.effect("uses the app-server account and model list for provider status", () => | 253 | it.effect("uses the app-server account and model list for provider status", () => |
| 183 | Effect.gen(function* () { | 254 | Effect.gen(function* () { |
| 184 | const status = yield* checkCodexProviderStatus(() => | 255 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => |
| 185 | Effect.succeed( | 256 | Effect.succeed( |
| 186 | makeCodexProbeSnapshot({ | 257 | makeCodexProbeSnapshot({ |
| 187 | skills: [ | 258 | skills: [ |
| ... | @@ -196,14 +267,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -196,14 +267,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 196 | }), | 267 | }), |
| 197 | ), | 268 | ), |
| 198 | ); | 269 | ); |
| 199 | |||
| 200 | assert.strictEqual(status.provider, "codex"); | ||
| 201 | assert.strictEqual(status.status, "ready"); | 270 | assert.strictEqual(status.status, "ready"); |
| 202 | assert.strictEqual(status.installed, true); | 271 | assert.strictEqual(status.installed, true); |
| 203 | assert.strictEqual(status.version, "1.0.0"); | 272 | assert.strictEqual(status.version, "1.0.0"); |
| 204 | assert.strictEqual(status.auth.status, "authenticated"); | 273 | assert.strictEqual(status.auth.status, "authenticated"); |
| 205 | assert.strictEqual(status.auth.type, "chatgpt"); | 274 | assert.strictEqual(status.auth.type, "chatgpt"); |
| 206 | assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); | 275 | assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); |
| 276 | assert.strictEqual(status.auth.email, "test@example.com"); | ||
| 207 | assert.deepStrictEqual(status.models, [ | 277 | assert.deepStrictEqual(status.models, [ |
| 208 | { | 278 | { |
| 209 | slug: "gpt-live-codex", | 279 | slug: "gpt-live-codex", |
| ... | @@ -226,7 +296,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -226,7 +296,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 226 | 296 | ||
| 227 | it.effect("returns unauthenticated when app-server requires OpenAI auth", () => | 297 | it.effect("returns unauthenticated when app-server requires OpenAI auth", () => |
| 228 | Effect.gen(function* () { | 298 | Effect.gen(function* () { |
| 229 | const status = yield* checkCodexProviderStatus(() => | 299 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => |
| 230 | Effect.succeed( | 300 | Effect.succeed( |
| 231 | makeCodexProbeSnapshot({ | 301 | makeCodexProbeSnapshot({ |
| 232 | account: { | 302 | account: { |
| ... | @@ -250,7 +320,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -250,7 +320,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 250 | "returns ready with unknown auth when app-server does not require OpenAI auth", | 320 | "returns ready with unknown auth when app-server does not require OpenAI auth", |
| 251 | () => | 321 | () => |
| 252 | Effect.gen(function* () { | 322 | Effect.gen(function* () { |
| 253 | const status = yield* checkCodexProviderStatus(() => | 323 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => |
| 254 | Effect.succeed( | 324 | Effect.succeed( |
| 255 | makeCodexProbeSnapshot({ | 325 | makeCodexProbeSnapshot({ |
| 256 | account: { | 326 | account: { |
| ... | @@ -268,7 +338,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -268,7 +338,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 268 | 338 | ||
| 269 | it.effect("returns an api key label for codex api key auth", () => | 339 | it.effect("returns an api key label for codex api key auth", () => |
| 270 | Effect.gen(function* () { | 340 | Effect.gen(function* () { |
| 271 | const status = yield* checkCodexProviderStatus(() => | 341 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => |
| 272 | Effect.succeed( | 342 | Effect.succeed( |
| 273 | makeCodexProbeSnapshot({ | 343 | makeCodexProbeSnapshot({ |
| 274 | account: { | 344 | account: { |
| ... | @@ -288,7 +358,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -288,7 +358,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 288 | 358 | ||
| 289 | it.effect("returns unavailable when codex is missing", () => | 359 | it.effect("returns unavailable when codex is missing", () => |
| 290 | Effect.gen(function* () { | 360 | Effect.gen(function* () { |
| 291 | const status = yield* checkCodexProviderStatus(() => | 361 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => |
| 292 | Effect.fail( | 362 | Effect.fail( |
| 293 | new CodexErrors.CodexAppServerSpawnError({ | 363 | new CodexErrors.CodexAppServerSpawnError({ |
| 294 | command: "codex app-server", | 364 | command: "codex app-server", |
| ... | @@ -296,7 +366,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -296,7 +366,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 296 | }), | 366 | }), |
| 297 | ), | 367 | ), |
| 298 | ); | 368 | ); |
| 299 | assert.strictEqual(status.provider, "codex"); | ||
| 300 | assert.strictEqual(status.status, "error"); | 369 | assert.strictEqual(status.status, "error"); |
| 301 | assert.strictEqual(status.installed, false); | 370 | assert.strictEqual(status.installed, false); |
| 302 | assert.strictEqual(status.auth.status, "unknown"); | 371 | assert.strictEqual(status.auth.status, "unknown"); |
| ... | @@ -312,7 +381,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -312,7 +381,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 312 | it("treats equal provider snapshots as unchanged", () => { | 381 | it("treats equal provider snapshots as unchanged", () => { |
| 313 | const providers = [ | 382 | const providers = [ |
| 314 | { | 383 | { |
| 315 | provider: "codex", | 384 | instanceId: ProviderInstanceId.make("codex"), |
| 385 | driver: ProviderDriverKind.make("codex"), | ||
| 316 | status: "ready", | 386 | status: "ready", |
| 317 | enabled: true, | 387 | enabled: true, |
| 318 | installed: true, | 388 | installed: true, |
| ... | @@ -324,7 +394,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -324,7 +394,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 324 | skills: [], | 394 | skills: [], |
| 325 | }, | 395 | }, |
| 326 | { | 396 | { |
| 327 | provider: "claudeAgent", | 397 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 398 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 328 | status: "warning", | 399 | status: "warning", |
| 329 | enabled: true, | 400 | enabled: true, |
| 330 | installed: true, | 401 | installed: true, |
| ... | @@ -342,7 +413,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -342,7 +413,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 342 | 413 | ||
| 343 | it("preserves previously discovered provider models when a refresh returns none", () => { | 414 | it("preserves previously discovered provider models when a refresh returns none", () => { |
| 344 | const previousProvider = { | 415 | const previousProvider = { |
| 345 | provider: "cursor", | 416 | instanceId: ProviderInstanceId.make("cursor"), |
| 417 | driver: ProviderDriverKind.make("cursor"), | ||
| 346 | status: "ready", | 418 | status: "ready", |
| 347 | enabled: true, | 419 | enabled: true, |
| 348 | installed: true, | 420 | installed: true, |
| ... | @@ -381,7 +453,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -381,7 +453,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 381 | 453 | ||
| 382 | it("fills missing capabilities from the previous provider snapshot", () => { | 454 | it("fills missing capabilities from the previous provider snapshot", () => { |
| 383 | const previousProvider = { | 455 | const previousProvider = { |
| 384 | provider: "cursor", | 456 | instanceId: ProviderInstanceId.make("cursor"), |
| 457 | driver: ProviderDriverKind.make("cursor"), | ||
| 385 | status: "ready", | 458 | status: "ready", |
| 386 | enabled: true, | 459 | enabled: true, |
| 387 | installed: true, | 460 | installed: true, |
| ... | @@ -427,41 +500,345 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -427,41 +500,345 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 427 | ]); | 500 | ]); |
| 428 | }); | 501 | }); |
| 429 | 502 | ||
| 430 | it.effect("probes enabled providers in the background during registry startup", () => | 503 | it.effect("returns the cached provider list when a manual refresh fails", () => |
| 504 | Effect.gen(function* () { | ||
| 505 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 506 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 507 | const cachedProvider = { | ||
| 508 | instanceId: codexInstanceId, | ||
| 509 | driver: codexDriver, | ||
| 510 | status: "ready", | ||
| 511 | enabled: true, | ||
| 512 | installed: true, | ||
| 513 | auth: { status: "authenticated" }, | ||
| 514 | checkedAt: "2026-04-29T10:00:00.000Z", | ||
| 515 | version: "1.0.0", | ||
| 516 | models: [], | ||
| 517 | slashCommands: [], | ||
| 518 | skills: [], | ||
| 519 | } as const satisfies ServerProvider; | ||
| 520 | const instance = { | ||
| 521 | instanceId: codexInstanceId, | ||
| 522 | driverKind: codexDriver, | ||
| 523 | continuationIdentity: { | ||
| 524 | driverKind: codexDriver, | ||
| 525 | continuationKey: "codex:instance:codex", | ||
| 526 | }, | ||
| 527 | displayName: undefined, | ||
| 528 | enabled: true, | ||
| 529 | snapshot: { | ||
| 530 | getSnapshot: Effect.succeed(cachedProvider), | ||
| 531 | refresh: Effect.die(new Error("simulated refresh failure")), | ||
| 532 | streamChanges: Stream.empty, | ||
| 533 | }, | ||
| 534 | adapter: {} as ProviderInstance["adapter"], | ||
| 535 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 536 | } satisfies ProviderInstance; | ||
| 537 | const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { | ||
| 538 | getInstance: (instanceId) => | ||
| 539 | Effect.succeed(instanceId === codexInstanceId ? instance : undefined), | ||
| 540 | listInstances: Effect.succeed([instance]), | ||
| 541 | listUnavailable: Effect.succeed([]), | ||
| 542 | streamChanges: Stream.empty, | ||
| 543 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 544 | PubSub.subscribe(pubsub), | ||
| 545 | ), | ||
| 546 | }); | ||
| 547 | const scope = yield* Scope.make(); | ||
| 548 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 549 | const runtimeServices = yield* Layer.build( | ||
| 550 | ProviderRegistryLive.pipe( | ||
| 551 | Layer.provideMerge(instanceRegistryLayer), | ||
| 552 | Layer.provideMerge( | ||
| 553 | ServerConfig.layerTest(process.cwd(), { | ||
| 554 | prefix: "t3-provider-registry-refresh-failure-", | ||
| 555 | }), | ||
| 556 | ), | ||
| 557 | Layer.provideMerge(NodeServices.layer), | ||
| 558 | ), | ||
| 559 | ).pipe(Scope.provide(scope)); | ||
| 560 | |||
| 561 | yield* Effect.gen(function* () { | ||
| 562 | const registry = yield* ProviderRegistry; | ||
| 563 | |||
| 564 | assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); | ||
| 565 | assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); | ||
| 566 | assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ | ||
| 567 | cachedProvider, | ||
| 568 | ]); | ||
| 569 | }).pipe(Effect.provide(runtimeServices)); | ||
| 570 | }), | ||
| 571 | ); | ||
| 572 | |||
| 573 | it.effect("keeps consuming registry changes after one sync fails", () => | ||
| 574 | Effect.gen(function* () { | ||
| 575 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 576 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 577 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); | ||
| 578 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); | ||
| 579 | const codexProvider = { | ||
| 580 | instanceId: codexInstanceId, | ||
| 581 | driver: codexDriver, | ||
| 582 | status: "ready", | ||
| 583 | enabled: true, | ||
| 584 | installed: true, | ||
| 585 | auth: { status: "authenticated" }, | ||
| 586 | checkedAt: "2026-04-29T10:00:00.000Z", | ||
| 587 | version: "1.0.0", | ||
| 588 | models: [], | ||
| 589 | slashCommands: [], | ||
| 590 | skills: [], | ||
| 591 | } as const satisfies ServerProvider; | ||
| 592 | const claudeProvider = { | ||
| 593 | instanceId: claudeInstanceId, | ||
| 594 | driver: claudeDriver, | ||
| 595 | status: "ready", | ||
| 596 | enabled: true, | ||
| 597 | installed: true, | ||
| 598 | auth: { status: "authenticated" }, | ||
| 599 | checkedAt: "2026-04-29T10:01:00.000Z", | ||
| 600 | version: "1.0.0", | ||
| 601 | models: [], | ||
| 602 | slashCommands: [], | ||
| 603 | skills: [], | ||
| 604 | } as const satisfies ServerProvider; | ||
| 605 | const makeInstance = (provider: ServerProvider): ProviderInstance => ({ | ||
| 606 | instanceId: provider.instanceId, | ||
| 607 | driverKind: provider.driver, | ||
| 608 | continuationIdentity: { | ||
| 609 | driverKind: provider.driver, | ||
| 610 | continuationKey: `${provider.driver}:instance:${provider.instanceId}`, | ||
| 611 | }, | ||
| 612 | displayName: undefined, | ||
| 613 | enabled: true, | ||
| 614 | snapshot: { | ||
| 615 | getSnapshot: Effect.succeed(provider), | ||
| 616 | refresh: Effect.succeed(provider), | ||
| 617 | streamChanges: Stream.empty, | ||
| 618 | }, | ||
| 619 | adapter: {} as ProviderInstance["adapter"], | ||
| 620 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 621 | }); | ||
| 622 | const codexInstance = makeInstance(codexProvider); | ||
| 623 | const claudeInstance = makeInstance(claudeProvider); | ||
| 624 | const changes = yield* PubSub.unbounded<void>(); | ||
| 625 | const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([codexInstance]); | ||
| 626 | const failNextList = yield* Ref.make(false); | ||
| 627 | const wait = (millis: number) => | ||
| 628 | Effect.promise<void>(() => new Promise((resolve) => setTimeout(resolve, millis))); | ||
| 629 | const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { | ||
| 630 | getInstance: (instanceId) => | ||
| 631 | Ref.get(instancesRef).pipe( | ||
| 632 | Effect.map((instances) => | ||
| 633 | instances.find((instance) => instance.instanceId === instanceId), | ||
| 634 | ), | ||
| 635 | ), | ||
| 636 | listInstances: Effect.gen(function* () { | ||
| 637 | const shouldFail = yield* Ref.get(failNextList); | ||
| 638 | if (shouldFail) { | ||
| 639 | yield* Ref.set(failNextList, false); | ||
| 640 | return yield* Effect.die(new Error("simulated registry list failure")); | ||
| 641 | } | ||
| 642 | return yield* Ref.get(instancesRef); | ||
| 643 | }), | ||
| 644 | listUnavailable: Effect.succeed([]), | ||
| 645 | streamChanges: Stream.fromPubSub(changes), | ||
| 646 | subscribeChanges: PubSub.subscribe(changes), | ||
| 647 | }); | ||
| 648 | const scope = yield* Scope.make(); | ||
| 649 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 650 | const runtimeServices = yield* Layer.build( | ||
| 651 | ProviderRegistryLive.pipe( | ||
| 652 | Layer.provideMerge(instanceRegistryLayer), | ||
| 653 | Layer.provideMerge( | ||
| 654 | ServerConfig.layerTest(process.cwd(), { | ||
| 655 | prefix: "t3-provider-registry-sync-failure-", | ||
| 656 | }), | ||
| 657 | ), | ||
| 658 | Layer.provideMerge(NodeServices.layer), | ||
| 659 | ), | ||
| 660 | ).pipe(Scope.provide(scope)); | ||
| 661 | |||
| 662 | yield* Effect.gen(function* () { | ||
| 663 | const registry = yield* ProviderRegistry; | ||
| 664 | assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); | ||
| 665 | |||
| 666 | yield* Ref.set(failNextList, true); | ||
| 667 | yield* PubSub.publish(changes, undefined); | ||
| 668 | |||
| 669 | yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); | ||
| 670 | yield* PubSub.publish(changes, undefined); | ||
| 671 | |||
| 672 | let providers = yield* registry.getProviders; | ||
| 673 | for ( | ||
| 674 | let attempt = 0; | ||
| 675 | attempt < 50 && | ||
| 676 | !providers.some((provider) => provider.instanceId === claudeInstanceId); | ||
| 677 | attempt += 1 | ||
| 678 | ) { | ||
| 679 | yield* wait(10); | ||
| 680 | providers = yield* registry.getProviders; | ||
| 681 | } | ||
| 682 | |||
| 683 | assert.deepStrictEqual( | ||
| 684 | providers.map((provider) => provider.instanceId).toSorted(), | ||
| 685 | [codexInstanceId, claudeInstanceId].toSorted(), | ||
| 686 | ); | ||
| 687 | }).pipe(Effect.provide(runtimeServices)); | ||
| 688 | }), | ||
| 689 | ); | ||
| 690 | |||
| 691 | // This test intentionally avoids `mockCommandSpawnerLayer` so the real | ||
| 692 | // `probeCodexAppServerProvider` path runs — including the full | ||
| 693 | // `codex app-server` RPC handshake via `CodexClient.layerCommand`. | ||
| 694 | // We point `binaryPath` at a name that cannot exist on any machine so | ||
| 695 | // the real `ChildProcessSpawner` deterministically returns ENOENT; the | ||
| 696 | // probe wraps that as `CodexAppServerSpawnError` and | ||
| 697 | // `checkCodexProviderStatus` turns it into the user-visible "not | ||
| 698 | // installed" error snapshot. If the aggregator's `syncLiveSources` | ||
| 699 | // breaks — the `codex_personal`-never-probes bug we are guarding | ||
| 700 | // against — that snapshot never lands in `getProviders` and the | ||
| 701 | // assertions below fail. | ||
| 702 | it.effect("propagates real Codex probe failures to the aggregator at boot", () => | ||
| 431 | Effect.gen(function* () { | 703 | Effect.gen(function* () { |
| 432 | let spawnCount = 0; | 704 | const missingBinary = `t3code_codex_missing_${process.pid}_${Date.now()}`; |
| 433 | const serverSettings = yield* makeMutableServerSettingsService( | 705 | const serverSettings = yield* makeMutableServerSettingsService( |
| 434 | Schema.decodeSync(ServerSettings)( | 706 | Schema.decodeSync(ServerSettings)( |
| 435 | deepMerge(DEFAULT_SERVER_SETTINGS, { | 707 | deepMerge(DEFAULT_SERVER_SETTINGS, { |
| 436 | providers: { | 708 | providers: { |
| 709 | // Disable every built-in probe that would otherwise spawn | ||
| 710 | // on the CI host. `enabled: false` short-circuits each | ||
| 711 | // driver's probe *before* it touches the spawner, so the | ||
| 712 | // test environment stays isolated from the dev | ||
| 713 | // machine's PATH. | ||
| 437 | codex: { enabled: false }, | 714 | codex: { enabled: false }, |
| 715 | claudeAgent: { enabled: false }, | ||
| 438 | cursor: { enabled: false }, | 716 | cursor: { enabled: false }, |
| 717 | opencode: { enabled: false }, | ||
| 439 | }, | 718 | }, |
| 719 | // `providerInstances` keys are branded `ProviderInstanceId`; | ||
| 720 | // the branded index signature rejects plain string literals | ||
| 721 | // at the TS level even though the runtime schema happily | ||
| 722 | // accepts + decodes them. Cast the patch to `unknown` so | ||
| 723 | // the `Schema.decodeSync` below does the real validation. | ||
| 724 | providerInstances: { | ||
| 725 | // Matches the shape the user had in `.t3/dev/settings.json` | ||
| 726 | // when the bug was reported: a custom enabled Codex instance | ||
| 727 | // pointing at a binary the server has to actually spawn. | ||
| 728 | codex_personal: { | ||
| 729 | driver: "codex", | ||
| 730 | displayName: "Codex Personal", | ||
| 731 | enabled: true, | ||
| 732 | config: { | ||
| 733 | binaryPath: missingBinary, | ||
| 734 | homePath: `/tmp/${missingBinary}_home`, | ||
| 735 | }, | ||
| 736 | }, | ||
| 737 | } as unknown as ContractServerSettings["providerInstances"], | ||
| 440 | }), | 738 | }), |
| 441 | ), | 739 | ), |
| 442 | ); | 740 | ); |
| 443 | const scope = yield* Scope.make(); | 741 | const scope = yield* Scope.make(); |
| 444 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | 742 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); |
| 445 | const providerRegistryLayer = ProviderRegistryLive.pipe( | 743 | const providerRegistryLayer = ProviderRegistryLive.pipe( |
| 744 | Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | ||
| 446 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), | 745 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), |
| 447 | Layer.provideMerge( | 746 | Layer.provideMerge( |
| 448 | ServerConfig.layerTest(process.cwd(), { | 747 | ServerConfig.layerTest(process.cwd(), { |
| 449 | prefix: "t3-provider-registry-", | 748 | prefix: "t3-provider-registry-", |
| 450 | }), | 749 | }), |
| 451 | ), | 750 | ), |
| 751 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 752 | Layer.provideMerge(OpenCodeRuntimeLive), | ||
| 753 | // NO spawner mock — `ChildProcessSpawner` is supplied by the | ||
| 754 | // outer `NodeServices.layer` on `it.layer(...)` and will | ||
| 755 | // genuinely spawn a subprocess. The missing-binary ENOENT is | ||
| 756 | // what exercises the same failure mode as a misconfigured | ||
| 757 | // production `binaryPath`. | ||
| 758 | ); | ||
| 759 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | ||
| 760 | Scope.provide(scope), | ||
| 761 | ); | ||
| 762 | |||
| 763 | yield* Effect.gen(function* () { | ||
| 764 | const registry = yield* ProviderRegistry; | ||
| 765 | const providers = yield* registry.getProviders; | ||
| 766 | const codexPersonal = providers.find( | ||
| 767 | (provider) => provider.instanceId === "codex_personal", | ||
| 768 | ); | ||
| 769 | assert.notStrictEqual( | ||
| 770 | codexPersonal, | ||
| 771 | undefined, | ||
| 772 | `Expected the aggregator to know about codex_personal; instead saw: ${providers | ||
| 773 | .map((provider) => provider.instanceId) | ||
| 774 | .join(", ")}`, | ||
| 775 | ); | ||
| 776 | assert.strictEqual( | ||
| 777 | codexPersonal?.status, | ||
| 778 | "error", | ||
| 779 | "Real Codex probe against a missing binary should surface as 'error' in the aggregator", | ||
| 780 | ); | ||
| 781 | assert.strictEqual(codexPersonal?.installed, false); | ||
| 782 | assert.strictEqual( | ||
| 783 | codexPersonal?.message, | ||
| 784 | "Codex CLI (`codex`) is not installed or not on PATH.", | ||
| 785 | ); | ||
| 786 | }).pipe(Effect.provide(runtimeServices)); | ||
| 787 | }), | ||
| 788 | ); | ||
| 789 | |||
| 790 | // Guards the second half of the reported bug: changing | ||
| 791 | // `providers.codex.binaryPath` in settings must tear down the live | ||
| 792 | // instance and rebuild it so a fresh probe runs with the new binary. | ||
| 793 | // This test drives the real settings stream → registry reconcile → | ||
| 794 | // aggregator sync pipeline and asserts that `getProviders` reflects | ||
| 795 | // the new probe's outcome. If `syncLiveSources` stops awaiting the | ||
| 796 | // rebuilt instance's refresh (previous bug mode), the aggregator | ||
| 797 | // keeps the old snapshot and this test fails. | ||
| 798 | // | ||
| 799 | // `live` (imported from `@effect/vitest`) is used instead of | ||
| 800 | // `it.effect` so real timers coordinate the fibres that drive the | ||
| 801 | // settings → reconcile → sync pipeline. Under `it.effect`'s | ||
| 802 | // TestClock, `Effect.sleep` blocks until `TestClock.adjust`, which | ||
| 803 | // would require this test to reach into the internals of the | ||
| 804 | // reconcile pipeline to advance it step by step. | ||
| 805 | // | ||
| 806 | // The nested `it` handed to `it.layer(…, (it) => …)` is the | ||
| 807 | // `MethodsNonLive` variant and therefore lacks `.live`; the | ||
| 808 | // top-level `live` export from `@effect/vitest` is the equivalent. | ||
| 809 | live("re-probes when settings change the codex binaryPath", () => | ||
| 810 | Effect.gen(function* () { | ||
| 811 | const firstMissing = `t3code_codex_first_${process.pid}_${Date.now()}`; | ||
| 812 | const secondMissing = `t3code_codex_second_${process.pid}_${Date.now()}`; | ||
| 813 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 814 | Schema.decodeSync(ServerSettings)( | ||
| 815 | deepMerge(DEFAULT_SERVER_SETTINGS, { | ||
| 816 | providers: { | ||
| 817 | codex: { enabled: true, binaryPath: firstMissing }, | ||
| 818 | claudeAgent: { enabled: false }, | ||
| 819 | cursor: { enabled: false }, | ||
| 820 | opencode: { enabled: false }, | ||
| 821 | }, | ||
| 822 | }), | ||
| 823 | ), | ||
| 824 | ); | ||
| 825 | const scope = yield* Scope.make(); | ||
| 826 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 827 | const providerRegistryLayer = ProviderRegistryLive.pipe( | ||
| 828 | Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | ||
| 829 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), | ||
| 452 | Layer.provideMerge( | 830 | Layer.provideMerge( |
| 453 | mockCommandSpawnerLayer((command, args) => { | 831 | ServerConfig.layerTest(process.cwd(), { |
| 454 | spawnCount += 1; | 832 | prefix: "t3-provider-registry-", |
| 455 | const joined = args.join(" "); | ||
| 456 | if (joined === "--version") { | ||
| 457 | return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; | ||
| 458 | } | ||
| 459 | if (joined === "auth status") { | ||
| 460 | return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; | ||
| 461 | } | ||
| 462 | throw new Error(`Unexpected args: ${command} ${joined}`); | ||
| 463 | }), | 833 | }), |
| 464 | ), | 834 | ), |
| 835 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 836 | Layer.provideMerge(OpenCodeRuntimeLive), | ||
| 837 | // `it.live` does not inherit layers from the outer `it.layer` | ||
| 838 | // wrapper, so provide `NodeServices.layer` inline. This is the | ||
| 839 | // same real `ChildProcessSpawner` + `FileSystem` + `Path` | ||
| 840 | // services that production uses. | ||
| 841 | Layer.provideMerge(NodeServices.layer), | ||
| 465 | ); | 842 | ); |
| 466 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | 843 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 467 | Scope.provide(scope), | 844 | Scope.provide(scope), |
| ... | @@ -469,24 +846,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -469,24 +846,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 469 | 846 | ||
| 470 | yield* Effect.gen(function* () { | 847 | yield* Effect.gen(function* () { |
| 471 | const registry = yield* ProviderRegistry; | 848 | const registry = yield* ProviderRegistry; |
| 472 | assert.strictEqual(spawnCount > 0, true); | 849 | // Boot-time probe: the default codex instance is enabled with |
| 850 | // `firstMissing`, so the real spawner yields ENOENT and the | ||
| 851 | // snapshot should be `status: "error"`. What *distinguishes* | ||
| 852 | // the two probe runs is `checkedAt` — each probe stamps a | ||
| 853 | // fresh DateTime, so we capture it and assert it advances | ||
| 854 | // after the settings mutation. | ||
| 855 | const initialProviders = yield* registry.getProviders; | ||
| 856 | const initialCodex = initialProviders.find( | ||
| 857 | (provider) => provider.instanceId === "codex", | ||
| 858 | ); | ||
| 859 | assert.strictEqual(initialCodex?.status, "error"); | ||
| 860 | assert.strictEqual(initialCodex?.installed, false); | ||
| 861 | const initialCheckedAt = initialCodex?.checkedAt; | ||
| 862 | assert.notStrictEqual(initialCheckedAt, undefined); | ||
| 863 | |||
| 864 | // Drive a settings change. The Hydration layer's | ||
| 865 | // `SettingsWatcherLive` consumes this via `streamChanges`, | ||
| 866 | // calls `reconcile`, which rebuilds the codex instance (the | ||
| 867 | // envelope changed because `binaryPath` differs → `entryEqual` | ||
| 868 | // is false). The registry's `Stream.runForEach( | ||
| 869 | // instanceRegistry.streamChanges, () => syncLiveSources)` | ||
| 870 | // fires `syncLiveSources`, which subscribes + awaits a fresh | ||
| 871 | // refresh on the rebuilt instance. | ||
| 872 | yield* serverSettings.updateSettings({ | ||
| 873 | providers: { | ||
| 874 | codex: { enabled: true, binaryPath: secondMissing }, | ||
| 875 | }, | ||
| 876 | }); | ||
| 877 | |||
| 878 | // Poll with real timers (via `it.live`) until `checkedAt` | ||
| 879 | // advances or we hit a generous 3-second ceiling. Anything | ||
| 880 | // slower than that is a regression — the real probe fails | ||
| 881 | // fast on ENOENT, and the reconcile + sync pipeline is | ||
| 882 | // purely in-process. | ||
| 473 | const refreshed = yield* Effect.gen(function* () { | 883 | const refreshed = yield* Effect.gen(function* () { |
| 474 | for (let remainingAttempts = 50; remainingAttempts > 0; remainingAttempts -= 1) { | 884 | for (let attempts = 0; attempts < 60; attempts += 1) { |
| 475 | const providers = yield* registry.getProviders; | 885 | const providers = yield* registry.getProviders; |
| 476 | const claudeProvider = providers.find( | 886 | const codex = providers.find((provider) => provider.instanceId === "codex"); |
| 477 | (provider) => provider.provider === "claudeAgent", | 887 | if (codex !== undefined && codex.checkedAt !== initialCheckedAt) { |
| 478 | ); | ||
| 479 | if (claudeProvider?.status === "ready") { | ||
| 480 | return providers; | 888 | return providers; |
| 481 | } | 889 | } |
| 482 | yield* Effect.sleep("10 millis"); | 890 | yield* Effect.sleep("50 millis"); |
| 483 | } | 891 | } |
| 484 | return yield* registry.getProviders; | 892 | return yield* registry.getProviders; |
| 485 | }); | 893 | }); |
| 486 | assert.strictEqual( | 894 | |
| 487 | refreshed.find((provider) => provider.provider === "claudeAgent")?.status, | 895 | const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); |
| 488 | "ready", | 896 | assert.notStrictEqual( |
| 897 | reprobedCodex?.checkedAt, | ||
| 898 | initialCheckedAt, | ||
| 899 | "Expected a fresh probe after settings change, got the stale snapshot", | ||
| 489 | ); | 900 | ); |
| 901 | assert.strictEqual(reprobedCodex?.status, "error"); | ||
| 902 | assert.strictEqual(reprobedCodex?.installed, false); | ||
| 903 | }).pipe(Effect.provide(runtimeServices)); | ||
| 904 | }), | ||
| 905 | ); | ||
| 906 | |||
| 907 | it.effect("includes unavailable instance snapshots in getProviders", () => | ||
| 908 | Effect.gen(function* () { | ||
| 909 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 910 | Schema.decodeSync(ServerSettings)( | ||
| 911 | deepMerge(DEFAULT_SERVER_SETTINGS, { | ||
| 912 | providers: { | ||
| 913 | codex: { enabled: false }, | ||
| 914 | claudeAgent: { enabled: false }, | ||
| 915 | cursor: { enabled: false }, | ||
| 916 | opencode: { enabled: false }, | ||
| 917 | }, | ||
| 918 | providerInstances: { | ||
| 919 | ghost_main: { | ||
| 920 | driver: "ghostDriver", | ||
| 921 | displayName: "A fork-only driver we don't ship", | ||
| 922 | enabled: false, | ||
| 923 | config: { arbitrary: "payload" }, | ||
| 924 | }, | ||
| 925 | } as unknown as ContractServerSettings["providerInstances"], | ||
| 926 | }), | ||
| 927 | ), | ||
| 928 | ); | ||
| 929 | const scope = yield* Scope.make(); | ||
| 930 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 931 | const providerRegistryLayer = ProviderRegistryLive.pipe( | ||
| 932 | Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | ||
| 933 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), | ||
| 934 | Layer.provideMerge( | ||
| 935 | ServerConfig.layerTest(process.cwd(), { | ||
| 936 | prefix: "t3-provider-registry-", | ||
| 937 | }), | ||
| 938 | ), | ||
| 939 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 940 | Layer.provideMerge(OpenCodeRuntimeLive), | ||
| 941 | Layer.provideMerge(NodeServices.layer), | ||
| 942 | ); | ||
| 943 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | ||
| 944 | Scope.provide(scope), | ||
| 945 | ); | ||
| 946 | |||
| 947 | yield* Effect.gen(function* () { | ||
| 948 | const registry = yield* ProviderRegistry; | ||
| 949 | const providers = yield* registry.getProviders; | ||
| 950 | const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); | ||
| 951 | |||
| 952 | assert.notStrictEqual(ghost, undefined); | ||
| 953 | assert.strictEqual(ghost?.driver, "ghostDriver"); | ||
| 954 | assert.strictEqual(ghost?.availability, "unavailable"); | ||
| 955 | assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); | ||
| 490 | }).pipe(Effect.provide(runtimeServices)); | 956 | }).pipe(Effect.provide(runtimeServices)); |
| 491 | }), | 957 | }), |
| 492 | ); | 958 | ); |
| ... | @@ -513,12 +979,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -513,12 +979,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 513 | const scope = yield* Scope.make(); | 979 | const scope = yield* Scope.make(); |
| 514 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | 980 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); |
| 515 | const providerRegistryLayer = ProviderRegistryLive.pipe( | 981 | const providerRegistryLayer = ProviderRegistryLive.pipe( |
| 982 | Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | ||
| 516 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), | 983 | Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), |
| 517 | Layer.provideMerge( | 984 | Layer.provideMerge( |
| 518 | ServerConfig.layerTest(process.cwd(), { | 985 | ServerConfig.layerTest(process.cwd(), { |
| 519 | prefix: "t3-provider-registry-", | 986 | prefix: "t3-provider-registry-", |
| 520 | }), | 987 | }), |
| 521 | ), | 988 | ), |
| 989 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 990 | Layer.provideMerge(OpenCodeRuntimeLive), | ||
| 522 | Layer.provideMerge( | 991 | Layer.provideMerge( |
| 523 | mockCommandSpawnerLayer((command, args) => { | 992 | mockCommandSpawnerLayer((command, args) => { |
| 524 | if (command === "agent") { | 993 | if (command === "agent") { |
| ... | @@ -526,10 +995,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -526,10 +995,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 526 | } | 995 | } |
| 527 | const joined = args.join(" "); | 996 | const joined = args.join(" "); |
| 528 | if (joined === "--version") { | 997 | if (joined === "--version") { |
| 529 | return { stdout: `${command} 1.0.0\n`, stderr: "", code: 0 }; | 998 | return { |
| 999 | stdout: `${command} 1.0.0\n`, | ||
| 1000 | stderr: "", | ||
| 1001 | code: 0, | ||
| 1002 | }; | ||
| 530 | } | 1003 | } |
| 531 | if (joined === "auth status") { | 1004 | if (joined === "auth status") { |
| 532 | return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; | 1005 | return { |
| 1006 | stdout: '{"authenticated":true}\n', | ||
| 1007 | stderr: "", | ||
| 1008 | code: 0, | ||
| 1009 | }; | ||
| 533 | } | 1010 | } |
| 534 | throw new Error(`Unexpected args: ${command} ${joined}`); | 1011 | throw new Error(`Unexpected args: ${command} ${joined}`); |
| 535 | }), | 1012 | }), |
| ... | @@ -545,12 +1022,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -545,12 +1022,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 545 | yield* Effect.gen(function* () { | 1022 | yield* Effect.gen(function* () { |
| 546 | const registry = yield* ProviderRegistry; | 1023 | const registry = yield* ProviderRegistry; |
| 547 | const providers = yield* registry.getProviders; | 1024 | const providers = yield* registry.getProviders; |
| 548 | const cursorProvider = providers.find((provider) => provider.provider === "cursor"); | 1025 | const cursorProvider = providers.find( |
| 549 | 1026 | (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), | |
| 550 | assert.deepStrictEqual( | ||
| 551 | providers.map((provider) => provider.provider), | ||
| 552 | ["codex", "claudeAgent", "opencode", "cursor"], | ||
| 553 | ); | 1027 | ); |
| 1028 | |||
| 1029 | assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ | ||
| 1030 | "claudeAgent", | ||
| 1031 | "codex", | ||
| 1032 | "cursor", | ||
| 1033 | "opencode", | ||
| 1034 | ]); | ||
| 554 | assert.strictEqual(cursorProvider?.enabled, false); | 1035 | assert.strictEqual(cursorProvider?.enabled, false); |
| 555 | assert.strictEqual(cursorProvider?.status, "disabled"); | 1036 | assert.strictEqual(cursorProvider?.status, "disabled"); |
| 556 | assert.strictEqual( | 1037 | assert.strictEqual( |
| ... | @@ -564,20 +1045,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -564,20 +1045,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 564 | 1045 | ||
| 565 | it.effect("skips codex probes entirely when the provider is disabled", () => | 1046 | it.effect("skips codex probes entirely when the provider is disabled", () => |
| 566 | Effect.gen(function* () { | 1047 | Effect.gen(function* () { |
| 567 | const serverSettingsLayer = ServerSettingsService.layerTest({ | 1048 | const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( |
| 568 | providers: { | 1049 | Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), |
| 569 | codex: { | ||
| 570 | enabled: false, | ||
| 571 | }, | ||
| 572 | }, | ||
| 573 | }); | ||
| 574 | |||
| 575 | const status = yield* checkCodexProviderStatus().pipe( | ||
| 576 | Effect.provide( | ||
| 577 | Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), | ||
| 578 | ), | ||
| 579 | ); | 1050 | ); |
| 580 | assert.strictEqual(status.provider, "codex"); | ||
| 581 | assert.strictEqual(status.enabled, false); | 1051 | assert.strictEqual(status.enabled, false); |
| 582 | assert.strictEqual(status.status, "disabled"); | 1052 | assert.strictEqual(status.status, "disabled"); |
| 583 | assert.strictEqual(status.installed, false); | 1053 | assert.strictEqual(status.installed, false); |
| ... | @@ -591,8 +1061,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -591,8 +1061,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 591 | describe("checkClaudeProviderStatus", () => { | 1061 | describe("checkClaudeProviderStatus", () => { |
| 592 | it.effect("returns ready when claude is installed and authenticated", () => | 1062 | it.effect("returns ready when claude is installed and authenticated", () => |
| 593 | Effect.gen(function* () { | 1063 | Effect.gen(function* () { |
| 594 | const status = yield* checkClaudeProviderStatus(); | 1064 | const status = yield* checkClaudeProviderStatus( |
| 595 | assert.strictEqual(status.provider, "claudeAgent"); | 1065 | defaultClaudeSettings, |
| 1066 | claudeCapabilities(), | ||
| 1067 | ); | ||
| 596 | assert.strictEqual(status.status, "ready"); | 1068 | assert.strictEqual(status.status, "ready"); |
| 597 | assert.strictEqual(status.installed, true); | 1069 | assert.strictEqual(status.installed, true); |
| 598 | assert.strictEqual(status.auth.status, "authenticated"); | 1070 | assert.strictEqual(status.auth.status, "authenticated"); |
| ... | @@ -617,7 +1089,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -617,7 +1089,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 617 | "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", | 1089 | "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", |
| 618 | () => | 1090 | () => |
| 619 | Effect.gen(function* () { | 1091 | Effect.gen(function* () { |
| 620 | const status = yield* checkClaudeProviderStatus(); | 1092 | const status = yield* checkClaudeProviderStatus( |
| 1093 | defaultClaudeSettings, | ||
| 1094 | claudeCapabilities(), | ||
| 1095 | ); | ||
| 621 | const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); | 1096 | const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); |
| 622 | if (!opus47) { | 1097 | if (!opus47) { |
| 623 | assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); | 1098 | assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); |
| ... | @@ -655,7 +1130,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -655,7 +1130,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 655 | 1130 | ||
| 656 | it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => | 1131 | it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => |
| 657 | Effect.gen(function* () { | 1132 | Effect.gen(function* () { |
| 658 | const status = yield* checkClaudeProviderStatus(); | 1133 | const status = yield* checkClaudeProviderStatus( |
| 1134 | defaultClaudeSettings, | ||
| 1135 | claudeCapabilities(), | ||
| 1136 | ); | ||
| 659 | assert.strictEqual( | 1137 | assert.strictEqual( |
| 660 | status.models.some((model) => model.slug === "claude-opus-4-7"), | 1138 | status.models.some((model) => model.slug === "claude-opus-4-7"), |
| 661 | false, | 1139 | false, |
| ... | @@ -683,8 +1161,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -683,8 +1161,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 683 | 1161 | ||
| 684 | it.effect("returns a display label for claude subscription types", () => | 1162 | it.effect("returns a display label for claude subscription types", () => |
| 685 | Effect.gen(function* () { | 1163 | Effect.gen(function* () { |
| 686 | const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); | 1164 | const status = yield* checkClaudeProviderStatus( |
| 687 | assert.strictEqual(status.provider, "claudeAgent"); | 1165 | defaultClaudeSettings, |
| 1166 | claudeCapabilities({ subscriptionType: "maxplan" }), | ||
| 1167 | ); | ||
| 688 | assert.strictEqual(status.status, "ready"); | 1168 | assert.strictEqual(status.status, "ready"); |
| 689 | assert.strictEqual(status.auth.status, "authenticated"); | 1169 | assert.strictEqual(status.auth.status, "authenticated"); |
| 690 | assert.strictEqual(status.auth.type, "maxplan"); | 1170 | assert.strictEqual(status.auth.type, "maxplan"); |
| ... | @@ -706,18 +1186,120 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -706,18 +1186,120 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 706 | ), | 1186 | ), |
| 707 | ); | 1187 | ); |
| 708 | 1188 | ||
| 1189 | it.effect("does not duplicate Claude in full subscription labels", () => | ||
| 1190 | Effect.gen(function* () { | ||
| 1191 | const status = yield* checkClaudeProviderStatus( | ||
| 1192 | defaultClaudeSettings, | ||
| 1193 | claudeCapabilities({ | ||
| 1194 | subscriptionType: "Claude Max Subscription", | ||
| 1195 | }), | ||
| 1196 | ); | ||
| 1197 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 1198 | assert.strictEqual(status.auth.type, "Claude Max Subscription"); | ||
| 1199 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 1200 | }).pipe( | ||
| 1201 | Effect.provide( | ||
| 1202 | mockSpawnerLayer((args) => { | ||
| 1203 | const joined = args.join(" "); | ||
| 1204 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 1205 | throw new Error(`Unexpected args: ${joined}`); | ||
| 1206 | }), | ||
| 1207 | ), | ||
| 1208 | ), | ||
| 1209 | ); | ||
| 1210 | |||
| 1211 | it.effect("does not duplicate Claude in provider-prefixed subscription names", () => | ||
| 1212 | Effect.gen(function* () { | ||
| 1213 | const status = yield* checkClaudeProviderStatus( | ||
| 1214 | defaultClaudeSettings, | ||
| 1215 | claudeCapabilities({ | ||
| 1216 | subscriptionType: "Claude Max", | ||
| 1217 | }), | ||
| 1218 | ); | ||
| 1219 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 1220 | assert.strictEqual(status.auth.type, "Claude Max"); | ||
| 1221 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 1222 | }).pipe( | ||
| 1223 | Effect.provide( | ||
| 1224 | mockSpawnerLayer((args) => { | ||
| 1225 | const joined = args.join(" "); | ||
| 1226 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 1227 | throw new Error(`Unexpected args: ${joined}`); | ||
| 1228 | }), | ||
| 1229 | ), | ||
| 1230 | ), | ||
| 1231 | ); | ||
| 1232 | |||
| 1233 | it.effect("returns claude auth email from initialization result", () => | ||
| 1234 | Effect.gen(function* () { | ||
| 1235 | const status = yield* checkClaudeProviderStatus( | ||
| 1236 | defaultClaudeSettings, | ||
| 1237 | claudeCapabilities({ email: "claude@example.com" }), | ||
| 1238 | ); | ||
| 1239 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 1240 | assert.strictEqual(status.auth.email, "claude@example.com"); | ||
| 1241 | }).pipe( | ||
| 1242 | Effect.provide( | ||
| 1243 | mockSpawnerLayer((args) => { | ||
| 1244 | const joined = args.join(" "); | ||
| 1245 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 1246 | if (joined === "auth status") | ||
| 1247 | return { | ||
| 1248 | stdout: | ||
| 1249 | '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', | ||
| 1250 | stderr: "", | ||
| 1251 | code: 0, | ||
| 1252 | }; | ||
| 1253 | throw new Error(`Unexpected args: ${joined}`); | ||
| 1254 | }), | ||
| 1255 | ), | ||
| 1256 | ), | ||
| 1257 | ); | ||
| 1258 | |||
| 1259 | it.effect("runs Claude status probes with the configured Claude HOME", () => { | ||
| 1260 | const claudeHome = "/tmp/t3code-claude-home"; | ||
| 1261 | const recorded = recordingMockSpawnerLayer((args) => { | ||
| 1262 | const joined = args.join(" "); | ||
| 1263 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 1264 | if (joined === "auth status") | ||
| 1265 | return { | ||
| 1266 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 1267 | stderr: "", | ||
| 1268 | code: 0, | ||
| 1269 | }; | ||
| 1270 | throw new Error(`Unexpected args: ${joined}`); | ||
| 1271 | }); | ||
| 1272 | |||
| 1273 | return Effect.gen(function* () { | ||
| 1274 | const status = yield* checkClaudeProviderStatus( | ||
| 1275 | { | ||
| 1276 | ...defaultClaudeSettings, | ||
| 1277 | homePath: claudeHome, | ||
| 1278 | }, | ||
| 1279 | claudeCapabilities(), | ||
| 1280 | ); | ||
| 1281 | assert.strictEqual(status.status, "ready"); | ||
| 1282 | assert.deepStrictEqual( | ||
| 1283 | recorded.commands.map((command) => command.env?.HOME), | ||
| 1284 | [claudeHome], | ||
| 1285 | ); | ||
| 1286 | }).pipe(Effect.provide(recorded.layer)); | ||
| 1287 | }); | ||
| 1288 | |||
| 709 | it.effect("includes probed claude slash commands in the provider snapshot", () => | 1289 | it.effect("includes probed claude slash commands in the provider snapshot", () => |
| 710 | Effect.gen(function* () { | 1290 | Effect.gen(function* () { |
| 711 | const status = yield* checkClaudeProviderStatus( | 1291 | const status = yield* checkClaudeProviderStatus( |
| 712 | () => Effect.succeed("maxplan"), | 1292 | defaultClaudeSettings, |
| 713 | () => | 1293 | claudeCapabilities({ |
| 714 | Effect.succeed([ | 1294 | subscriptionType: "maxplan", |
| 1295 | slashCommands: [ | ||
| 715 | { | 1296 | { |
| 716 | name: "review", | 1297 | name: "review", |
| 717 | description: "Review a pull request", | 1298 | description: "Review a pull request", |
| 718 | input: { hint: "pr-or-branch" }, | 1299 | input: { hint: "pr-or-branch" }, |
| 719 | }, | 1300 | }, |
| 720 | ]), | 1301 | ], |
| 1302 | }), | ||
| 721 | ); | 1303 | ); |
| 722 | 1304 | ||
| 723 | assert.deepStrictEqual(status.slashCommands, [ | 1305 | assert.deepStrictEqual(status.slashCommands, [ |
| ... | @@ -747,9 +1329,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -747,9 +1329,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 747 | it.effect("deduplicates probed claude slash commands by name", () => | 1329 | it.effect("deduplicates probed claude slash commands by name", () => |
| 748 | Effect.gen(function* () { | 1330 | Effect.gen(function* () { |
| 749 | const status = yield* checkClaudeProviderStatus( | 1331 | const status = yield* checkClaudeProviderStatus( |
| 750 | () => Effect.succeed("maxplan"), | 1332 | defaultClaudeSettings, |
| 751 | () => | 1333 | claudeCapabilities({ |
| 752 | Effect.succeed([ | 1334 | subscriptionType: "maxplan", |
| 1335 | slashCommands: [ | ||
| 753 | { | 1336 | { |
| 754 | name: "ui", | 1337 | name: "ui", |
| 755 | description: "Explore and refine UI", | 1338 | description: "Explore and refine UI", |
| ... | @@ -758,7 +1341,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -758,7 +1341,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 758 | name: "ui", | 1341 | name: "ui", |
| 759 | input: { hint: "component-or-screen" }, | 1342 | input: { hint: "component-or-screen" }, |
| 760 | }, | 1343 | }, |
| 761 | ]), | 1344 | ], |
| 1345 | }), | ||
| 762 | ); | 1346 | ); |
| 763 | 1347 | ||
| 764 | assert.deepStrictEqual(status.slashCommands, [ | 1348 | assert.deepStrictEqual(status.slashCommands, [ |
| ... | @@ -787,8 +1371,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -787,8 +1371,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 787 | 1371 | ||
| 788 | it.effect("returns an api key label for claude api key auth", () => | 1372 | it.effect("returns an api key label for claude api key auth", () => |
| 789 | Effect.gen(function* () { | 1373 | Effect.gen(function* () { |
| 790 | const status = yield* checkClaudeProviderStatus(); | 1374 | const status = yield* checkClaudeProviderStatus( |
| 791 | assert.strictEqual(status.provider, "claudeAgent"); | 1375 | defaultClaudeSettings, |
| 1376 | claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), | ||
| 1377 | ); | ||
| 792 | assert.strictEqual(status.status, "ready"); | 1378 | assert.strictEqual(status.status, "ready"); |
| 793 | assert.strictEqual(status.auth.status, "authenticated"); | 1379 | assert.strictEqual(status.auth.status, "authenticated"); |
| 794 | assert.strictEqual(status.auth.type, "apiKey"); | 1380 | assert.strictEqual(status.auth.type, "apiKey"); |
| ... | @@ -812,8 +1398,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -812,8 +1398,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 812 | 1398 | ||
| 813 | it.effect("returns unavailable when claude is missing", () => | 1399 | it.effect("returns unavailable when claude is missing", () => |
| 814 | Effect.gen(function* () { | 1400 | Effect.gen(function* () { |
| 815 | const status = yield* checkClaudeProviderStatus(); | 1401 | const status = yield* checkClaudeProviderStatus( |
| 816 | assert.strictEqual(status.provider, "claudeAgent"); | 1402 | defaultClaudeSettings, |
| 1403 | claudeCapabilities(), | ||
| 1404 | ); | ||
| 817 | assert.strictEqual(status.status, "error"); | 1405 | assert.strictEqual(status.status, "error"); |
| 818 | assert.strictEqual(status.installed, false); | 1406 | assert.strictEqual(status.installed, false); |
| 819 | assert.strictEqual(status.auth.status, "unknown"); | 1407 | assert.strictEqual(status.auth.status, "unknown"); |
| ... | @@ -826,8 +1414,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -826,8 +1414,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 826 | 1414 | ||
| 827 | it.effect("returns error when version check fails with non-zero exit code", () => | 1415 | it.effect("returns error when version check fails with non-zero exit code", () => |
| 828 | Effect.gen(function* () { | 1416 | Effect.gen(function* () { |
| 829 | const status = yield* checkClaudeProviderStatus(); | 1417 | const status = yield* checkClaudeProviderStatus( |
| 830 | assert.strictEqual(status.provider, "claudeAgent"); | 1418 | defaultClaudeSettings, |
| 1419 | claudeCapabilities(), | ||
| 1420 | ); | ||
| 831 | assert.strictEqual(status.status, "error"); | 1421 | assert.strictEqual(status.status, "error"); |
| 832 | assert.strictEqual(status.installed, true); | 1422 | assert.strictEqual(status.installed, true); |
| 833 | }).pipe( | 1423 | }).pipe( |
| ... | @@ -835,33 +1425,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -835,33 +1425,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 835 | mockSpawnerLayer((args) => { | 1425 | mockSpawnerLayer((args) => { |
| 836 | const joined = args.join(" "); | 1426 | const joined = args.join(" "); |
| 837 | if (joined === "--version") | 1427 | if (joined === "--version") |
| 838 | return { stdout: "", stderr: "Something went wrong", code: 1 }; | ||
| 839 | throw new Error(`Unexpected args: ${joined}`); | ||
| 840 | }), | ||
| 841 | ), | ||
| 842 | ), | ||
| 843 | ); | ||
| 844 | |||
| 845 | it.effect("returns unauthenticated when auth status reports not logged in", () => | ||
| 846 | Effect.gen(function* () { | ||
| 847 | const status = yield* checkClaudeProviderStatus(); | ||
| 848 | assert.strictEqual(status.provider, "claudeAgent"); | ||
| 849 | assert.strictEqual(status.status, "error"); | ||
| 850 | assert.strictEqual(status.installed, true); | ||
| 851 | assert.strictEqual(status.auth.status, "unauthenticated"); | ||
| 852 | assert.strictEqual( | ||
| 853 | status.message, | ||
| 854 | "Claude is not authenticated. Run `claude auth login` and try again.", | ||
| 855 | ); | ||
| 856 | }).pipe( | ||
| 857 | Effect.provide( | ||
| 858 | mockSpawnerLayer((args) => { | ||
| 859 | const joined = args.join(" "); | ||
| 860 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 861 | if (joined === "auth status") | ||
| 862 | return { | 1428 | return { |
| 863 | stdout: '{"loggedIn":false}\n', | 1429 | stdout: "", |
| 864 | stderr: "", | 1430 | stderr: "Something went wrong", |
| 865 | code: 1, | 1431 | code: 1, |
| 866 | }; | 1432 | }; |
| 867 | throw new Error(`Unexpected args: ${joined}`); | 1433 | throw new Error(`Unexpected args: ${joined}`); |
| ... | @@ -870,36 +1436,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -870,36 +1436,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 870 | ), | 1436 | ), |
| 871 | ); | 1437 | ); |
| 872 | 1438 | ||
| 873 | it.effect("returns unauthenticated when output includes 'not logged in'", () => | 1439 | it.effect("returns warning when the Claude initialization result is unavailable", () => |
| 874 | Effect.gen(function* () { | ||
| 875 | const status = yield* checkClaudeProviderStatus(); | ||
| 876 | assert.strictEqual(status.provider, "claudeAgent"); | ||
| 877 | assert.strictEqual(status.status, "error"); | ||
| 878 | assert.strictEqual(status.installed, true); | ||
| 879 | assert.strictEqual(status.auth.status, "unauthenticated"); | ||
| 880 | }).pipe( | ||
| 881 | Effect.provide( | ||
| 882 | mockSpawnerLayer((args) => { | ||
| 883 | const joined = args.join(" "); | ||
| 884 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 885 | if (joined === "auth status") | ||
| 886 | return { stdout: "Not logged in\n", stderr: "", code: 1 }; | ||
| 887 | throw new Error(`Unexpected args: ${joined}`); | ||
| 888 | }), | ||
| 889 | ), | ||
| 890 | ), | ||
| 891 | ); | ||
| 892 | |||
| 893 | it.effect("returns warning when auth status command is unsupported", () => | ||
| 894 | Effect.gen(function* () { | 1440 | Effect.gen(function* () { |
| 895 | const status = yield* checkClaudeProviderStatus(); | 1441 | const status = yield* checkClaudeProviderStatus( |
| 896 | assert.strictEqual(status.provider, "claudeAgent"); | 1442 | defaultClaudeSettings, |
| 1443 | noClaudeCapabilities, | ||
| 1444 | ); | ||
| 897 | assert.strictEqual(status.status, "warning"); | 1445 | assert.strictEqual(status.status, "warning"); |
| 898 | assert.strictEqual(status.installed, true); | 1446 | assert.strictEqual(status.installed, true); |
| 899 | assert.strictEqual(status.auth.status, "unknown"); | 1447 | assert.strictEqual(status.auth.status, "unknown"); |
| 900 | assert.strictEqual( | 1448 | assert.strictEqual( |
| 901 | status.message, | 1449 | status.message, |
| 902 | "Claude Agent authentication status command is unavailable in this version of Claude.", | 1450 | "Could not verify Claude authentication status from initialization result.", |
| 903 | ); | 1451 | ); |
| 904 | }).pipe( | 1452 | }).pipe( |
| 905 | Effect.provide( | 1453 | Effect.provide( |
| ... | @@ -907,52 +1455,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -907,52 +1455,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 907 | const joined = args.join(" "); | 1455 | const joined = args.join(" "); |
| 908 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | 1456 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; |
| 909 | if (joined === "auth status") | 1457 | if (joined === "auth status") |
| 910 | return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; | 1458 | return { |
| 1459 | stdout: '{"loggedIn":false}\n', | ||
| 1460 | stderr: "", | ||
| 1461 | code: 1, | ||
| 1462 | }; | ||
| 911 | throw new Error(`Unexpected args: ${joined}`); | 1463 | throw new Error(`Unexpected args: ${joined}`); |
| 912 | }), | 1464 | }), |
| 913 | ), | 1465 | ), |
| 914 | ), | 1466 | ), |
| 915 | ); | 1467 | ); |
| 916 | }); | 1468 | }); |
| 917 | |||
| 918 | // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── | ||
| 919 | |||
| 920 | describe("parseClaudeAuthStatusFromOutput", () => { | ||
| 921 | it("exit code 0 with no auth markers is ready", () => { | ||
| 922 | const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); | ||
| 923 | assert.strictEqual(parsed.status, "ready"); | ||
| 924 | assert.strictEqual(parsed.auth.status, "authenticated"); | ||
| 925 | }); | ||
| 926 | |||
| 927 | it("JSON with loggedIn=true is authenticated", () => { | ||
| 928 | const parsed = parseClaudeAuthStatusFromOutput({ | ||
| 929 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 930 | stderr: "", | ||
| 931 | code: 0, | ||
| 932 | }); | ||
| 933 | assert.strictEqual(parsed.status, "ready"); | ||
| 934 | assert.strictEqual(parsed.auth.status, "authenticated"); | ||
| 935 | }); | ||
| 936 | |||
| 937 | it("JSON with loggedIn=false is unauthenticated", () => { | ||
| 938 | const parsed = parseClaudeAuthStatusFromOutput({ | ||
| 939 | stdout: '{"loggedIn":false}\n', | ||
| 940 | stderr: "", | ||
| 941 | code: 0, | ||
| 942 | }); | ||
| 943 | assert.strictEqual(parsed.status, "error"); | ||
| 944 | assert.strictEqual(parsed.auth.status, "unauthenticated"); | ||
| 945 | }); | ||
| 946 | |||
| 947 | it("JSON without auth marker is warning", () => { | ||
| 948 | const parsed = parseClaudeAuthStatusFromOutput({ | ||
| 949 | stdout: '{"ok":true}\n', | ||
| 950 | stderr: "", | ||
| 951 | code: 0, | ||
| 952 | }); | ||
| 953 | assert.strictEqual(parsed.status, "warning"); | ||
| 954 | assert.strictEqual(parsed.auth.status, "unknown"); | ||
| 955 | }); | ||
| 956 | }); | ||
| 957 | }, | 1469 | }, |
| 958 | ); | 1470 | ); |
apps/server/src/provider/Layers/ProviderRegistry.ts+422-124| ... | @@ -1,39 +1,63 @@ | ... | @@ -1,39 +1,63 @@ |
| 1 | /** | 1 | /** |
| 2 | * ProviderRegistryLive - Aggregates provider-specific snapshot services. | 2 | * ProviderRegistryLive — aggregates per-instance snapshot streams into a |
| 3 | * single materialized list. | ||
| 4 | * | ||
| 5 | * Historically this Layer composed four per-kind Live Layers | ||
| 6 | * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a | ||
| 7 | * `ServerProviderShape`. Those Lives were deleted during the driver / | ||
| 8 | * instance refactor — every driver now carries its `snapshot: ServerProviderShape` | ||
| 9 | * bundled onto the `ProviderInstance` the registry produces. | ||
| 10 | * | ||
| 11 | * Each configured instance (including multi-instance setups like | ||
| 12 | * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, | ||
| 13 | * keyed by `instanceId`. Instances whose driver is unavailable or whose | ||
| 14 | * config failed to decode are merged from `instanceRegistry.listUnavailable` | ||
| 15 | * as shadow snapshots so the UI can render their exact unavailable reason. | ||
| 16 | * | ||
| 17 | * Cache paths on disk are now keyed by `instanceId`. Because | ||
| 18 | * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing | ||
| 19 | * `<kind>.json` files remain the on-disk location for that driver's default | ||
| 20 | * instance. Identity-less legacy cache contents are ignored and replaced by | ||
| 21 | * the first live refresh. | ||
| 3 | * | 22 | * |
| 4 | * @module ProviderRegistryLive | 23 | * @module ProviderRegistryLive |
| 5 | */ | 24 | */ |
| 6 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | 25 | import { |
| 7 | import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; | 26 | defaultInstanceIdForDriver, |
| 27 | ProviderDriverKind, | ||
| 28 | type ProviderInstanceId, | ||
| 29 | type ServerProvider, | ||
| 30 | } from "@t3tools/contracts"; | ||
| 31 | import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; | ||
| 32 | import * as Semaphore from "effect/Semaphore"; | ||
| 8 | 33 | ||
| 9 | import { ServerConfig } from "../../config.ts"; | 34 | import { ServerConfig } from "../../config.ts"; |
| 10 | import { ClaudeProviderLive } from "./ClaudeProvider.ts"; | 35 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 11 | import { CodexProviderLive } from "./CodexProvider.ts"; | ||
| 12 | import { CursorProviderLive } from "./CursorProvider.ts"; | ||
| 13 | import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; | ||
| 14 | import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; | ||
| 15 | import { CodexProvider } from "../Services/CodexProvider.ts"; | ||
| 16 | import { CursorProvider } from "../Services/CursorProvider.ts"; | ||
| 17 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | ||
| 18 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; | 36 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; |
| 19 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | ||
| 20 | import { | 37 | import { |
| 21 | hydrateCachedProvider, | 38 | hydrateCachedProvider, |
| 22 | PROVIDER_CACHE_IDS, | 39 | isCachedProviderCorrelated, |
| 23 | orderProviderSnapshots, | 40 | orderProviderSnapshots, |
| 24 | readProviderStatusCache, | 41 | readProviderStatusCache, |
| 25 | resolveProviderStatusCachePath, | 42 | resolveProviderStatusCachePath, |
| 26 | writeProviderStatusCache, | 43 | writeProviderStatusCache, |
| 27 | } from "../providerStatusCache.ts"; | 44 | } from "../providerStatusCache.ts"; |
| 28 | import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts"; | 45 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 29 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; | 46 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; |
| 30 | 47 | ||
| 31 | const loadProviders = ( | 48 | const loadProviders = ( |
| 32 | providerSources: ReadonlyArray<ProviderSnapshotSource>, | 49 | providerSources: ReadonlyArray<ProviderSnapshotSource>, |
| 33 | ): Effect.Effect<ReadonlyArray<ServerProvider>> => | 50 | ): Effect.Effect<ReadonlyArray<ServerProvider>> => |
| 34 | Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, { | 51 | Effect.forEach( |
| 35 | concurrency: "unbounded", | 52 | providerSources, |
| 36 | }); | 53 | (providerSource) => |
| 54 | providerSource.getSnapshot.pipe( | ||
| 55 | Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), | ||
| 56 | ), | ||
| 57 | { | ||
| 58 | concurrency: "unbounded", | ||
| 59 | }, | ||
| 60 | ); | ||
| 37 | 61 | ||
| 38 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => | 62 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => |
| 39 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | 63 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; |
| ... | @@ -77,63 +101,123 @@ export const haveProvidersChanged = ( | ... | @@ -77,63 +101,123 @@ export const haveProvidersChanged = ( |
| 77 | nextProviders: ReadonlyArray<ServerProvider>, | 101 | nextProviders: ReadonlyArray<ServerProvider>, |
| 78 | ): boolean => !Equal.equals(previousProviders, nextProviders); | 102 | ): boolean => !Equal.equals(previousProviders, nextProviders); |
| 79 | 103 | ||
| 80 | const ProviderRegistryLiveBase = Layer.effect( | 104 | const correlateSnapshotWithSource = ( |
| 105 | source: ProviderSnapshotSource, | ||
| 106 | snapshot: ServerProvider, | ||
| 107 | ): Effect.Effect<ServerProvider> => { | ||
| 108 | if (snapshot.instanceId !== source.instanceId) { | ||
| 109 | return Effect.die( | ||
| 110 | new Error( | ||
| 111 | `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | if (snapshot.driver !== source.driverKind) { | ||
| 116 | return Effect.die( | ||
| 117 | new Error( | ||
| 118 | `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, | ||
| 119 | ), | ||
| 120 | ); | ||
| 121 | } | ||
| 122 | return Effect.succeed(snapshot); | ||
| 123 | }; | ||
| 124 | |||
| 125 | /** | ||
| 126 | * Key a snapshot for aggregation and persistence. Snapshot sources | ||
| 127 | * must be correlated by instance id before reaching this map; missing | ||
| 128 | * identities are defects, not runtime routing fallbacks. | ||
| 129 | */ | ||
| 130 | const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { | ||
| 131 | return provider.instanceId; | ||
| 132 | }; | ||
| 133 | |||
| 134 | // Project a live `ProviderInstance` into the aggregator's consumption | ||
| 135 | // shape. Each call re-captures the instance's `snapshot` closures, so | ||
| 136 | // after `ProviderInstanceRegistry` rebuilds an instance (e.g. because | ||
| 137 | // its settings changed), a fresh source rides the new PubSub instead | ||
| 138 | // of a closed one. | ||
| 139 | const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ | ||
| 140 | instanceId: instance.instanceId, | ||
| 141 | driverKind: instance.driverKind, | ||
| 142 | getSnapshot: instance.snapshot.getSnapshot, | ||
| 143 | refresh: instance.snapshot.refresh, | ||
| 144 | streamChanges: instance.snapshot.streamChanges, | ||
| 145 | }); | ||
| 146 | |||
| 147 | export const ProviderRegistryLive = Layer.effect( | ||
| 81 | ProviderRegistry, | 148 | ProviderRegistry, |
| 82 | Effect.gen(function* () { | 149 | Effect.gen(function* () { |
| 83 | const codexProvider = yield* CodexProvider; | 150 | const instanceRegistry = yield* ProviderInstanceRegistry; |
| 84 | const claudeProvider = yield* ClaudeProvider; | ||
| 85 | const openCodeProvider = yield* OpenCodeProvider; | ||
| 86 | const config = yield* ServerConfig; | 151 | const config = yield* ServerConfig; |
| 87 | const fileSystem = yield* FileSystem.FileSystem; | 152 | const fileSystem = yield* FileSystem.FileSystem; |
| 88 | const path = yield* Path.Path; | 153 | const path = yield* Path.Path; |
| 89 | 154 | ||
| 90 | const cursorProvider = yield* CursorProvider; | 155 | // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for |
| 91 | 156 | // coalesced updates across every instance. | |
| 92 | const providerSources = createBuiltInProviderSources({ | ||
| 93 | codex: codexProvider, | ||
| 94 | claudeAgent: claudeProvider, | ||
| 95 | opencode: openCodeProvider, | ||
| 96 | cursor: cursorProvider, | ||
| 97 | }) satisfies ReadonlyArray<ProviderSnapshotSource>; | ||
| 98 | const activeProviders = PROVIDER_CACHE_IDS; | ||
| 99 | const changesPubSub = yield* Effect.acquireRelease( | 157 | const changesPubSub = yield* Effect.acquireRelease( |
| 100 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), | 158 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), |
| 101 | PubSub.shutdown, | 159 | PubSub.shutdown, |
| 102 | ); | 160 | ); |
| 103 | const fallbackProviders = yield* loadProviders(providerSources); | 161 | |
| 104 | const cachePathByProvider = new Map( | 162 | // Boot-only: hydrate `providersRef` from the on-disk per-instance |
| 105 | activeProviders.map( | 163 | // cache so the UI has something to render during the first refresh. |
| 106 | (provider) => | 164 | // Instances added post-boot skip this path; their first entry in |
| 107 | [ | 165 | // `providersRef` comes from the reactive `syncLiveSources` pass |
| 108 | provider, | 166 | // below. |
| 109 | resolveProviderStatusCachePath({ | 167 | const bootInstances = yield* instanceRegistry.listInstances; |
| 110 | cacheDir: config.providerStatusCacheDir, | 168 | const bootSources = bootInstances.map(buildSnapshotSource); |
| 111 | provider, | 169 | const fallbackProviders = yield* loadProviders(bootSources); |
| 112 | }), | 170 | const fallbackByInstance = new Map<ProviderInstanceId, ServerProvider>(); |
| 113 | ] as const, | 171 | for (let index = 0; index < fallbackProviders.length; index++) { |
| 114 | ), | 172 | const provider = fallbackProviders[index]; |
| 115 | ); | 173 | const source = bootSources[index]; |
| 116 | const fallbackByProvider = new Map( | 174 | if (provider === undefined || source === undefined) { |
| 117 | fallbackProviders.map((provider) => [provider.provider, provider] as const), | 175 | continue; |
| 118 | ); | 176 | } |
| 177 | fallbackByInstance.set(source.instanceId, provider); | ||
| 178 | } | ||
| 119 | 179 | ||
| 120 | const cachedProviders = yield* Effect.forEach( | 180 | const cachedProviders = yield* Effect.forEach( |
| 121 | activeProviders, | 181 | bootSources, |
| 122 | (provider) => { | 182 | (source) => |
| 123 | const filePath = cachePathByProvider.get(provider)!; | 183 | Effect.gen(function* () { |
| 124 | const fallbackProvider = fallbackByProvider.get(provider)!; | 184 | // One cache file per configured instance. For the default |
| 125 | return readProviderStatusCache(filePath).pipe( | 185 | // instance of a built-in kind the path equals `<kind>.json` — |
| 126 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 186 | // identical to the legacy filename. We still require the cache |
| 127 | Effect.map((cachedProvider) => | 187 | // payload to carry matching instance id + driver kind; old |
| 128 | cachedProvider === undefined | 188 | // identity-less payloads are discarded and the awaited refresh |
| 129 | ? undefined | 189 | // below repopulates the cache. |
| 130 | : hydrateCachedProvider({ | 190 | const filePath = yield* resolveProviderStatusCachePath({ |
| 131 | cachedProvider, | 191 | cacheDir: config.providerStatusCacheDir, |
| 132 | fallbackProvider, | 192 | instanceId: source.instanceId, |
| 133 | }), | 193 | }).pipe(Effect.provideService(Path.Path, path)); |
| 134 | ), | 194 | const fallbackProvider = fallbackByInstance.get(source.instanceId); |
| 135 | ); | 195 | if (fallbackProvider === undefined) { |
| 136 | }, | 196 | return undefined; |
| 197 | } | ||
| 198 | return yield* readProviderStatusCache(filePath).pipe( | ||
| 199 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 200 | Effect.flatMap((cachedProvider) => { | ||
| 201 | if (cachedProvider === undefined) { | ||
| 202 | return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); | ||
| 203 | } | ||
| 204 | const correlation = { | ||
| 205 | cachedProvider, | ||
| 206 | fallbackProvider, | ||
| 207 | } as const; | ||
| 208 | if (!isCachedProviderCorrelated(correlation)) { | ||
| 209 | return Effect.logWarning("provider status cache identity mismatch, ignoring", { | ||
| 210 | path: filePath, | ||
| 211 | instanceId: source.instanceId, | ||
| 212 | cachedInstanceId: cachedProvider.instanceId ?? null, | ||
| 213 | driver: source.driverKind, | ||
| 214 | cachedDriver: cachedProvider.driver ?? null, | ||
| 215 | }).pipe(Effect.as(undefined as ServerProvider | undefined)); | ||
| 216 | } | ||
| 217 | return Effect.succeed(hydrateCachedProvider(correlation)); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | }), | ||
| 137 | { concurrency: "unbounded" }, | 221 | { concurrency: "unbounded" }, |
| 138 | ).pipe( | 222 | ).pipe( |
| 139 | Effect.map((providers) => | 223 | Effect.map((providers) => |
| ... | @@ -144,34 +228,64 @@ const ProviderRegistryLiveBase = Layer.effect( | ... | @@ -144,34 +228,64 @@ const ProviderRegistryLiveBase = Layer.effect( |
| 144 | ); | 228 | ); |
| 145 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); | 229 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); |
| 146 | 230 | ||
| 231 | // Live-source registry — the dynamic counterpart to the boot-time | ||
| 232 | // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` | ||
| 233 | // reference is used for identity equality so "no-op" reconciles | ||
| 234 | // (settings unchanged) skip re-subscribing + re-probing. | ||
| 235 | const liveSubsRef = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ProviderInstance>>( | ||
| 236 | new Map(), | ||
| 237 | ); | ||
| 238 | // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't | ||
| 239 | // interleave two passes clobbering each other's fiber bookkeeping. | ||
| 240 | const syncSemaphore = yield* Semaphore.make(1); | ||
| 241 | |||
| 242 | const getLiveSources: Effect.Effect<ReadonlyArray<ProviderSnapshotSource>> = Ref.get( | ||
| 243 | liveSubsRef, | ||
| 244 | ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); | ||
| 245 | |||
| 147 | const persistProvider = (provider: ServerProvider) => | 246 | const persistProvider = (provider: ServerProvider) => |
| 148 | writeProviderStatusCache({ | 247 | Effect.gen(function* () { |
| 149 | filePath: cachePathByProvider.get(provider.provider)!, | 248 | // Persist every instance — the file name is the instance id, so |
| 150 | provider, | 249 | // multi-instance setups (e.g. `codex_personal`, `codex_work`) each |
| 151 | }).pipe( | 250 | // get their own cache. We resolve the path fresh so snapshots |
| 152 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 251 | // produced by newly-added instances post-boot still land on disk |
| 153 | Effect.provideService(Path.Path, path), | 252 | // without the aggregator holding a stale `cachePathByInstance` |
| 154 | Effect.tapError(Effect.logError), | 253 | // entry. |
| 155 | Effect.ignore, | 254 | const key = snapshotInstanceKey(provider); |
| 156 | ); | 255 | const filePath = yield* resolveProviderStatusCachePath({ |
| 256 | cacheDir: config.providerStatusCacheDir, | ||
| 257 | instanceId: key, | ||
| 258 | }).pipe(Effect.provideService(Path.Path, path)); | ||
| 259 | yield* writeProviderStatusCache({ filePath, provider }).pipe( | ||
| 260 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 261 | Effect.provideService(Path.Path, path), | ||
| 262 | Effect.tapError(Effect.logError), | ||
| 263 | Effect.ignore, | ||
| 264 | ); | ||
| 265 | }); | ||
| 157 | 266 | ||
| 158 | const upsertProviders = Effect.fn("upsertProviders")(function* ( | 267 | const upsertProviders = Effect.fn("upsertProviders")(function* ( |
| 159 | nextProviders: ReadonlyArray<ServerProvider>, | 268 | nextProviders: ReadonlyArray<ServerProvider>, |
| 160 | options?: { | 269 | options?: { |
| 161 | readonly publish?: boolean; | 270 | readonly publish?: boolean; |
| 271 | readonly persist?: boolean; | ||
| 272 | readonly replace?: boolean; | ||
| 162 | }, | 273 | }, |
| 163 | ) { | 274 | ) { |
| 164 | const [previousProviders, providers] = yield* Ref.modify( | 275 | const [previousProviders, providers] = yield* Ref.modify( |
| 165 | providersRef, | 276 | providersRef, |
| 166 | (previousProviders) => { | 277 | (previousProviders) => { |
| 167 | const mergedProviders = new Map( | 278 | const mergedProviders = new Map( |
| 168 | previousProviders.map((provider) => [provider.provider, provider] as const), | 279 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), |
| 169 | ); | 280 | ); |
| 170 | 281 | ||
| 171 | for (const provider of nextProviders) { | 282 | for (const provider of nextProviders) { |
| 283 | const key = snapshotInstanceKey(provider); | ||
| 172 | mergedProviders.set( | 284 | mergedProviders.set( |
| 173 | provider.provider, | 285 | key, |
| 174 | mergeProviderSnapshot(mergedProviders.get(provider.provider), provider), | 286 | options?.replace === true |
| 287 | ? provider | ||
| 288 | : mergeProviderSnapshot(mergedProviders.get(key), provider), | ||
| 175 | ); | 289 | ); |
| 176 | } | 290 | } |
| 177 | 291 | ||
| ... | @@ -181,10 +295,12 @@ const ProviderRegistryLiveBase = Layer.effect( | ... | @@ -181,10 +295,12 @@ const ProviderRegistryLiveBase = Layer.effect( |
| 181 | ); | 295 | ); |
| 182 | 296 | ||
| 183 | if (haveProvidersChanged(previousProviders, providers)) { | 297 | if (haveProvidersChanged(previousProviders, providers)) { |
| 184 | yield* Effect.forEach(nextProviders, persistProvider, { | 298 | if (options?.persist !== false) { |
| 185 | concurrency: "unbounded", | 299 | yield* Effect.forEach(nextProviders, persistProvider, { |
| 186 | discard: true, | 300 | concurrency: "unbounded", |
| 187 | }); | 301 | discard: true, |
| 302 | }); | ||
| 303 | } | ||
| 188 | if (options?.publish !== false) { | 304 | if (options?.publish !== false) { |
| 189 | yield* PubSub.publish(changesPubSub, providers); | 305 | yield* PubSub.publish(changesPubSub, providers); |
| 190 | } | 306 | } |
| ... | @@ -202,64 +318,246 @@ const ProviderRegistryLiveBase = Layer.effect( | ... | @@ -202,64 +318,246 @@ const ProviderRegistryLiveBase = Layer.effect( |
| 202 | return yield* upsertProviders([provider], options); | 318 | return yield* upsertProviders([provider], options); |
| 203 | }); | 319 | }); |
| 204 | 320 | ||
| 205 | const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { | 321 | const refreshOneSource = Effect.fn("refreshOneSource")(function* ( |
| 206 | if (provider) { | 322 | providerSource: ProviderSnapshotSource, |
| 207 | const providerSource = providerSources.find((candidate) => candidate.provider === provider); | 323 | ) { |
| 208 | if (!providerSource) { | 324 | return yield* providerSource.refresh.pipe( |
| 209 | return yield* Ref.get(providersRef); | 325 | Effect.flatMap((nextProvider) => |
| 210 | } | 326 | correlateSnapshotWithSource(providerSource, nextProvider).pipe( |
| 211 | return yield* providerSource.refresh.pipe( | 327 | Effect.flatMap(syncProvider), |
| 212 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), | 328 | ), |
| 213 | ); | 329 | ), |
| 214 | } | 330 | ); |
| 215 | |||
| 216 | return yield* Effect.forEach( | ||
| 217 | providerSources, | ||
| 218 | (providerSource) => providerSource.refresh.pipe(Effect.flatMap(syncProvider)), | ||
| 219 | { | ||
| 220 | concurrency: "unbounded", | ||
| 221 | discard: true, | ||
| 222 | }, | ||
| 223 | ).pipe(Effect.andThen(Ref.get(providersRef))); | ||
| 224 | }); | 331 | }); |
| 225 | 332 | ||
| 226 | yield* Effect.forEach( | 333 | const refreshAll = Effect.fn("refreshAll")(function* () { |
| 227 | providerSources, | 334 | const sources = yield* getLiveSources; |
| 228 | (providerSource) => | 335 | return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { |
| 229 | Stream.runForEach(providerSource.streamChanges, (provider) => syncProvider(provider)).pipe( | ||
| 230 | Effect.forkScoped, | ||
| 231 | ), | ||
| 232 | { | ||
| 233 | concurrency: "unbounded", | 336 | concurrency: "unbounded", |
| 234 | discard: true, | 337 | discard: true, |
| 235 | }, | 338 | }).pipe(Effect.andThen(Ref.get(providersRef))); |
| 339 | }); | ||
| 340 | |||
| 341 | const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { | ||
| 342 | if (provider === undefined) { | ||
| 343 | return yield* refreshAll(); | ||
| 344 | } | ||
| 345 | // Kind-scoped refreshes target the default instance for that driver. | ||
| 346 | const defaultInstanceId = defaultInstanceIdForDriver(provider); | ||
| 347 | const sources = yield* getLiveSources; | ||
| 348 | const providerSource = sources.find( | ||
| 349 | (candidate) => candidate.instanceId === defaultInstanceId, | ||
| 350 | ); | ||
| 351 | if (!providerSource) { | ||
| 352 | return yield* Ref.get(providersRef); | ||
| 353 | } | ||
| 354 | return yield* refreshOneSource(providerSource); | ||
| 355 | }); | ||
| 356 | |||
| 357 | const refreshInstance = Effect.fn("refreshInstance")(function* ( | ||
| 358 | instanceId: ProviderInstanceId, | ||
| 359 | ) { | ||
| 360 | const sources = yield* getLiveSources; | ||
| 361 | const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); | ||
| 362 | if (!providerSource) { | ||
| 363 | return yield* Ref.get(providersRef); | ||
| 364 | } | ||
| 365 | return yield* refreshOneSource(providerSource); | ||
| 366 | }); | ||
| 367 | |||
| 368 | /** | ||
| 369 | * Diff the aggregator's live-source set against the current | ||
| 370 | * `ProviderInstanceRegistry` and: | ||
| 371 | * - subscribe to each newly-added or rebuilt instance's | ||
| 372 | * `streamChanges` (so periodic + enrichment refreshes land in | ||
| 373 | * `providersRef`); | ||
| 374 | * - force-refresh each newly-added/rebuilt instance and feed the | ||
| 375 | * result directly into `providersRef`, bypassing the PubSub | ||
| 376 | * attachment race that otherwise drops the initial probe; | ||
| 377 | * - prune `providersRef` of instances that no longer exist. | ||
| 378 | * | ||
| 379 | * Initial refreshes are awaited in parallel rather than forked, so | ||
| 380 | * callers (layer build; `streamChanges` watcher) see fully-probed | ||
| 381 | * state on return. This matters for layer build in particular: | ||
| 382 | * consumers reading `getProviders` immediately after layer build | ||
| 383 | * expect the probe to have already landed. | ||
| 384 | * | ||
| 385 | * Per-instance subscription fibers are not tracked explicitly. When | ||
| 386 | * a rebuilt instance's old child scope closes, its PubSub shuts | ||
| 387 | * down and our `Stream.runForEach` fiber exits naturally. | ||
| 388 | */ | ||
| 389 | const syncLiveSources = syncSemaphore.withPermits(1)( | ||
| 390 | Effect.gen(function* () { | ||
| 391 | const instances = yield* instanceRegistry.listInstances; | ||
| 392 | const unavailableProviders = yield* instanceRegistry.listUnavailable; | ||
| 393 | const nextByInstance = new Map<ProviderInstanceId, ProviderInstance>( | ||
| 394 | instances.map((instance) => [instance.instanceId, instance] as const), | ||
| 395 | ); | ||
| 396 | const knownInstanceIds = new Set<ProviderInstanceId>(nextByInstance.keys()); | ||
| 397 | for (const provider of unavailableProviders) { | ||
| 398 | knownInstanceIds.add(snapshotInstanceKey(provider)); | ||
| 399 | } | ||
| 400 | const previousSubs = yield* Ref.get(liveSubsRef); | ||
| 401 | |||
| 402 | // Carry over subscriptions for instances whose identity is | ||
| 403 | // unchanged (reconcile treated them as no-op). Instances that | ||
| 404 | // disappeared, or were rebuilt with a different reference, | ||
| 405 | // fall through to the "newly-added" branch below. | ||
| 406 | const carriedOver = new Map<ProviderInstanceId, ProviderInstance>(); | ||
| 407 | for (const [instanceId, previousInstance] of previousSubs) { | ||
| 408 | const nextInstance = nextByInstance.get(instanceId); | ||
| 409 | if (nextInstance !== undefined && nextInstance === previousInstance) { | ||
| 410 | carriedOver.set(instanceId, previousInstance); | ||
| 411 | } | ||
| 412 | } | ||
| 413 | |||
| 414 | // Collect new/rebuilt instances in `nextByInstance` insertion | ||
| 415 | // order (which preserves settings-author order). | ||
| 416 | const newlyAdded: Array<readonly [ProviderInstanceId, ProviderInstance]> = []; | ||
| 417 | for (const [instanceId, instance] of nextByInstance) { | ||
| 418 | if (carriedOver.has(instanceId)) { | ||
| 419 | continue; | ||
| 420 | } | ||
| 421 | newlyAdded.push([instanceId, instance] as const); | ||
| 422 | } | ||
| 423 | |||
| 424 | // Fork long-lived subscriptions to each new/rebuilt instance's | ||
| 425 | // change stream BEFORE kicking off refreshes — if the driver's | ||
| 426 | // own initial probe (line 140 in `makeManagedServerProvider`) | ||
| 427 | // wins the refreshSemaphore race, its PubSub publish must land | ||
| 428 | // in an active subscriber or the result is dropped. | ||
| 429 | for (const [, instance] of newlyAdded) { | ||
| 430 | const source = buildSnapshotSource(instance); | ||
| 431 | yield* Stream.runForEach(source.streamChanges, (provider) => | ||
| 432 | correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), | ||
| 433 | ).pipe(Effect.forkScoped); | ||
| 434 | } | ||
| 435 | |||
| 436 | // Force-refresh every new/rebuilt instance in parallel and wait | ||
| 437 | // for them all to complete. The refresh's result is piped | ||
| 438 | // directly into `syncProvider`, so `providersRef` is populated | ||
| 439 | // deterministically by the time this block returns — regardless | ||
| 440 | // of PubSub subscription timing. Failures are logged and | ||
| 441 | // swallowed so one bad driver can't wedge the whole registry. | ||
| 442 | yield* Effect.forEach( | ||
| 443 | newlyAdded, | ||
| 444 | ([, instance]) => | ||
| 445 | refreshOneSource(buildSnapshotSource(instance)).pipe(Effect.ignoreCause({ log: true })), | ||
| 446 | { concurrency: "unbounded", discard: true }, | ||
| 447 | ); | ||
| 448 | yield* upsertProviders(unavailableProviders, { | ||
| 449 | persist: false, | ||
| 450 | replace: true, | ||
| 451 | }); | ||
| 452 | |||
| 453 | const nextSubs = new Map(carriedOver); | ||
| 454 | for (const [instanceId, instance] of newlyAdded) { | ||
| 455 | nextSubs.set(instanceId, instance); | ||
| 456 | } | ||
| 457 | yield* Ref.set(liveSubsRef, nextSubs); | ||
| 458 | |||
| 459 | // Drop aggregator state for instances that have disappeared — | ||
| 460 | // otherwise the UI would keep rendering ghosts. | ||
| 461 | const [previousProviders, providers] = yield* Ref.modify( | ||
| 462 | providersRef, | ||
| 463 | (previousProviders) => { | ||
| 464 | const providers = orderProviderSnapshots( | ||
| 465 | previousProviders.filter((provider) => | ||
| 466 | knownInstanceIds.has(snapshotInstanceKey(provider)), | ||
| 467 | ), | ||
| 468 | ); | ||
| 469 | return [[previousProviders, providers] as const, providers]; | ||
| 470 | }, | ||
| 471 | ); | ||
| 472 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 473 | yield* PubSub.publish(changesPubSub, providers); | ||
| 474 | } | ||
| 475 | }), | ||
| 236 | ); | 476 | ); |
| 237 | yield* loadProviders(providerSources).pipe( | 477 | const syncLiveSourcesAndContinue = syncLiveSources.pipe( |
| 238 | Effect.flatMap((providers) => upsertProviders(providers, { publish: false })), | 478 | Effect.catchCause((cause) => { |
| 479 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 480 | return Effect.interrupt; | ||
| 481 | } | ||
| 482 | return Effect.logError( | ||
| 483 | "provider registry instance sync failed; keeping subscription alive", | ||
| 484 | { | ||
| 485 | cause: Cause.pretty(cause), | ||
| 486 | }, | ||
| 487 | ); | ||
| 488 | }), | ||
| 239 | ); | 489 | ); |
| 240 | 490 | ||
| 491 | // Seed `providersRef` with the boot-time fallback snapshots so | ||
| 492 | // consumers calling `getProviders` immediately after layer build see | ||
| 493 | // a populated list — even before the first `syncLiveSources` refresh | ||
| 494 | // resolves. Cached snapshots (already in `providersRef`) merge with | ||
| 495 | // these via `upsertProviders` so on-disk state wins where present | ||
| 496 | // and pending fallbacks fill the gaps. | ||
| 497 | yield* upsertProviders(fallbackProviders, { publish: false }); | ||
| 498 | // Subscribe to registry mutations BEFORE running the initial sync. | ||
| 499 | // `subscribeChanges` acquires the dequeue synchronously in this | ||
| 500 | // fibre; the subscription is active the instant this `yield*` | ||
| 501 | // returns. Forking the consumer loop later cannot lose a publish | ||
| 502 | // because no publish can reach a not-yet-subscribed dequeue. | ||
| 503 | // | ||
| 504 | // (Contrast with the pre-fix code that did | ||
| 505 | // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. | ||
| 506 | // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, | ||
| 507 | // and `forkScoped` only schedules the fibre — so a reconcile that | ||
| 508 | // published between "fibre scheduled" and "fibre starts running" | ||
| 509 | // was dropped, which made any settings change that replaced an | ||
| 510 | // instance never propagate to the aggregator's `providersRef`.) | ||
| 511 | // Subscribe to registry mutations BEFORE running the initial sync. | ||
| 512 | // `subscribeChanges` acquires the `PubSub.Subscription` synchronously | ||
| 513 | // in this fibre; the subscription is registered with the PubSub the | ||
| 514 | // instant this `yield*` returns, so any subsequent publish is | ||
| 515 | // buffered in the subscription regardless of when the consumer | ||
| 516 | // fibre below actually starts running. | ||
| 517 | // | ||
| 518 | // (Contrast with the pre-fix code that did | ||
| 519 | // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. | ||
| 520 | // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, | ||
| 521 | // which defers `PubSub.subscribe` to stream start. `forkScoped` only | ||
| 522 | // schedules the consumer fibre — so a reconcile that published | ||
| 523 | // between "fibre scheduled" and "fibre starts running + subscribes" | ||
| 524 | // was dropped, which made any settings change that replaced an | ||
| 525 | // instance never propagate to the aggregator's `providersRef`.) | ||
| 526 | const instanceChanges = yield* instanceRegistry.subscribeChanges; | ||
| 527 | // Initial sync: subscribe + kick off refreshes for every instance | ||
| 528 | // present at boot. Run synchronously so consumers pulling immediately | ||
| 529 | // after the layer build see the correct aggregator state. | ||
| 530 | yield* syncLiveSources; | ||
| 531 | // React to registry mutations — instance added / removed / rebuilt. | ||
| 532 | // `Stream.fromSubscription` builds a stream over the pre-acquired | ||
| 533 | // subscription rather than subscribing on stream start, which is | ||
| 534 | // what closes the race. | ||
| 535 | yield* Stream.runForEach( | ||
| 536 | Stream.fromSubscription(instanceChanges), | ||
| 537 | () => syncLiveSourcesAndContinue, | ||
| 538 | ).pipe(Effect.forkScoped); | ||
| 539 | |||
| 540 | const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( | ||
| 541 | cause: Cause.Cause<unknown>, | ||
| 542 | ) { | ||
| 543 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 544 | return yield* Effect.interrupt; | ||
| 545 | } | ||
| 546 | yield* Effect.logError("provider registry refresh failed; preserving cached providers", { | ||
| 547 | cause: Cause.pretty(cause), | ||
| 548 | }); | ||
| 549 | return yield* Ref.get(providersRef); | ||
| 550 | }); | ||
| 551 | |||
| 241 | return { | 552 | return { |
| 242 | getProviders: Ref.get(providersRef), | 553 | getProviders: Ref.get(providersRef), |
| 243 | refresh: (provider?: ProviderKind) => | 554 | refresh: (provider?: ProviderDriverKind) => |
| 244 | refresh(provider).pipe( | 555 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 245 | Effect.tapError(Effect.logError), | 556 | refreshInstance: (instanceId: ProviderInstanceId) => |
| 246 | Effect.orElseSucceed(() => [] as ReadonlyArray<ServerProvider>), | 557 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 247 | ), | ||
| 248 | get streamChanges() { | 558 | get streamChanges() { |
| 249 | return Stream.fromPubSub(changesPubSub); | 559 | return Stream.fromPubSub(changesPubSub); |
| 250 | }, | 560 | }, |
| 251 | } satisfies ProviderRegistryShape; | 561 | } satisfies ProviderRegistryShape; |
| 252 | }), | 562 | }), |
| 253 | ); | 563 | ); |
| 254 | |||
| 255 | export const ProviderRegistryLive = Layer.unwrap( | ||
| 256 | Effect.sync(() => | ||
| 257 | ProviderRegistryLiveBase.pipe( | ||
| 258 | Layer.provideMerge(CursorProviderLive), | ||
| 259 | Layer.provideMerge(CodexProviderLive), | ||
| 260 | Layer.provideMerge(ClaudeProviderLive), | ||
| 261 | Layer.provideMerge(OpenCodeProviderLive), | ||
| 262 | Layer.provideMerge(OpenCodeRuntimeLive), | ||
| 263 | ), | ||
| 264 | ), | ||
| 265 | ); |
apps/server/src/provider/Layers/ProviderService.test.ts+439-144| ... | @@ -12,7 +12,8 @@ import type { | ... | @@ -12,7 +12,8 @@ import type { |
| 12 | import { | 12 | import { |
| 13 | ApprovalRequestId, | 13 | ApprovalRequestId, |
| 14 | EventId, | 14 | EventId, |
| 15 | type ProviderKind, | 15 | ProviderDriverKind, |
| 16 | ProviderInstanceId, | ||
| 16 | ProviderSessionStartInput, | 17 | ProviderSessionStartInput, |
| 17 | ThreadId, | 18 | ThreadId, |
| 18 | TurnId, | 19 | TurnId, |
| ... | @@ -20,20 +21,25 @@ import { | ... | @@ -20,20 +21,25 @@ import { |
| 20 | import { createModelSelection } from "@t3tools/shared/model"; | 21 | import { createModelSelection } from "@t3tools/shared/model"; |
| 21 | import { it, assert, vi } from "@effect/vitest"; | 22 | import { it, assert, vi } from "@effect/vitest"; |
| 22 | 23 | ||
| 23 | import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; | 24 | import { Effect, Exit, Fiber, Layer, Metric, Option, PubSub, Ref, Scope, Stream } from "effect"; |
| 24 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 25 | 26 | ||
| 26 | import { | 27 | import { |
| 28 | ProviderAdapterRequestError, | ||
| 27 | ProviderAdapterSessionNotFoundError, | 29 | ProviderAdapterSessionNotFoundError, |
| 28 | ProviderUnsupportedError, | 30 | ProviderUnsupportedError, |
| 29 | ProviderValidationError, | 31 | ProviderValidationError, |
| 30 | type ProviderAdapterError, | 32 | type ProviderAdapterError, |
| 31 | } from "../Errors.ts"; | 33 | } from "../Errors.ts"; |
| 32 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | 34 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| 33 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 35 | import { |
| 36 | ProviderAdapterRegistry, | ||
| 37 | type ProviderAdapterRegistryShape, | ||
| 38 | } from "../Services/ProviderAdapterRegistry.ts"; | ||
| 34 | import { ProviderService } from "../Services/ProviderService.ts"; | 39 | import { ProviderService } from "../Services/ProviderService.ts"; |
| 35 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 40 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 36 | import { makeProviderServiceLive } from "./ProviderService.ts"; | 41 | import { makeProviderServiceLive } from "./ProviderService.ts"; |
| 42 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | ||
| 37 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | 43 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 38 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 44 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 39 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; | 45 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; |
| ... | @@ -44,6 +50,7 @@ import { | ... | @@ -44,6 +50,7 @@ import { |
| 44 | } from "../../persistence/Layers/Sqlite.ts"; | 50 | } from "../../persistence/Layers/Sqlite.ts"; |
| 45 | import { ServerSettingsService } from "../../serverSettings.ts"; | 51 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 46 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; | 52 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 53 | import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; | ||
| 47 | 54 | ||
| 48 | const defaultServerSettingsLayer = ServerSettingsService.layerTest(); | 55 | const defaultServerSettingsLayer = ServerSettingsService.layerTest(); |
| 49 | 56 | ||
| ... | @@ -51,11 +58,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make | ... | @@ -51,11 +58,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make |
| 51 | const asEventId = (value: string): EventId => EventId.make(value); | 58 | const asEventId = (value: string): EventId => EventId.make(value); |
| 52 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); | 59 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); |
| 53 | const asTurnId = (value: string): TurnId => TurnId.make(value); | 60 | const asTurnId = (value: string): TurnId => TurnId.make(value); |
| 61 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 62 | const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent"); | ||
| 63 | const CODEX_DRIVER = ProviderDriverKind.make("codex"); | ||
| 64 | const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); | ||
| 65 | const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); | ||
| 54 | 66 | ||
| 55 | type LegacyProviderRuntimeEvent = { | 67 | type LegacyProviderRuntimeEvent = { |
| 56 | readonly type: string; | 68 | readonly type: string; |
| 57 | readonly eventId: EventId; | 69 | readonly eventId: EventId; |
| 58 | readonly provider: ProviderKind; | 70 | readonly provider: ProviderDriverKind; |
| 59 | readonly createdAt: string; | 71 | readonly createdAt: string; |
| 60 | readonly threadId: ThreadId; | 72 | readonly threadId: ThreadId; |
| 61 | readonly turnId?: string | undefined; | 73 | readonly turnId?: string | undefined; |
| ... | @@ -65,7 +77,7 @@ type LegacyProviderRuntimeEvent = { | ... | @@ -65,7 +77,7 @@ type LegacyProviderRuntimeEvent = { |
| 65 | readonly [key: string]: unknown; | 77 | readonly [key: string]: unknown; |
| 66 | }; | 78 | }; |
| 67 | 79 | ||
| 68 | function makeFakeCodexAdapter(provider: ProviderKind = "codex") { | 80 | function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 69 | const sessions = new Map<ThreadId, ProviderSession>(); | 81 | const sessions = new Map<ThreadId, ProviderSession>(); |
| 70 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 82 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 71 | 83 | ||
| ... | @@ -74,10 +86,15 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { | ... | @@ -74,10 +86,15 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 74 | const now = new Date().toISOString(); | 86 | const now = new Date().toISOString(); |
| 75 | const session: ProviderSession = { | 87 | const session: ProviderSession = { |
| 76 | provider, | 88 | provider, |
| 89 | ...(input.providerInstanceId !== undefined | ||
| 90 | ? { providerInstanceId: input.providerInstanceId } | ||
| 91 | : {}), | ||
| 77 | status: "ready", | 92 | status: "ready", |
| 78 | runtimeMode: input.runtimeMode, | 93 | runtimeMode: input.runtimeMode, |
| 79 | threadId: input.threadId, | 94 | threadId: input.threadId, |
| 80 | resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` }, | 95 | resumeCursor: input.resumeCursor ?? { |
| 96 | opaque: `resume-${String(input.threadId)}`, | ||
| 97 | }, | ||
| 81 | cwd: input.cwd ?? process.cwd(), | 98 | cwd: input.cwd ?? process.cwd(), |
| 82 | createdAt: now, | 99 | createdAt: now, |
| 83 | updatedAt: now, | 100 | updatedAt: now, |
| ... | @@ -245,19 +262,13 @@ const hasMetricSnapshot = ( | ... | @@ -245,19 +262,13 @@ const hasMetricSnapshot = ( |
| 245 | 262 | ||
| 246 | function makeProviderServiceLayer() { | 263 | function makeProviderServiceLayer() { |
| 247 | const codex = makeFakeCodexAdapter(); | 264 | const codex = makeFakeCodexAdapter(); |
| 248 | const claude = makeFakeCodexAdapter("claudeAgent"); | 265 | const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); |
| 249 | const cursor = makeFakeCodexAdapter("cursor"); | 266 | const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); |
| 250 | const registry: typeof ProviderAdapterRegistry.Service = { | 267 | const registry = makeAdapterRegistryMock({ |
| 251 | getByProvider: (provider) => | 268 | [ProviderDriverKind.make("codex")]: codex.adapter, |
| 252 | provider === "codex" | 269 | [ProviderDriverKind.make("claudeAgent")]: claude.adapter, |
| 253 | ? Effect.succeed(codex.adapter) | 270 | [ProviderDriverKind.make("cursor")]: cursor.adapter, |
| 254 | : provider === "claudeAgent" | 271 | }); |
| 255 | ? Effect.succeed(claude.adapter) | ||
| 256 | : provider === "cursor" | ||
| 257 | ? Effect.succeed(cursor.adapter) | ||
| 258 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 259 | listProviders: () => Effect.succeed(["codex", "claudeAgent", "cursor"]), | ||
| 260 | }; | ||
| 261 | 272 | ||
| 262 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | 273 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); |
| 263 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | 274 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| ... | @@ -272,6 +283,7 @@ function makeProviderServiceLayer() { | ... | @@ -272,6 +283,7 @@ function makeProviderServiceLayer() { |
| 272 | Layer.provide(directoryLayer), | 283 | Layer.provide(directoryLayer), |
| 273 | Layer.provide(defaultServerSettingsLayer), | 284 | Layer.provide(defaultServerSettingsLayer), |
| 274 | Layer.provideMerge(AnalyticsService.layerTest), | 285 | Layer.provideMerge(AnalyticsService.layerTest), |
| 286 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 275 | ), | 287 | ), |
| 276 | directoryLayer, | 288 | directoryLayer, |
| 277 | 289 | ||
| ... | @@ -288,27 +300,76 @@ function makeProviderServiceLayer() { | ... | @@ -288,27 +300,76 @@ function makeProviderServiceLayer() { |
| 288 | }; | 300 | }; |
| 289 | } | 301 | } |
| 290 | 302 | ||
| 303 | it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => | ||
| 304 | Effect.gen(function* () { | ||
| 305 | const codex = makeFakeCodexAdapter(); | ||
| 306 | codex.stopAll.mockImplementation(() => | ||
| 307 | Effect.fail( | ||
| 308 | new ProviderAdapterRequestError({ | ||
| 309 | provider: String(CODEX_DRIVER), | ||
| 310 | method: "stopAll", | ||
| 311 | detail: "simulated stopAll failure", | ||
| 312 | }), | ||
| 313 | ), | ||
| 314 | ); | ||
| 315 | const registry = makeAdapterRegistryMock({ | ||
| 316 | [CODEX_DRIVER]: codex.adapter, | ||
| 317 | }); | ||
| 318 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | ||
| 319 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | ||
| 320 | Layer.provide(SqlitePersistenceMemory), | ||
| 321 | ); | ||
| 322 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | ||
| 323 | const providerLayer = Layer.mergeAll( | ||
| 324 | makeProviderServiceLive().pipe( | ||
| 325 | Layer.provide(providerAdapterLayer), | ||
| 326 | Layer.provide(directoryLayer), | ||
| 327 | Layer.provide(defaultServerSettingsLayer), | ||
| 328 | Layer.provideMerge(AnalyticsService.layerTest), | ||
| 329 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 330 | ), | ||
| 331 | directoryLayer, | ||
| 332 | runtimeRepositoryLayer, | ||
| 333 | NodeServices.layer, | ||
| 334 | ); | ||
| 335 | const scope = yield* Scope.make(); | ||
| 336 | const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); | ||
| 337 | |||
| 338 | yield* Effect.gen(function* () { | ||
| 339 | yield* ProviderService; | ||
| 340 | }).pipe(Effect.provide(runtimeServices)); | ||
| 341 | const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); | ||
| 342 | |||
| 343 | assert.equal(Exit.isSuccess(closeExit), true); | ||
| 344 | assert.equal(codex.stopAll.mock.calls.length, 1); | ||
| 345 | }), | ||
| 346 | ); | ||
| 347 | |||
| 291 | it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => | 348 | it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => |
| 292 | Effect.gen(function* () { | 349 | Effect.gen(function* () { |
| 293 | const codex = makeFakeCodexAdapter(); | 350 | const codex = makeFakeCodexAdapter(); |
| 294 | const claude = makeFakeCodexAdapter("claudeAgent"); | 351 | const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); |
| 295 | const registry: typeof ProviderAdapterRegistry.Service = { | 352 | const registryBase = makeAdapterRegistryMock({ |
| 296 | getByProvider: (provider) => | 353 | [CODEX_DRIVER]: codex.adapter, |
| 297 | provider === "codex" | 354 | [CLAUDE_AGENT_DRIVER]: claude.adapter, |
| 298 | ? Effect.succeed(codex.adapter) | 355 | }); |
| 299 | : provider === "claudeAgent" | 356 | const registry: ProviderAdapterRegistryShape = { |
| 300 | ? Effect.succeed(claude.adapter) | 357 | ...registryBase, |
| 301 | : Effect.fail(new ProviderUnsupportedError({ provider })), | 358 | getInstanceInfo: (instanceId) => |
| 302 | listProviders: () => Effect.succeed(["codex", "claudeAgent"]), | 359 | instanceId === claudeAgentInstanceId |
| 360 | ? Effect.succeed({ | ||
| 361 | instanceId, | ||
| 362 | driverKind: CLAUDE_AGENT_DRIVER, | ||
| 363 | displayName: undefined, | ||
| 364 | enabled: false, | ||
| 365 | continuationIdentity: { | ||
| 366 | driverKind: CLAUDE_AGENT_DRIVER, | ||
| 367 | continuationKey: "claudeAgent:instance:claudeAgent", | ||
| 368 | }, | ||
| 369 | }) | ||
| 370 | : registryBase.getInstanceInfo(instanceId), | ||
| 303 | }; | 371 | }; |
| 304 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | 372 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); |
| 305 | const serverSettingsLayer = ServerSettingsService.layerTest({ | ||
| 306 | providers: { | ||
| 307 | claudeAgent: { | ||
| 308 | enabled: false, | ||
| 309 | }, | ||
| 310 | }, | ||
| 311 | }); | ||
| 312 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | 373 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| 313 | Layer.provide(SqlitePersistenceMemory), | 374 | Layer.provide(SqlitePersistenceMemory), |
| 314 | ); | 375 | ); |
| ... | @@ -316,15 +377,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () | ... | @@ -316,15 +377,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () |
| 316 | const providerLayer = makeProviderServiceLive().pipe( | 377 | const providerLayer = makeProviderServiceLive().pipe( |
| 317 | Layer.provide(providerAdapterLayer), | 378 | Layer.provide(providerAdapterLayer), |
| 318 | Layer.provide(directoryLayer), | 379 | Layer.provide(directoryLayer), |
| 319 | Layer.provide(serverSettingsLayer), | 380 | Layer.provide(defaultServerSettingsLayer), |
| 320 | Layer.provide(AnalyticsService.layerTest), | 381 | Layer.provide(AnalyticsService.layerTest), |
| 382 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 321 | ); | 383 | ); |
| 322 | 384 | ||
| 323 | const failure = yield* Effect.flip( | 385 | const failure = yield* Effect.flip( |
| 324 | Effect.gen(function* () { | 386 | Effect.gen(function* () { |
| 325 | const provider = yield* ProviderService; | 387 | const provider = yield* ProviderService; |
| 326 | return yield* provider.startSession(asThreadId("thread-disabled"), { | 388 | return yield* provider.startSession(asThreadId("thread-disabled"), { |
| 327 | provider: "claudeAgent", | 389 | provider: ProviderDriverKind.make("claudeAgent"), |
| 390 | providerInstanceId: claudeAgentInstanceId, | ||
| 328 | threadId: asThreadId("thread-disabled"), | 391 | threadId: asThreadId("thread-disabled"), |
| 329 | runtimeMode: "full-access", | 392 | runtimeMode: "full-access", |
| 330 | }); | 393 | }); |
| ... | @@ -332,11 +395,149 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () | ... | @@ -332,11 +395,149 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () |
| 332 | ); | 395 | ); |
| 333 | 396 | ||
| 334 | assert.instanceOf(failure, ProviderValidationError); | 397 | assert.instanceOf(failure, ProviderValidationError); |
| 335 | assert.include(failure.issue, "Provider 'claudeAgent' is disabled in T3 Code settings."); | 398 | assert.include(failure.issue, "Provider instance 'claudeAgent' is disabled"); |
| 336 | assert.equal(claude.startSession.mock.calls.length, 0); | 399 | assert.equal(claude.startSession.mock.calls.length, 0); |
| 337 | }).pipe(Effect.provide(NodeServices.layer)), | 400 | }).pipe(Effect.provide(NodeServices.layer)), |
| 338 | ); | 401 | ); |
| 339 | 402 | ||
| 403 | it.effect( | ||
| 404 | "ProviderServiceLive allows enabled custom instances when legacy driver is disabled", | ||
| 405 | () => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | const instanceId = ProviderInstanceId.make("codex_personal"); | ||
| 408 | const driverKind = CODEX_DRIVER; | ||
| 409 | const codex = makeFakeCodexAdapter(); | ||
| 410 | const unsupported = () => | ||
| 411 | new ProviderUnsupportedError({ | ||
| 412 | provider: driverKind, | ||
| 413 | }); | ||
| 414 | const registry: ProviderAdapterRegistryShape = { | ||
| 415 | getByInstance: (requestedInstanceId) => | ||
| 416 | requestedInstanceId === instanceId | ||
| 417 | ? Effect.succeed(codex.adapter) | ||
| 418 | : Effect.fail(unsupported()), | ||
| 419 | getInstanceInfo: (requestedInstanceId) => | ||
| 420 | requestedInstanceId === instanceId | ||
| 421 | ? Effect.succeed({ | ||
| 422 | instanceId, | ||
| 423 | driverKind, | ||
| 424 | displayName: "Codex Personal", | ||
| 425 | enabled: true, | ||
| 426 | continuationIdentity: { | ||
| 427 | driverKind, | ||
| 428 | continuationKey: "codex:/Users/example/.codex", | ||
| 429 | }, | ||
| 430 | }) | ||
| 431 | : Effect.fail(unsupported()), | ||
| 432 | listInstances: () => Effect.succeed([instanceId]), | ||
| 433 | listProviders: () => Effect.succeed([driverKind] as const), | ||
| 434 | streamChanges: Stream.empty, | ||
| 435 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 436 | PubSub.subscribe(pubsub), | ||
| 437 | ), | ||
| 438 | }; | ||
| 439 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | ||
| 440 | const serverSettingsLayer = ServerSettingsService.layerTest({ | ||
| 441 | providers: { | ||
| 442 | codex: { | ||
| 443 | enabled: false, | ||
| 444 | }, | ||
| 445 | }, | ||
| 446 | }); | ||
| 447 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | ||
| 448 | Layer.provide(SqlitePersistenceMemory), | ||
| 449 | ); | ||
| 450 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | ||
| 451 | Layer.provide(runtimeRepositoryLayer), | ||
| 452 | ); | ||
| 453 | const providerLayer = makeProviderServiceLive().pipe( | ||
| 454 | Layer.provide(providerAdapterLayer), | ||
| 455 | Layer.provide(directoryLayer), | ||
| 456 | Layer.provide(serverSettingsLayer), | ||
| 457 | Layer.provide(AnalyticsService.layerTest), | ||
| 458 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 459 | ); | ||
| 460 | |||
| 461 | const session = yield* Effect.gen(function* () { | ||
| 462 | const provider = yield* ProviderService; | ||
| 463 | return yield* provider.startSession(asThreadId("thread-enabled-custom"), { | ||
| 464 | provider: driverKind, | ||
| 465 | providerInstanceId: instanceId, | ||
| 466 | threadId: asThreadId("thread-enabled-custom"), | ||
| 467 | runtimeMode: "full-access", | ||
| 468 | }); | ||
| 469 | }).pipe(Effect.provide(providerLayer)); | ||
| 470 | |||
| 471 | assert.equal(session.providerInstanceId, instanceId); | ||
| 472 | assert.equal(codex.startSession.mock.calls.length, 1); | ||
| 473 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 474 | ); | ||
| 475 | |||
| 476 | it.effect("ProviderServiceLive rejects new sessions for disabled custom instances", () => | ||
| 477 | Effect.gen(function* () { | ||
| 478 | const instanceId = ProviderInstanceId.make("codex_personal"); | ||
| 479 | const driverKind = ProviderDriverKind.make("codex"); | ||
| 480 | const codex = makeFakeCodexAdapter(); | ||
| 481 | const unsupported = () => | ||
| 482 | new ProviderUnsupportedError({ | ||
| 483 | provider: ProviderDriverKind.make("codex"), | ||
| 484 | }); | ||
| 485 | const registry: ProviderAdapterRegistryShape = { | ||
| 486 | getByInstance: (requestedInstanceId) => | ||
| 487 | requestedInstanceId === instanceId | ||
| 488 | ? Effect.succeed(codex.adapter) | ||
| 489 | : Effect.fail(unsupported()), | ||
| 490 | getInstanceInfo: (requestedInstanceId) => | ||
| 491 | requestedInstanceId === instanceId | ||
| 492 | ? Effect.succeed({ | ||
| 493 | instanceId, | ||
| 494 | driverKind, | ||
| 495 | displayName: "Codex Personal", | ||
| 496 | enabled: false, | ||
| 497 | continuationIdentity: { | ||
| 498 | driverKind, | ||
| 499 | continuationKey: "codex:/Users/example/.codex", | ||
| 500 | }, | ||
| 501 | }) | ||
| 502 | : Effect.fail(unsupported()), | ||
| 503 | listInstances: () => Effect.succeed([instanceId]), | ||
| 504 | listProviders: () => Effect.succeed([CODEX_DRIVER] as const), | ||
| 505 | streamChanges: Stream.empty, | ||
| 506 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 507 | PubSub.subscribe(pubsub), | ||
| 508 | ), | ||
| 509 | }; | ||
| 510 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | ||
| 511 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | ||
| 512 | Layer.provide(SqlitePersistenceMemory), | ||
| 513 | ); | ||
| 514 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | ||
| 515 | const providerLayer = makeProviderServiceLive().pipe( | ||
| 516 | Layer.provide(providerAdapterLayer), | ||
| 517 | Layer.provide(directoryLayer), | ||
| 518 | Layer.provide(defaultServerSettingsLayer), | ||
| 519 | Layer.provide(AnalyticsService.layerTest), | ||
| 520 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 521 | ); | ||
| 522 | |||
| 523 | const failure = yield* Effect.flip( | ||
| 524 | Effect.gen(function* () { | ||
| 525 | const provider = yield* ProviderService; | ||
| 526 | return yield* provider.startSession(asThreadId("thread-disabled-instance"), { | ||
| 527 | provider: ProviderDriverKind.make("codex"), | ||
| 528 | providerInstanceId: instanceId, | ||
| 529 | threadId: asThreadId("thread-disabled-instance"), | ||
| 530 | runtimeMode: "full-access", | ||
| 531 | }); | ||
| 532 | }).pipe(Effect.provide(providerLayer)), | ||
| 533 | ); | ||
| 534 | |||
| 535 | assert.instanceOf(failure, ProviderValidationError); | ||
| 536 | assert.include(failure.issue, "Provider instance 'codex_personal' is disabled"); | ||
| 537 | assert.equal(codex.startSession.mock.calls.length, 0); | ||
| 538 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 539 | ); | ||
| 540 | |||
| 340 | const routing = makeProviderServiceLayer(); | 541 | const routing = makeProviderServiceLayer(); |
| 341 | 542 | ||
| 342 | it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => | 543 | it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => |
| ... | @@ -344,13 +545,9 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se | ... | @@ -344,13 +545,9 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se |
| 344 | const codex = makeFakeCodexAdapter(); | 545 | const codex = makeFakeCodexAdapter(); |
| 345 | const canonicalEvents: ProviderRuntimeEvent[] = []; | 546 | const canonicalEvents: ProviderRuntimeEvent[] = []; |
| 346 | const canonicalThreadIds: Array<string | null> = []; | 547 | const canonicalThreadIds: Array<string | null> = []; |
| 347 | const registry: typeof ProviderAdapterRegistry.Service = { | 548 | const registry = makeAdapterRegistryMock({ |
| 348 | getByProvider: (provider) => | 549 | [ProviderDriverKind.make("codex")]: codex.adapter, |
| 349 | provider === "codex" | 550 | }); |
| 350 | ? Effect.succeed(codex.adapter) | ||
| 351 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 352 | listProviders: () => Effect.succeed(["codex"]), | ||
| 353 | }; | ||
| 354 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | 551 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| 355 | Layer.provide(SqlitePersistenceMemory), | 552 | Layer.provide(SqlitePersistenceMemory), |
| 356 | ); | 553 | ); |
| ... | @@ -370,6 +567,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se | ... | @@ -370,6 +567,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se |
| 370 | Layer.provide(directoryLayer), | 567 | Layer.provide(directoryLayer), |
| 371 | Layer.provide(defaultServerSettingsLayer), | 568 | Layer.provide(defaultServerSettingsLayer), |
| 372 | Layer.provide(AnalyticsService.layerTest), | 569 | Layer.provide(AnalyticsService.layerTest), |
| 570 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 373 | ); | 571 | ); |
| 374 | 572 | ||
| 375 | yield* Effect.gen(function* () { | 573 | yield* Effect.gen(function* () { |
| ... | @@ -377,7 +575,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se | ... | @@ -377,7 +575,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se |
| 377 | yield* sleep(10); | 575 | yield* sleep(10); |
| 378 | codex.emit({ | 576 | codex.emit({ |
| 379 | eventId: asEventId("evt-canonical-thread-segment"), | 577 | eventId: asEventId("evt-canonical-thread-segment"), |
| 380 | provider: "codex", | 578 | provider: ProviderDriverKind.make("codex"), |
| 381 | threadId: asThreadId("thread-canonical-thread-segment"), | 579 | threadId: asThreadId("thread-canonical-thread-segment"), |
| 382 | createdAt: new Date().toISOString(), | 580 | createdAt: new Date().toISOString(), |
| 383 | type: "turn.completed", | 581 | type: "turn.completed", |
| ... | @@ -400,13 +598,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -400,13 +598,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 400 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | 598 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| 401 | 599 | ||
| 402 | const codex = makeFakeCodexAdapter(); | 600 | const codex = makeFakeCodexAdapter(); |
| 403 | const registry: typeof ProviderAdapterRegistry.Service = { | 601 | const registry = makeAdapterRegistryMock({ |
| 404 | getByProvider: (provider) => | 602 | [ProviderDriverKind.make("codex")]: codex.adapter, |
| 405 | provider === "codex" | 603 | }); |
| 406 | ? Effect.succeed(codex.adapter) | ||
| 407 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 408 | listProviders: () => Effect.succeed(["codex"]), | ||
| 409 | }; | ||
| 410 | 604 | ||
| 411 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | 605 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 412 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | 606 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| ... | @@ -417,7 +611,8 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -417,7 +611,8 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 417 | yield* Effect.gen(function* () { | 611 | yield* Effect.gen(function* () { |
| 418 | const directory = yield* ProviderSessionDirectory; | 612 | const directory = yield* ProviderSessionDirectory; |
| 419 | yield* directory.upsert({ | 613 | yield* directory.upsert({ |
| 420 | provider: "codex", | 614 | provider: ProviderDriverKind.make("codex"), |
| 615 | providerInstanceId: codexInstanceId, | ||
| 421 | threadId: ThreadId.make("thread-stale"), | 616 | threadId: ThreadId.make("thread-stale"), |
| 422 | }); | 617 | }); |
| 423 | }).pipe(Effect.provide(directoryLayer)); | 618 | }).pipe(Effect.provide(directoryLayer)); |
| ... | @@ -427,6 +622,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -427,6 +622,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 427 | Layer.provide(directoryLayer), | 622 | Layer.provide(directoryLayer), |
| 428 | Layer.provide(defaultServerSettingsLayer), | 623 | Layer.provide(defaultServerSettingsLayer), |
| 429 | Layer.provide(AnalyticsService.layerTest), | 624 | Layer.provide(AnalyticsService.layerTest), |
| 625 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 430 | ); | 626 | ); |
| 431 | 627 | ||
| 432 | yield* Effect.gen(function* () { | 628 | yield* Effect.gen(function* () { |
| ... | @@ -441,7 +637,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -441,7 +637,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 441 | 637 | ||
| 442 | const runtime = yield* Effect.gen(function* () { | 638 | const runtime = yield* Effect.gen(function* () { |
| 443 | const repository = yield* ProviderSessionRuntimeRepository; | 639 | const repository = yield* ProviderSessionRuntimeRepository; |
| 444 | return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") }); | 640 | return yield* repository.getByThreadId({ |
| 641 | threadId: asThreadId("thread-stale"), | ||
| 642 | }); | ||
| 445 | }).pipe(Effect.provide(runtimeRepositoryLayer)); | 643 | }).pipe(Effect.provide(runtimeRepositoryLayer)); |
| 446 | assert.equal(Option.isSome(runtime), true); | 644 | assert.equal(Option.isSome(runtime), true); |
| 447 | 645 | ||
| ... | @@ -471,13 +669,9 @@ it.effect( | ... | @@ -471,13 +669,9 @@ it.effect( |
| 471 | ); | 669 | ); |
| 472 | 670 | ||
| 473 | const firstCodex = makeFakeCodexAdapter(); | 671 | const firstCodex = makeFakeCodexAdapter(); |
| 474 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { | 672 | const firstRegistry = makeAdapterRegistryMock({ |
| 475 | getByProvider: (provider) => | 673 | [ProviderDriverKind.make("codex")]: firstCodex.adapter, |
| 476 | provider === "codex" | 674 | }); |
| 477 | ? Effect.succeed(firstCodex.adapter) | ||
| 478 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 479 | listProviders: () => Effect.succeed(["codex"]), | ||
| 480 | }; | ||
| 481 | 675 | ||
| 482 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 676 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 483 | Layer.provide(runtimeRepositoryLayer), | 677 | Layer.provide(runtimeRepositoryLayer), |
| ... | @@ -487,6 +681,7 @@ it.effect( | ... | @@ -487,6 +681,7 @@ it.effect( |
| 487 | Layer.provide(firstDirectoryLayer), | 681 | Layer.provide(firstDirectoryLayer), |
| 488 | Layer.provide(defaultServerSettingsLayer), | 682 | Layer.provide(defaultServerSettingsLayer), |
| 489 | Layer.provide(AnalyticsService.layerTest), | 683 | Layer.provide(AnalyticsService.layerTest), |
| 684 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 490 | ); | 685 | ); |
| 491 | const updatedResumeCursor = { | 686 | const updatedResumeCursor = { |
| 492 | threadId: asThreadId("thread-1"), | 687 | threadId: asThreadId("thread-1"), |
| ... | @@ -499,7 +694,8 @@ it.effect( | ... | @@ -499,7 +694,8 @@ it.effect( |
| 499 | const provider = yield* ProviderService; | 694 | const provider = yield* ProviderService; |
| 500 | const threadId = asThreadId("thread-1"); | 695 | const threadId = asThreadId("thread-1"); |
| 501 | const session = yield* provider.startSession(threadId, { | 696 | const session = yield* provider.startSession(threadId, { |
| 502 | provider: "codex", | 697 | provider: ProviderDriverKind.make("codex"), |
| 698 | providerInstanceId: codexInstanceId, | ||
| 503 | cwd: "/tmp/project", | 699 | cwd: "/tmp/project", |
| 504 | runtimeMode: "full-access", | 700 | runtimeMode: "full-access", |
| 505 | threadId, | 701 | threadId, |
| ... | @@ -515,7 +711,9 @@ it.effect( | ... | @@ -515,7 +711,9 @@ it.effect( |
| 515 | 711 | ||
| 516 | const persistedAfterStopAll = yield* Effect.gen(function* () { | 712 | const persistedAfterStopAll = yield* Effect.gen(function* () { |
| 517 | const repository = yield* ProviderSessionRuntimeRepository; | 713 | const repository = yield* ProviderSessionRuntimeRepository; |
| 518 | return yield* repository.getByThreadId({ threadId: startedSession.threadId }); | 714 | return yield* repository.getByThreadId({ |
| 715 | threadId: startedSession.threadId, | ||
| 716 | }); | ||
| 519 | }).pipe(Effect.provide(runtimeRepositoryLayer)); | 717 | }).pipe(Effect.provide(runtimeRepositoryLayer)); |
| 520 | assert.equal(Option.isSome(persistedAfterStopAll), true); | 718 | assert.equal(Option.isSome(persistedAfterStopAll), true); |
| 521 | if (Option.isSome(persistedAfterStopAll)) { | 719 | if (Option.isSome(persistedAfterStopAll)) { |
| ... | @@ -524,13 +722,9 @@ it.effect( | ... | @@ -524,13 +722,9 @@ it.effect( |
| 524 | } | 722 | } |
| 525 | 723 | ||
| 526 | const secondCodex = makeFakeCodexAdapter(); | 724 | const secondCodex = makeFakeCodexAdapter(); |
| 527 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { | 725 | const secondRegistry = makeAdapterRegistryMock({ |
| 528 | getByProvider: (provider) => | 726 | [ProviderDriverKind.make("codex")]: secondCodex.adapter, |
| 529 | provider === "codex" | 727 | }); |
| 530 | ? Effect.succeed(secondCodex.adapter) | ||
| 531 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 532 | listProviders: () => Effect.succeed(["codex"]), | ||
| 533 | }; | ||
| 534 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 728 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 535 | Layer.provide(runtimeRepositoryLayer), | 729 | Layer.provide(runtimeRepositoryLayer), |
| 536 | ); | 730 | ); |
| ... | @@ -539,6 +733,7 @@ it.effect( | ... | @@ -539,6 +733,7 @@ it.effect( |
| 539 | Layer.provide(secondDirectoryLayer), | 733 | Layer.provide(secondDirectoryLayer), |
| 540 | Layer.provide(defaultServerSettingsLayer), | 734 | Layer.provide(defaultServerSettingsLayer), |
| 541 | Layer.provide(AnalyticsService.layerTest), | 735 | Layer.provide(AnalyticsService.layerTest), |
| 736 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 542 | ); | 737 | ); |
| 543 | 738 | ||
| 544 | secondCodex.startSession.mockClear(); | 739 | secondCodex.startSession.mockClear(); |
| ... | @@ -582,7 +777,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -582,7 +777,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 582 | const provider = yield* ProviderService; | 777 | const provider = yield* ProviderService; |
| 583 | 778 | ||
| 584 | const session = yield* provider.startSession(asThreadId("thread-1"), { | 779 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 585 | provider: "codex", | 780 | provider: ProviderDriverKind.make("codex"), |
| 781 | providerInstanceId: codexInstanceId, | ||
| 586 | threadId: asThreadId("thread-1"), | 782 | threadId: asThreadId("thread-1"), |
| 587 | cwd: "/tmp/project", | 783 | cwd: "/tmp/project", |
| 588 | runtimeMode: "full-access", | 784 | runtimeMode: "full-access", |
| ... | @@ -667,7 +863,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -667,7 +863,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 667 | const provider = yield* ProviderService; | 863 | const provider = yield* ProviderService; |
| 668 | 864 | ||
| 669 | const initial = yield* provider.startSession(asThreadId("thread-1"), { | 865 | const initial = yield* provider.startSession(asThreadId("thread-1"), { |
| 670 | provider: "codex", | 866 | provider: ProviderDriverKind.make("codex"), |
| 867 | providerInstanceId: codexInstanceId, | ||
| 671 | threadId: asThreadId("thread-1"), | 868 | threadId: asThreadId("thread-1"), |
| 672 | cwd: "/tmp/project", | 869 | cwd: "/tmp/project", |
| 673 | runtimeMode: "full-access", | 870 | runtimeMode: "full-access", |
| ... | @@ -708,7 +905,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -708,7 +905,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 708 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | 905 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; |
| 709 | 906 | ||
| 710 | const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { | 907 | const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { |
| 711 | provider: "codex", | 908 | provider: ProviderDriverKind.make("codex"), |
| 909 | providerInstanceId: codexInstanceId, | ||
| 712 | threadId: asThreadId("thread-reap-preserve"), | 910 | threadId: asThreadId("thread-reap-preserve"), |
| 713 | cwd: "/tmp/project-reap-preserve", | 911 | cwd: "/tmp/project-reap-preserve", |
| 714 | runtimeMode: "full-access", | 912 | runtimeMode: "full-access", |
| ... | @@ -758,7 +956,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -758,7 +956,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 758 | const provider = yield* ProviderService; | 956 | const provider = yield* ProviderService; |
| 759 | 957 | ||
| 760 | const session = yield* provider.startSession(asThreadId("thread-claude"), { | 958 | const session = yield* provider.startSession(asThreadId("thread-claude"), { |
| 761 | provider: "claudeAgent", | 959 | provider: ProviderDriverKind.make("claudeAgent"), |
| 960 | providerInstanceId: claudeAgentInstanceId, | ||
| 762 | threadId: asThreadId("thread-claude"), | 961 | threadId: asThreadId("thread-claude"), |
| 763 | cwd: "/tmp/project-claude", | 962 | cwd: "/tmp/project-claude", |
| 764 | runtimeMode: "full-access", | 963 | runtimeMode: "full-access", |
| ... | @@ -769,20 +968,57 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -769,20 +968,57 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 769 | const startInput = routing.claude.startSession.mock.calls[0]?.[0]; | 968 | const startInput = routing.claude.startSession.mock.calls[0]?.[0]; |
| 770 | assert.equal(typeof startInput === "object" && startInput !== null, true); | 969 | assert.equal(typeof startInput === "object" && startInput !== null, true); |
| 771 | if (startInput && typeof startInput === "object") { | 970 | if (startInput && typeof startInput === "object") { |
| 772 | const startPayload = startInput as { provider?: string; cwd?: string }; | 971 | const startPayload = startInput as { |
| 972 | provider?: string; | ||
| 973 | providerInstanceId?: ProviderInstanceId; | ||
| 974 | cwd?: string; | ||
| 975 | }; | ||
| 773 | assert.equal(startPayload.provider, "claudeAgent"); | 976 | assert.equal(startPayload.provider, "claudeAgent"); |
| 977 | assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId); | ||
| 774 | assert.equal(startPayload.cwd, "/tmp/project-claude"); | 978 | assert.equal(startPayload.cwd, "/tmp/project-claude"); |
| 775 | } | 979 | } |
| 776 | }), | 980 | }), |
| 777 | ); | 981 | ); |
| 778 | 982 | ||
| 983 | it.effect("dies when an active session conflicts with its persisted binding", () => | ||
| 984 | Effect.gen(function* () { | ||
| 985 | const provider = yield* ProviderService; | ||
| 986 | const directory = yield* ProviderSessionDirectory; | ||
| 987 | const threadId = asThreadId("thread-binding-mismatch"); | ||
| 988 | |||
| 989 | yield* provider.startSession(threadId, { | ||
| 990 | provider: ProviderDriverKind.make("codex"), | ||
| 991 | providerInstanceId: codexInstanceId, | ||
| 992 | threadId, | ||
| 993 | cwd: "/tmp/project-binding-mismatch", | ||
| 994 | runtimeMode: "full-access", | ||
| 995 | }); | ||
| 996 | yield* directory.upsert({ | ||
| 997 | threadId, | ||
| 998 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 999 | providerInstanceId: claudeAgentInstanceId, | ||
| 1000 | runtimeMode: "full-access", | ||
| 1001 | }); | ||
| 1002 | |||
| 1003 | const exit = yield* Effect.exit(provider.listSessions()); | ||
| 1004 | assert.equal(Exit.hasDies(exit), true); | ||
| 1005 | yield* directory.upsert({ | ||
| 1006 | threadId, | ||
| 1007 | provider: ProviderDriverKind.make("codex"), | ||
| 1008 | providerInstanceId: codexInstanceId, | ||
| 1009 | runtimeMode: "full-access", | ||
| 1010 | }); | ||
| 1011 | }), | ||
| 1012 | ); | ||
| 1013 | |||
| 779 | it.effect("stops stale sessions in other providers after a successful replacement start", () => | 1014 | it.effect("stops stale sessions in other providers after a successful replacement start", () => |
| 780 | Effect.gen(function* () { | 1015 | Effect.gen(function* () { |
| 781 | const provider = yield* ProviderService; | 1016 | const provider = yield* ProviderService; |
| 782 | const threadId = asThreadId("thread-provider-replacement"); | 1017 | const threadId = asThreadId("thread-provider-replacement"); |
| 783 | 1018 | ||
| 784 | const codexSession = yield* provider.startSession(threadId, { | 1019 | const codexSession = yield* provider.startSession(threadId, { |
| 785 | provider: "codex", | 1020 | provider: ProviderDriverKind.make("codex"), |
| 1021 | providerInstanceId: codexInstanceId, | ||
| 786 | threadId, | 1022 | threadId, |
| 787 | cwd: "/tmp/project-provider-replacement", | 1023 | cwd: "/tmp/project-provider-replacement", |
| 788 | runtimeMode: "full-access", | 1024 | runtimeMode: "full-access", |
| ... | @@ -792,7 +1028,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -792,7 +1028,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 792 | routing.claude.stopSession.mockClear(); | 1028 | routing.claude.stopSession.mockClear(); |
| 793 | 1029 | ||
| 794 | const claudeSession = yield* provider.startSession(threadId, { | 1030 | const claudeSession = yield* provider.startSession(threadId, { |
| 795 | provider: "claudeAgent", | 1031 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1032 | providerInstanceId: claudeAgentInstanceId, | ||
| 796 | threadId, | 1033 | threadId, |
| 797 | cwd: "/tmp/project-provider-replacement", | 1034 | cwd: "/tmp/project-provider-replacement", |
| 798 | runtimeMode: "full-access", | 1035 | runtimeMode: "full-access", |
| ... | @@ -818,7 +1055,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -818,7 +1055,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 818 | const provider = yield* ProviderService; | 1055 | const provider = yield* ProviderService; |
| 819 | 1056 | ||
| 820 | const initial = yield* provider.startSession(asThreadId("thread-1"), { | 1057 | const initial = yield* provider.startSession(asThreadId("thread-1"), { |
| 821 | provider: "codex", | 1058 | provider: ProviderDriverKind.make("codex"), |
| 1059 | providerInstanceId: codexInstanceId, | ||
| 822 | threadId: asThreadId("thread-1"), | 1060 | threadId: asThreadId("thread-1"), |
| 823 | cwd: "/tmp/project-send-turn", | 1061 | cwd: "/tmp/project-send-turn", |
| 824 | runtimeMode: "full-access", | 1062 | runtimeMode: "full-access", |
| ... | @@ -858,12 +1096,15 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -858,12 +1096,15 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 858 | const provider = yield* ProviderService; | 1096 | const provider = yield* ProviderService; |
| 859 | 1097 | ||
| 860 | const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { | 1098 | const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { |
| 861 | provider: "claudeAgent", | 1099 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1100 | providerInstanceId: claudeAgentInstanceId, | ||
| 862 | threadId: asThreadId("thread-claude-send-turn"), | 1101 | threadId: asThreadId("thread-claude-send-turn"), |
| 863 | cwd: "/tmp/project-claude-send-turn", | 1102 | cwd: "/tmp/project-claude-send-turn", |
| 864 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 1103 | modelSelection: createModelSelection( |
| 865 | { id: "effort", value: "max" }, | 1104 | ProviderInstanceId.make("claudeAgent"), |
| 866 | ]), | 1105 | "claude-opus-4-6", |
| 1106 | [{ id: "effort", value: "max" }], | ||
| 1107 | ), | ||
| 867 | runtimeMode: "full-access", | 1108 | runtimeMode: "full-access", |
| 868 | }); | 1109 | }); |
| 869 | 1110 | ||
| ... | @@ -892,7 +1133,9 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -892,7 +1133,9 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 892 | assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); | 1133 | assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); |
| 893 | assert.deepEqual( | 1134 | assert.deepEqual( |
| 894 | startPayload.modelSelection, | 1135 | startPayload.modelSelection, |
| 895 | createModelSelection("claudeAgent", "claude-opus-4-6", [{ id: "effort", value: "max" }]), | 1136 | createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ |
| 1137 | { id: "effort", value: "max" }, | ||
| 1138 | ]), | ||
| 896 | ); | 1139 | ); |
| 897 | assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); | 1140 | assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); |
| 898 | assert.equal(startPayload.threadId, initial.threadId); | 1141 | assert.equal(startPayload.threadId, initial.threadId); |
| ... | @@ -906,12 +1149,14 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -906,12 +1149,14 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 906 | const provider = yield* ProviderService; | 1149 | const provider = yield* ProviderService; |
| 907 | 1150 | ||
| 908 | yield* provider.startSession(asThreadId("thread-1"), { | 1151 | yield* provider.startSession(asThreadId("thread-1"), { |
| 909 | provider: "codex", | 1152 | provider: ProviderDriverKind.make("codex"), |
| 1153 | providerInstanceId: codexInstanceId, | ||
| 910 | threadId: asThreadId("thread-1"), | 1154 | threadId: asThreadId("thread-1"), |
| 911 | runtimeMode: "full-access", | 1155 | runtimeMode: "full-access", |
| 912 | }); | 1156 | }); |
| 913 | yield* provider.startSession(asThreadId("thread-2"), { | 1157 | yield* provider.startSession(asThreadId("thread-2"), { |
| 914 | provider: "codex", | 1158 | provider: ProviderDriverKind.make("codex"), |
| 1159 | providerInstanceId: codexInstanceId, | ||
| 915 | threadId: asThreadId("thread-2"), | 1160 | threadId: asThreadId("thread-2"), |
| 916 | runtimeMode: "full-access", | 1161 | runtimeMode: "full-access", |
| 917 | }); | 1162 | }); |
| ... | @@ -931,7 +1176,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -931,7 +1176,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 931 | 1176 | ||
| 932 | const threadId = asThreadId("thread-runtime-status"); | 1177 | const threadId = asThreadId("thread-runtime-status"); |
| 933 | const session = yield* provider.startSession(threadId, { | 1178 | const session = yield* provider.startSession(threadId, { |
| 934 | provider: "codex", | 1179 | provider: ProviderDriverKind.make("codex"), |
| 1180 | providerInstanceId: codexInstanceId, | ||
| 935 | threadId, | 1181 | threadId, |
| 936 | runtimeMode: "full-access", | 1182 | runtimeMode: "full-access", |
| 937 | }); | 1183 | }); |
| ... | @@ -977,14 +1223,10 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -977,14 +1223,10 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 977 | Layer.provide(persistenceLayer), | 1223 | Layer.provide(persistenceLayer), |
| 978 | ); | 1224 | ); |
| 979 | 1225 | ||
| 980 | const firstClaude = makeFakeCodexAdapter("claudeAgent"); | 1226 | const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); |
| 981 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { | 1227 | const firstRegistry = makeAdapterRegistryMock({ |
| 982 | getByProvider: (provider) => | 1228 | [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, |
| 983 | provider === "claudeAgent" | 1229 | }); |
| 984 | ? Effect.succeed(firstClaude.adapter) | ||
| 985 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 986 | listProviders: () => Effect.succeed(["claudeAgent"]), | ||
| 987 | }; | ||
| 988 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 1230 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 989 | Layer.provide(runtimeRepositoryLayer), | 1231 | Layer.provide(runtimeRepositoryLayer), |
| 990 | ); | 1232 | ); |
| ... | @@ -993,12 +1235,14 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -993,12 +1235,14 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 993 | Layer.provide(firstDirectoryLayer), | 1235 | Layer.provide(firstDirectoryLayer), |
| 994 | Layer.provide(defaultServerSettingsLayer), | 1236 | Layer.provide(defaultServerSettingsLayer), |
| 995 | Layer.provide(AnalyticsService.layerTest), | 1237 | Layer.provide(AnalyticsService.layerTest), |
| 1238 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 996 | ); | 1239 | ); |
| 997 | 1240 | ||
| 998 | const initial = yield* Effect.gen(function* () { | 1241 | const initial = yield* Effect.gen(function* () { |
| 999 | const provider = yield* ProviderService; | 1242 | const provider = yield* ProviderService; |
| 1000 | return yield* provider.startSession(asThreadId("thread-claude-start"), { | 1243 | return yield* provider.startSession(asThreadId("thread-claude-start"), { |
| 1001 | provider: "claudeAgent", | 1244 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1245 | providerInstanceId: claudeAgentInstanceId, | ||
| 1002 | threadId: asThreadId("thread-claude-start"), | 1246 | threadId: asThreadId("thread-claude-start"), |
| 1003 | cwd: "/tmp/project-claude-start", | 1247 | cwd: "/tmp/project-claude-start", |
| 1004 | runtimeMode: "full-access", | 1248 | runtimeMode: "full-access", |
| ... | @@ -1010,14 +1254,10 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1010,14 +1254,10 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1010 | yield* provider.listSessions(); | 1254 | yield* provider.listSessions(); |
| 1011 | }).pipe(Effect.provide(firstProviderLayer)); | 1255 | }).pipe(Effect.provide(firstProviderLayer)); |
| 1012 | 1256 | ||
| 1013 | const secondClaude = makeFakeCodexAdapter("claudeAgent"); | 1257 | const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); |
| 1014 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { | 1258 | const secondRegistry = makeAdapterRegistryMock({ |
| 1015 | getByProvider: (provider) => | 1259 | [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, |
| 1016 | provider === "claudeAgent" | 1260 | }); |
| 1017 | ? Effect.succeed(secondClaude.adapter) | ||
| 1018 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 1019 | listProviders: () => Effect.succeed(["claudeAgent"]), | ||
| 1020 | }; | ||
| 1021 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 1261 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 1022 | Layer.provide(runtimeRepositoryLayer), | 1262 | Layer.provide(runtimeRepositoryLayer), |
| 1023 | ); | 1263 | ); |
| ... | @@ -1026,6 +1266,7 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1026,6 +1266,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1026 | Layer.provide(secondDirectoryLayer), | 1266 | Layer.provide(secondDirectoryLayer), |
| 1027 | Layer.provide(defaultServerSettingsLayer), | 1267 | Layer.provide(defaultServerSettingsLayer), |
| 1028 | Layer.provide(AnalyticsService.layerTest), | 1268 | Layer.provide(AnalyticsService.layerTest), |
| 1269 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 1029 | ); | 1270 | ); |
| 1030 | 1271 | ||
| 1031 | secondClaude.startSession.mockClear(); | 1272 | secondClaude.startSession.mockClear(); |
| ... | @@ -1033,7 +1274,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1033,7 +1274,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1033 | yield* Effect.gen(function* () { | 1274 | yield* Effect.gen(function* () { |
| 1034 | const provider = yield* ProviderService; | 1275 | const provider = yield* ProviderService; |
| 1035 | yield* provider.startSession(initial.threadId, { | 1276 | yield* provider.startSession(initial.threadId, { |
| 1036 | provider: "claudeAgent", | 1277 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1278 | providerInstanceId: claudeAgentInstanceId, | ||
| 1037 | threadId: initial.threadId, | 1279 | threadId: initial.threadId, |
| 1038 | cwd: "/tmp/project-claude-start", | 1280 | cwd: "/tmp/project-claude-start", |
| 1039 | runtimeMode: "full-access", | 1281 | runtimeMode: "full-access", |
| ... | @@ -1071,14 +1313,10 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1071,14 +1313,10 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1071 | Layer.provide(persistenceLayer), | 1313 | Layer.provide(persistenceLayer), |
| 1072 | ); | 1314 | ); |
| 1073 | 1315 | ||
| 1074 | const firstClaude = makeFakeCodexAdapter("claudeAgent"); | 1316 | const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); |
| 1075 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { | 1317 | const firstRegistry = makeAdapterRegistryMock({ |
| 1076 | getByProvider: (provider) => | 1318 | [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, |
| 1077 | provider === "claudeAgent" | 1319 | }); |
| 1078 | ? Effect.succeed(firstClaude.adapter) | ||
| 1079 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 1080 | listProviders: () => Effect.succeed(["claudeAgent"]), | ||
| 1081 | }; | ||
| 1082 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 1320 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 1083 | Layer.provide(runtimeRepositoryLayer), | 1321 | Layer.provide(runtimeRepositoryLayer), |
| 1084 | ); | 1322 | ); |
| ... | @@ -1087,26 +1325,24 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1087,26 +1325,24 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1087 | Layer.provide(firstDirectoryLayer), | 1325 | Layer.provide(firstDirectoryLayer), |
| 1088 | Layer.provide(defaultServerSettingsLayer), | 1326 | Layer.provide(defaultServerSettingsLayer), |
| 1089 | Layer.provide(AnalyticsService.layerTest), | 1327 | Layer.provide(AnalyticsService.layerTest), |
| 1328 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 1090 | ); | 1329 | ); |
| 1091 | 1330 | ||
| 1092 | const initial = yield* Effect.gen(function* () { | 1331 | const initial = yield* Effect.gen(function* () { |
| 1093 | const provider = yield* ProviderService; | 1332 | const provider = yield* ProviderService; |
| 1094 | return yield* provider.startSession(asThreadId("thread-claude-cwd"), { | 1333 | return yield* provider.startSession(asThreadId("thread-claude-cwd"), { |
| 1095 | provider: "claudeAgent", | 1334 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1335 | providerInstanceId: claudeAgentInstanceId, | ||
| 1096 | threadId: asThreadId("thread-claude-cwd"), | 1336 | threadId: asThreadId("thread-claude-cwd"), |
| 1097 | cwd: "/tmp/project-claude-cwd", | 1337 | cwd: "/tmp/project-claude-cwd", |
| 1098 | runtimeMode: "full-access", | 1338 | runtimeMode: "full-access", |
| 1099 | }); | 1339 | }); |
| 1100 | }).pipe(Effect.provide(firstProviderLayer)); | 1340 | }).pipe(Effect.provide(firstProviderLayer)); |
| 1101 | 1341 | ||
| 1102 | const secondClaude = makeFakeCodexAdapter("claudeAgent"); | 1342 | const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); |
| 1103 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { | 1343 | const secondRegistry = makeAdapterRegistryMock({ |
| 1104 | getByProvider: (provider) => | 1344 | [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, |
| 1105 | provider === "claudeAgent" | 1345 | }); |
| 1106 | ? Effect.succeed(secondClaude.adapter) | ||
| 1107 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 1108 | listProviders: () => Effect.succeed(["claudeAgent"]), | ||
| 1109 | }; | ||
| 1110 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 1346 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 1111 | Layer.provide(runtimeRepositoryLayer), | 1347 | Layer.provide(runtimeRepositoryLayer), |
| 1112 | ); | 1348 | ); |
| ... | @@ -1115,6 +1351,7 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1115,6 +1351,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1115 | Layer.provide(secondDirectoryLayer), | 1351 | Layer.provide(secondDirectoryLayer), |
| 1116 | Layer.provide(defaultServerSettingsLayer), | 1352 | Layer.provide(defaultServerSettingsLayer), |
| 1117 | Layer.provide(AnalyticsService.layerTest), | 1353 | Layer.provide(AnalyticsService.layerTest), |
| 1354 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 1118 | ); | 1355 | ); |
| 1119 | 1356 | ||
| 1120 | secondClaude.startSession.mockClear(); | 1357 | secondClaude.startSession.mockClear(); |
| ... | @@ -1122,7 +1359,8 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -1122,7 +1359,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1122 | yield* Effect.gen(function* () { | 1359 | yield* Effect.gen(function* () { |
| 1123 | const provider = yield* ProviderService; | 1360 | const provider = yield* ProviderService; |
| 1124 | yield* provider.startSession(initial.threadId, { | 1361 | yield* provider.startSession(initial.threadId, { |
| 1125 | provider: "claudeAgent", | 1362 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1363 | providerInstanceId: claudeAgentInstanceId, | ||
| 1126 | threadId: initial.threadId, | 1364 | threadId: initial.threadId, |
| 1127 | runtimeMode: "full-access", | 1365 | runtimeMode: "full-access", |
| 1128 | }); | 1366 | }); |
| ... | @@ -1155,7 +1393,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1155,7 +1393,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1155 | Effect.gen(function* () { | 1393 | Effect.gen(function* () { |
| 1156 | const provider = yield* ProviderService; | 1394 | const provider = yield* ProviderService; |
| 1157 | const session = yield* provider.startSession(asThreadId("thread-1"), { | 1395 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 1158 | provider: "codex", | 1396 | provider: ProviderDriverKind.make("codex"), |
| 1397 | providerInstanceId: codexInstanceId, | ||
| 1159 | threadId: asThreadId("thread-1"), | 1398 | threadId: asThreadId("thread-1"), |
| 1160 | runtimeMode: "full-access", | 1399 | runtimeMode: "full-access", |
| 1161 | }); | 1400 | }); |
| ... | @@ -1169,7 +1408,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1169,7 +1408,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1169 | const completedEvent: LegacyProviderRuntimeEvent = { | 1408 | const completedEvent: LegacyProviderRuntimeEvent = { |
| 1170 | type: "turn.completed", | 1409 | type: "turn.completed", |
| 1171 | eventId: asEventId("evt-1"), | 1410 | eventId: asEventId("evt-1"), |
| 1172 | provider: "codex", | 1411 | provider: ProviderDriverKind.make("codex"), |
| 1173 | createdAt: new Date().toISOString(), | 1412 | createdAt: new Date().toISOString(), |
| 1174 | threadId: session.threadId, | 1413 | threadId: session.threadId, |
| 1175 | turnId: asTurnId("turn-1"), | 1414 | turnId: asTurnId("turn-1"), |
| ... | @@ -1186,6 +1425,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1186,6 +1425,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1186 | events.some((entry) => entry.type === "turn.completed"), | 1425 | events.some((entry) => entry.type === "turn.completed"), |
| 1187 | true, | 1426 | true, |
| 1188 | ); | 1427 | ); |
| 1428 | assert.equal( | ||
| 1429 | events.some( | ||
| 1430 | (entry) => | ||
| 1431 | entry.type === "turn.completed" && entry.providerInstanceId === codexInstanceId, | ||
| 1432 | ), | ||
| 1433 | true, | ||
| 1434 | ); | ||
| 1189 | }), | 1435 | }), |
| 1190 | ); | 1436 | ); |
| 1191 | 1437 | ||
| ... | @@ -1193,7 +1439,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1193,7 +1439,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1193 | Effect.gen(function* () { | 1439 | Effect.gen(function* () { |
| 1194 | const provider = yield* ProviderService; | 1440 | const provider = yield* ProviderService; |
| 1195 | const session = yield* provider.startSession(asThreadId("thread-seq"), { | 1441 | const session = yield* provider.startSession(asThreadId("thread-seq"), { |
| 1196 | provider: "codex", | 1442 | provider: ProviderDriverKind.make("codex"), |
| 1443 | providerInstanceId: codexInstanceId, | ||
| 1197 | threadId: asThreadId("thread-seq"), | 1444 | threadId: asThreadId("thread-seq"), |
| 1198 | runtimeMode: "full-access", | 1445 | runtimeMode: "full-access", |
| 1199 | }); | 1446 | }); |
| ... | @@ -1208,7 +1455,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1208,7 +1455,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1208 | fanout.codex.emit({ | 1455 | fanout.codex.emit({ |
| 1209 | type: "tool.started", | 1456 | type: "tool.started", |
| 1210 | eventId: asEventId("evt-seq-1"), | 1457 | eventId: asEventId("evt-seq-1"), |
| 1211 | provider: "codex", | 1458 | provider: ProviderDriverKind.make("codex"), |
| 1212 | createdAt: new Date().toISOString(), | 1459 | createdAt: new Date().toISOString(), |
| 1213 | threadId: session.threadId, | 1460 | threadId: session.threadId, |
| 1214 | turnId: asTurnId("turn-1"), | 1461 | turnId: asTurnId("turn-1"), |
| ... | @@ -1218,7 +1465,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1218,7 +1465,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1218 | fanout.codex.emit({ | 1465 | fanout.codex.emit({ |
| 1219 | type: "tool.completed", | 1466 | type: "tool.completed", |
| 1220 | eventId: asEventId("evt-seq-2"), | 1467 | eventId: asEventId("evt-seq-2"), |
| 1221 | provider: "codex", | 1468 | provider: ProviderDriverKind.make("codex"), |
| 1222 | createdAt: new Date().toISOString(), | 1469 | createdAt: new Date().toISOString(), |
| 1223 | threadId: session.threadId, | 1470 | threadId: session.threadId, |
| 1224 | turnId: asTurnId("turn-1"), | 1471 | turnId: asTurnId("turn-1"), |
| ... | @@ -1228,7 +1475,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1228,7 +1475,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1228 | fanout.codex.emit({ | 1475 | fanout.codex.emit({ |
| 1229 | type: "turn.completed", | 1476 | type: "turn.completed", |
| 1230 | eventId: asEventId("evt-seq-3"), | 1477 | eventId: asEventId("evt-seq-3"), |
| 1231 | provider: "codex", | 1478 | provider: ProviderDriverKind.make("codex"), |
| 1232 | createdAt: new Date().toISOString(), | 1479 | createdAt: new Date().toISOString(), |
| 1233 | threadId: session.threadId, | 1480 | threadId: session.threadId, |
| 1234 | turnId: asTurnId("turn-1"), | 1481 | turnId: asTurnId("turn-1"), |
| ... | @@ -1248,7 +1495,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1248,7 +1495,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1248 | Effect.gen(function* () { | 1495 | Effect.gen(function* () { |
| 1249 | const provider = yield* ProviderService; | 1496 | const provider = yield* ProviderService; |
| 1250 | const session = yield* provider.startSession(asThreadId("thread-1"), { | 1497 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 1251 | provider: "codex", | 1498 | provider: ProviderDriverKind.make("codex"), |
| 1499 | providerInstanceId: codexInstanceId, | ||
| 1252 | threadId: asThreadId("thread-1"), | 1500 | threadId: asThreadId("thread-1"), |
| 1253 | runtimeMode: "full-access", | 1501 | runtimeMode: "full-access", |
| 1254 | }); | 1502 | }); |
| ... | @@ -1273,7 +1521,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1273,7 +1521,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1273 | { | 1521 | { |
| 1274 | type: "tool.completed", | 1522 | type: "tool.completed", |
| 1275 | eventId: asEventId("evt-ordered-1"), | 1523 | eventId: asEventId("evt-ordered-1"), |
| 1276 | provider: "codex", | 1524 | provider: ProviderDriverKind.make("codex"), |
| 1277 | createdAt: new Date().toISOString(), | 1525 | createdAt: new Date().toISOString(), |
| 1278 | threadId: session.threadId, | 1526 | threadId: session.threadId, |
| 1279 | turnId: asTurnId("turn-1"), | 1527 | turnId: asTurnId("turn-1"), |
| ... | @@ -1284,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1284,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1284 | { | 1532 | { |
| 1285 | type: "message.delta", | 1533 | type: "message.delta", |
| 1286 | eventId: asEventId("evt-ordered-2"), | 1534 | eventId: asEventId("evt-ordered-2"), |
| 1287 | provider: "codex", | 1535 | provider: ProviderDriverKind.make("codex"), |
| 1288 | createdAt: new Date().toISOString(), | 1536 | createdAt: new Date().toISOString(), |
| 1289 | threadId: session.threadId, | 1537 | threadId: session.threadId, |
| 1290 | turnId: asTurnId("turn-1"), | 1538 | turnId: asTurnId("turn-1"), |
| ... | @@ -1293,7 +1541,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1293,7 +1541,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1293 | { | 1541 | { |
| 1294 | type: "turn.completed", | 1542 | type: "turn.completed", |
| 1295 | eventId: asEventId("evt-ordered-3"), | 1543 | eventId: asEventId("evt-ordered-3"), |
| 1296 | provider: "codex", | 1544 | provider: ProviderDriverKind.make("codex"), |
| 1297 | createdAt: new Date().toISOString(), | 1545 | createdAt: new Date().toISOString(), |
| 1298 | threadId: session.threadId, | 1546 | threadId: session.threadId, |
| 1299 | turnId: asTurnId("turn-1"), | 1547 | turnId: asTurnId("turn-1"), |
| ... | @@ -1320,7 +1568,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1320,7 +1568,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1320 | const provider = yield* ProviderService; | 1568 | const provider = yield* ProviderService; |
| 1321 | 1569 | ||
| 1322 | const session = yield* provider.startSession(asThreadId("thread-metrics"), { | 1570 | const session = yield* provider.startSession(asThreadId("thread-metrics"), { |
| 1323 | provider: "claudeAgent", | 1571 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1572 | providerInstanceId: claudeAgentInstanceId, | ||
| 1324 | threadId: asThreadId("thread-metrics"), | 1573 | threadId: asThreadId("thread-metrics"), |
| 1325 | cwd: "/tmp/project", | 1574 | cwd: "/tmp/project", |
| 1326 | runtimeMode: "full-access", | 1575 | runtimeMode: "full-access", |
| ... | @@ -1349,7 +1598,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1349,7 +1598,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1349 | 1598 | ||
| 1350 | assert.equal( | 1599 | assert.equal( |
| 1351 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { | 1600 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { |
| 1352 | provider: "claudeAgent", | 1601 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1353 | operation: "interrupt", | 1602 | operation: "interrupt", |
| 1354 | outcome: "success", | 1603 | outcome: "success", |
| 1355 | }), | 1604 | }), |
| ... | @@ -1357,7 +1606,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1357,7 +1606,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1357 | ); | 1606 | ); |
| 1358 | assert.equal( | 1607 | assert.equal( |
| 1359 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { | 1608 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { |
| 1360 | provider: "claudeAgent", | 1609 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1361 | operation: "approval-response", | 1610 | operation: "approval-response", |
| 1362 | outcome: "success", | 1611 | outcome: "success", |
| 1363 | }), | 1612 | }), |
| ... | @@ -1365,7 +1614,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1365,7 +1614,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1365 | ); | 1614 | ); |
| 1366 | assert.equal( | 1615 | assert.equal( |
| 1367 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { | 1616 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { |
| 1368 | provider: "claudeAgent", | 1617 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1369 | operation: "user-input-response", | 1618 | operation: "user-input-response", |
| 1370 | outcome: "success", | 1619 | outcome: "success", |
| 1371 | }), | 1620 | }), |
| ... | @@ -1373,7 +1622,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1373,7 +1622,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1373 | ); | 1622 | ); |
| 1374 | assert.equal( | 1623 | assert.equal( |
| 1375 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { | 1624 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { |
| 1376 | provider: "claudeAgent", | 1625 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1377 | operation: "rollback", | 1626 | operation: "rollback", |
| 1378 | outcome: "success", | 1627 | outcome: "success", |
| 1379 | }), | 1628 | }), |
| ... | @@ -1381,7 +1630,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1381,7 +1630,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1381 | ); | 1630 | ); |
| 1382 | assert.equal( | 1631 | assert.equal( |
| 1383 | hasMetricSnapshot(snapshots, "t3_provider_sessions_total", { | 1632 | hasMetricSnapshot(snapshots, "t3_provider_sessions_total", { |
| 1384 | provider: "claudeAgent", | 1633 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1385 | operation: "stop", | 1634 | operation: "stop", |
| 1386 | outcome: "success", | 1635 | outcome: "success", |
| 1387 | }), | 1636 | }), |
| ... | @@ -1397,7 +1646,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1397,7 +1646,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1397 | const provider = yield* ProviderService; | 1646 | const provider = yield* ProviderService; |
| 1398 | 1647 | ||
| 1399 | const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { | 1648 | const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { |
| 1400 | provider: "claudeAgent", | 1649 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1650 | providerInstanceId: claudeAgentInstanceId, | ||
| 1401 | threadId: asThreadId("thread-send-metrics"), | 1651 | threadId: asThreadId("thread-send-metrics"), |
| 1402 | cwd: "/tmp/project-send-metrics", | 1652 | cwd: "/tmp/project-send-metrics", |
| 1403 | runtimeMode: "full-access", | 1653 | runtimeMode: "full-access", |
| ... | @@ -1413,7 +1663,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1413,7 +1663,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1413 | 1663 | ||
| 1414 | assert.equal( | 1664 | assert.equal( |
| 1415 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { | 1665 | hasMetricSnapshot(snapshots, "t3_provider_turns_total", { |
| 1416 | provider: "claudeAgent", | 1666 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1417 | operation: "send", | 1667 | operation: "send", |
| 1418 | outcome: "success", | 1668 | outcome: "success", |
| 1419 | }), | 1669 | }), |
| ... | @@ -1421,7 +1671,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1421,7 +1671,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1421 | ); | 1671 | ); |
| 1422 | assert.equal( | 1672 | assert.equal( |
| 1423 | hasMetricSnapshot(snapshots, "t3_provider_turn_duration", { | 1673 | hasMetricSnapshot(snapshots, "t3_provider_turn_duration", { |
| 1424 | provider: "claudeAgent", | 1674 | provider: ProviderDriverKind.make("claudeAgent"), |
| 1425 | operation: "send", | 1675 | operation: "send", |
| 1426 | }), | 1676 | }), |
| 1427 | true, | 1677 | true, |
| ... | @@ -1432,6 +1682,50 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -1432,6 +1682,50 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 1432 | 1682 | ||
| 1433 | const validation = makeProviderServiceLayer(); | 1683 | const validation = makeProviderServiceLayer(); |
| 1434 | validation.layer("ProviderServiceLive validation", (it) => { | 1684 | validation.layer("ProviderServiceLive validation", (it) => { |
| 1685 | it.effect("rejects session starts without an explicit provider instance id", () => | ||
| 1686 | Effect.gen(function* () { | ||
| 1687 | const provider = yield* ProviderService; | ||
| 1688 | |||
| 1689 | validation.codex.startSession.mockClear(); | ||
| 1690 | const failure = yield* Effect.flip( | ||
| 1691 | provider.startSession(asThreadId("thread-missing-instance-id"), { | ||
| 1692 | provider: ProviderDriverKind.make("codex"), | ||
| 1693 | threadId: asThreadId("thread-missing-instance-id"), | ||
| 1694 | runtimeMode: "full-access", | ||
| 1695 | }), | ||
| 1696 | ); | ||
| 1697 | |||
| 1698 | assert.instanceOf(failure, ProviderValidationError); | ||
| 1699 | assert.include(failure.issue, "Provider instance id is required for provider 'codex'."); | ||
| 1700 | assert.equal(validation.codex.startSession.mock.calls.length, 0); | ||
| 1701 | }), | ||
| 1702 | ); | ||
| 1703 | |||
| 1704 | it.effect("rejects mismatched provider kind and provider instance id", () => | ||
| 1705 | Effect.gen(function* () { | ||
| 1706 | const provider = yield* ProviderService; | ||
| 1707 | |||
| 1708 | validation.codex.startSession.mockClear(); | ||
| 1709 | validation.claude.startSession.mockClear(); | ||
| 1710 | const failure = yield* Effect.flip( | ||
| 1711 | provider.startSession(asThreadId("thread-instance-mismatch"), { | ||
| 1712 | provider: ProviderDriverKind.make("codex"), | ||
| 1713 | providerInstanceId: claudeAgentInstanceId, | ||
| 1714 | threadId: asThreadId("thread-instance-mismatch"), | ||
| 1715 | runtimeMode: "full-access", | ||
| 1716 | }), | ||
| 1717 | ); | ||
| 1718 | |||
| 1719 | assert.instanceOf(failure, ProviderValidationError); | ||
| 1720 | assert.include( | ||
| 1721 | failure.issue, | ||
| 1722 | "Provider instance 'claudeAgent' belongs to driver 'claudeAgent', not 'codex'.", | ||
| 1723 | ); | ||
| 1724 | assert.equal(validation.codex.startSession.mock.calls.length, 0); | ||
| 1725 | assert.equal(validation.claude.startSession.mock.calls.length, 0); | ||
| 1726 | }), | ||
| 1727 | ); | ||
| 1728 | |||
| 1435 | it.effect("returns ProviderValidationError for invalid input payloads", () => | 1729 | it.effect("returns ProviderValidationError for invalid input payloads", () => |
| 1436 | Effect.gen(function* () { | 1730 | Effect.gen(function* () { |
| 1437 | const provider = yield* ProviderService; | 1731 | const provider = yield* ProviderService; |
| ... | @@ -1466,7 +1760,7 @@ validation.layer("ProviderServiceLive validation", (it) => { | ... | @@ -1466,7 +1760,7 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 1466 | Effect.sync(() => { | 1760 | Effect.sync(() => { |
| 1467 | const now = new Date().toISOString(); | 1761 | const now = new Date().toISOString(); |
| 1468 | return { | 1762 | return { |
| 1469 | provider: "codex", | 1763 | provider: ProviderDriverKind.make("codex"), |
| 1470 | status: "ready", | 1764 | status: "ready", |
| 1471 | threadId: input.threadId, | 1765 | threadId: input.threadId, |
| 1472 | runtimeMode: input.runtimeMode, | 1766 | runtimeMode: input.runtimeMode, |
| ... | @@ -1478,7 +1772,8 @@ validation.layer("ProviderServiceLive validation", (it) => { | ... | @@ -1478,7 +1772,8 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 1478 | ); | 1772 | ); |
| 1479 | 1773 | ||
| 1480 | const session = yield* provider.startSession(asThreadId("thread-missing"), { | 1774 | const session = yield* provider.startSession(asThreadId("thread-missing"), { |
| 1481 | provider: "codex", | 1775 | provider: ProviderDriverKind.make("codex"), |
| 1776 | providerInstanceId: codexInstanceId, | ||
| 1482 | threadId: asThreadId("thread-missing"), | 1777 | threadId: asThreadId("thread-missing"), |
| 1483 | cwd: "/tmp/project", | 1778 | cwd: "/tmp/project", |
| 1484 | runtimeMode: "full-access", | 1779 | runtimeMode: "full-access", |
apps/server/src/provider/Layers/ProviderService.ts+316-102| ... | @@ -19,10 +19,12 @@ import { | ... | @@ -19,10 +19,12 @@ import { |
| 19 | ProviderSendTurnInput, | 19 | ProviderSendTurnInput, |
| 20 | ProviderSessionStartInput, | 20 | ProviderSessionStartInput, |
| 21 | ProviderStopSessionInput, | 21 | ProviderStopSessionInput, |
| 22 | type ProviderInstanceId, | ||
| 23 | type ProviderDriverKind, | ||
| 22 | type ProviderRuntimeEvent, | 24 | type ProviderRuntimeEvent, |
| 23 | type ProviderSession, | 25 | type ProviderSession, |
| 24 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 25 | import { Effect, Layer, Option, PubSub, Schema, SchemaIssue, Stream } from "effect"; | 27 | import { Cause, Effect, Layer, Option, PubSub, Ref, Schema, SchemaIssue, Stream } from "effect"; |
| 26 | 28 | ||
| 27 | import { | 29 | import { |
| 28 | increment, | 30 | increment, |
| ... | @@ -34,19 +36,24 @@ import { | ... | @@ -34,19 +36,24 @@ import { |
| 34 | providerTurnMetricAttributes, | 36 | providerTurnMetricAttributes, |
| 35 | withMetrics, | 37 | withMetrics, |
| 36 | } from "../../observability/Metrics.ts"; | 38 | } from "../../observability/Metrics.ts"; |
| 37 | import { ProviderValidationError } from "../Errors.ts"; | 39 | import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; |
| 40 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | ||
| 38 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 41 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 39 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; | 42 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; |
| 40 | import { | 43 | import { |
| 41 | ProviderSessionDirectory, | 44 | ProviderSessionDirectory, |
| 42 | type ProviderRuntimeBinding, | 45 | type ProviderRuntimeBinding, |
| 43 | } from "../Services/ProviderSessionDirectory.ts"; | 46 | } from "../Services/ProviderSessionDirectory.ts"; |
| 44 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 47 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 48 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | ||
| 45 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; | 49 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 46 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 47 | 50 | ||
| 51 | /** | ||
| 52 | * Hook for tests that want to override the canonical event logger pulled | ||
| 53 | * from `ProviderEventLoggers`. Production wiring leaves this undefined and | ||
| 54 | * reads the logger off the tag. | ||
| 55 | */ | ||
| 48 | export interface ProviderServiceLiveOptions { | 56 | export interface ProviderServiceLiveOptions { |
| 49 | readonly canonicalEventLogPath?: string; | ||
| 50 | readonly canonicalEventLogger?: EventNdjsonLogger; | 57 | readonly canonicalEventLogger?: EventNdjsonLogger; |
| 51 | } | 58 | } |
| 52 | 59 | ||
| ... | @@ -141,18 +148,53 @@ function readPersistedCwd( | ... | @@ -141,18 +148,53 @@ function readPersistedCwd( |
| 141 | return trimmed.length > 0 ? trimmed : undefined; | 148 | return trimmed.length > 0 ? trimmed : undefined; |
| 142 | } | 149 | } |
| 143 | 150 | ||
| 151 | const dieOnMissingBindingInstanceId = ( | ||
| 152 | operation: string, | ||
| 153 | payload: { | ||
| 154 | readonly providerInstanceId?: ProviderInstanceId | undefined; | ||
| 155 | readonly provider?: ProviderDriverKind | undefined; | ||
| 156 | }, | ||
| 157 | ): ProviderInstanceId => { | ||
| 158 | if (payload.providerInstanceId !== undefined) { | ||
| 159 | return payload.providerInstanceId; | ||
| 160 | } | ||
| 161 | throw new Error( | ||
| 162 | payload.provider | ||
| 163 | ? `${operation}: provider instance id is required for provider '${payload.provider}'.` | ||
| 164 | : `${operation}: provider instance id is required.`, | ||
| 165 | ); | ||
| 166 | }; | ||
| 167 | |||
| 168 | const correlateRuntimeEventWithInstance = ( | ||
| 169 | source: { | ||
| 170 | readonly instanceId: ProviderInstanceId; | ||
| 171 | readonly provider: ProviderDriverKind; | ||
| 172 | }, | ||
| 173 | event: ProviderRuntimeEvent, | ||
| 174 | ): ProviderRuntimeEvent => { | ||
| 175 | if (event.provider !== source.provider) { | ||
| 176 | throw new Error( | ||
| 177 | `ProviderService.streamEvents: provider instance '${source.instanceId}' is backed by driver '${source.provider}' but emitted driver '${event.provider}'.`, | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | if (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) { | ||
| 181 | throw new Error( | ||
| 182 | `ProviderService.streamEvents: provider instance '${source.instanceId}' emitted event for instance '${event.providerInstanceId}'.`, | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | return { ...event, providerInstanceId: source.instanceId }; | ||
| 186 | }; | ||
| 187 | |||
| 144 | const makeProviderService = Effect.fn("makeProviderService")(function* ( | 188 | const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 145 | options?: ProviderServiceLiveOptions, | 189 | options?: ProviderServiceLiveOptions, |
| 146 | ) { | 190 | ) { |
| 147 | const analytics = yield* Effect.service(AnalyticsService); | 191 | const analytics = yield* Effect.service(AnalyticsService); |
| 148 | const serverSettings = yield* ServerSettingsService; | 192 | const eventLoggers = yield* ProviderEventLoggers; |
| 149 | const canonicalEventLogger = | 193 | // Options-provided logger wins (test overrides); otherwise we take whatever |
| 150 | options?.canonicalEventLogger ?? | 194 | // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical |
| 151 | (options?.canonicalEventLogPath !== undefined | 195 | // log writer is attached", which downstream code already handles as a |
| 152 | ? yield* makeEventNdjsonLogger(options.canonicalEventLogPath, { | 196 | // no-op. |
| 153 | stream: "canonical", | 197 | const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; |
| 154 | }) | ||
| 155 | : undefined); | ||
| 156 | 198 | ||
| 157 | const registry = yield* ProviderAdapterRegistry; | 199 | const registry = yield* ProviderAdapterRegistry; |
| 158 | const directory = yield* ProviderSessionDirectory; | 200 | const directory = yield* ProviderSessionDirectory; |
| ... | @@ -169,6 +211,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -169,6 +211,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 169 | Effect.asVoid, | 211 | Effect.asVoid, |
| 170 | ); | 212 | ); |
| 171 | 213 | ||
| 214 | const requireBindingInstanceId = ( | ||
| 215 | operation: string, | ||
| 216 | payload: { | ||
| 217 | readonly providerInstanceId?: ProviderInstanceId | undefined; | ||
| 218 | readonly provider?: ProviderDriverKind | undefined; | ||
| 219 | }, | ||
| 220 | ): Effect.Effect<ProviderInstanceId, ProviderValidationError> => | ||
| 221 | payload.providerInstanceId !== undefined | ||
| 222 | ? Effect.succeed(payload.providerInstanceId) | ||
| 223 | : Effect.fail( | ||
| 224 | toValidationError( | ||
| 225 | operation, | ||
| 226 | payload.provider | ||
| 227 | ? `Provider instance id is required for provider '${payload.provider}'.` | ||
| 228 | : "Provider instance id is required.", | ||
| 229 | ), | ||
| 230 | ); | ||
| 231 | |||
| 172 | const upsertSessionBinding = ( | 232 | const upsertSessionBinding = ( |
| 173 | session: ProviderSession, | 233 | session: ProviderSession, |
| 174 | threadId: ThreadId, | 234 | threadId: ThreadId, |
| ... | @@ -178,38 +238,106 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -178,38 +238,106 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 178 | readonly lastRuntimeEventAt?: string; | 238 | readonly lastRuntimeEventAt?: string; |
| 179 | }, | 239 | }, |
| 180 | ) => | 240 | ) => |
| 181 | directory.upsert({ | 241 | Effect.gen(function* () { |
| 182 | threadId, | 242 | const providerInstanceId = yield* requireBindingInstanceId( |
| 183 | provider: session.provider, | 243 | "ProviderService.upsertSessionBinding", |
| 184 | runtimeMode: session.runtimeMode, | 244 | session, |
| 185 | status: toRuntimeStatus(session), | 245 | ); |
| 186 | ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), | 246 | yield* directory.upsert({ |
| 187 | runtimePayload: toRuntimePayloadFromSession(session, extra), | 247 | threadId, |
| 248 | provider: session.provider, | ||
| 249 | providerInstanceId, | ||
| 250 | runtimeMode: session.runtimeMode, | ||
| 251 | status: toRuntimeStatus(session), | ||
| 252 | ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), | ||
| 253 | runtimePayload: toRuntimePayloadFromSession(session, extra), | ||
| 254 | }); | ||
| 188 | }); | 255 | }); |
| 189 | 256 | ||
| 190 | const providers = yield* registry.listProviders(); | 257 | const processRuntimeEvent = ( |
| 191 | const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); | 258 | source: { |
| 192 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 259 | readonly instanceId: ProviderInstanceId; |
| 193 | increment(providerRuntimeEventsTotal, { | 260 | readonly provider: ProviderDriverKind; |
| 194 | provider: event.provider, | 261 | }, |
| 195 | eventType: event.type, | 262 | event: ProviderRuntimeEvent, |
| 196 | }).pipe(Effect.andThen(publishRuntimeEvent(event))); | 263 | ): Effect.Effect<void> => |
| 264 | Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( | ||
| 265 | Effect.flatMap((canonicalEvent) => | ||
| 266 | increment(providerRuntimeEventsTotal, { | ||
| 267 | provider: canonicalEvent.provider, | ||
| 268 | eventType: canonicalEvent.type, | ||
| 269 | }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), | ||
| 270 | ), | ||
| 271 | ); | ||
| 272 | |||
| 273 | // `subscribedAdapters` is our source-of-truth for "which instance adapters | ||
| 274 | // are currently wired into the runtime event bus". It both tracks the set | ||
| 275 | // of live subscriptions (so `reconcileInstanceSubscriptions` can diff and | ||
| 276 | // fork only the *new* or *rebuilt* ones) and serves as the dynamic adapter | ||
| 277 | // list consumed by `stopStaleSessionsForThread`, `listSessions`, and | ||
| 278 | // `runStopAll` — replacing the pre-Slice-D startup snapshot so hot-added | ||
| 279 | // instances become visible to those call sites as soon as settings edits | ||
| 280 | // land. | ||
| 281 | const subscribedAdapters = yield* Ref.make( | ||
| 282 | new Map<ProviderInstanceId, ProviderAdapterShape<ProviderAdapterError>>(), | ||
| 283 | ); | ||
| 197 | 284 | ||
| 198 | yield* Effect.forEach(adapters, (adapter) => | 285 | const getAdapterEntries = Ref.get(subscribedAdapters).pipe( |
| 199 | Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), | 286 | Effect.map((map) => Array.from(map.entries())), |
| 200 | ).pipe(Effect.asVoid); | 287 | ); |
| 288 | |||
| 289 | // Rebuild the map of id → adapter from the registry and fork a new event | ||
| 290 | // subscription for every instance that is either brand new or whose adapter | ||
| 291 | // identity changed (indicating the underlying `ProviderInstance` was torn | ||
| 292 | // down and rebuilt by `ProviderInstanceRegistry.reconcile`). Orphaned | ||
| 293 | // fibers for removed/replaced instances exit on their own because their | ||
| 294 | // adapter's `streamEvents` source terminates when the old scope closes. | ||
| 295 | const reconcileInstanceSubscriptions = Effect.gen(function* () { | ||
| 296 | const previous = yield* Ref.get(subscribedAdapters); | ||
| 297 | const currentIds = yield* registry.listInstances(); | ||
| 298 | const next = new Map<ProviderInstanceId, ProviderAdapterShape<ProviderAdapterError>>(); | ||
| 299 | for (const id of currentIds) { | ||
| 300 | const adapterOption = yield* registry | ||
| 301 | .getByInstance(id) | ||
| 302 | .pipe(Effect.tapError(Effect.logWarning), Effect.option); | ||
| 303 | if (Option.isNone(adapterOption)) continue; | ||
| 304 | const adapter = adapterOption.value; | ||
| 305 | next.set(id, adapter); | ||
| 306 | if (previous.get(id) !== adapter) { | ||
| 307 | yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 308 | processRuntimeEvent( | ||
| 309 | { | ||
| 310 | instanceId: id, | ||
| 311 | provider: adapter.provider, | ||
| 312 | }, | ||
| 313 | event, | ||
| 314 | ), | ||
| 315 | ).pipe(Effect.forkScoped); | ||
| 316 | } | ||
| 317 | } | ||
| 318 | yield* Ref.set(subscribedAdapters, next); | ||
| 319 | }); | ||
| 320 | |||
| 321 | const instanceChanges = yield* registry.subscribeChanges; | ||
| 322 | yield* reconcileInstanceSubscriptions; | ||
| 323 | yield* Stream.runForEach( | ||
| 324 | Stream.fromSubscription(instanceChanges), | ||
| 325 | () => reconcileInstanceSubscriptions, | ||
| 326 | ).pipe(Effect.forkScoped); | ||
| 201 | 327 | ||
| 202 | const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { | 328 | const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { |
| 203 | readonly binding: ProviderRuntimeBinding; | 329 | readonly binding: ProviderRuntimeBinding; |
| 204 | readonly operation: string; | 330 | readonly operation: string; |
| 205 | }) { | 331 | }) { |
| 332 | const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); | ||
| 206 | yield* Effect.annotateCurrentSpan({ | 333 | yield* Effect.annotateCurrentSpan({ |
| 207 | "provider.operation": "recover-session", | 334 | "provider.operation": "recover-session", |
| 208 | "provider.kind": input.binding.provider, | 335 | "provider.kind": input.binding.provider, |
| 336 | "provider.instance_id": bindingInstanceId, | ||
| 209 | "provider.thread_id": input.binding.threadId, | 337 | "provider.thread_id": input.binding.threadId, |
| 210 | }); | 338 | }); |
| 211 | return yield* Effect.gen(function* () { | 339 | return yield* Effect.gen(function* () { |
| 212 | const adapter = yield* registry.getByProvider(input.binding.provider); | 340 | const adapter = yield* registry.getByInstance(bindingInstanceId); |
| 213 | const hasResumeCursor = | 341 | const hasResumeCursor = |
| 214 | input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; | 342 | input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; |
| 215 | const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); | 343 | const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); |
| ... | @@ -219,7 +347,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -219,7 +347,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 219 | (session) => session.threadId === input.binding.threadId, | 347 | (session) => session.threadId === input.binding.threadId, |
| 220 | ); | 348 | ); |
| 221 | if (existing) { | 349 | if (existing) { |
| 222 | yield* upsertSessionBinding(existing, input.binding.threadId); | 350 | yield* upsertSessionBinding( |
| 351 | { ...existing, providerInstanceId: bindingInstanceId }, | ||
| 352 | input.binding.threadId, | ||
| 353 | ); | ||
| 223 | yield* analytics.record("provider.session.recovered", { | 354 | yield* analytics.record("provider.session.recovered", { |
| 224 | provider: existing.provider, | 355 | provider: existing.provider, |
| 225 | strategy: "adopt-existing", | 356 | strategy: "adopt-existing", |
| ... | @@ -242,6 +373,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -242,6 +373,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 242 | const resumed = yield* adapter.startSession({ | 373 | const resumed = yield* adapter.startSession({ |
| 243 | threadId: input.binding.threadId, | 374 | threadId: input.binding.threadId, |
| 244 | provider: input.binding.provider, | 375 | provider: input.binding.provider, |
| 376 | providerInstanceId: bindingInstanceId, | ||
| 245 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | 377 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 246 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), | 378 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), |
| 247 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | 379 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| ... | @@ -254,7 +386,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -254,7 +386,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 254 | ); | 386 | ); |
| 255 | } | 387 | } |
| 256 | 388 | ||
| 257 | yield* upsertSessionBinding(resumed, input.binding.threadId); | 389 | yield* upsertSessionBinding( |
| 390 | { ...resumed, providerInstanceId: bindingInstanceId }, | ||
| 391 | input.binding.threadId, | ||
| 392 | ); | ||
| 258 | yield* analytics.record("provider.session.recovered", { | 393 | yield* analytics.record("provider.session.recovered", { |
| 259 | provider: resumed.provider, | 394 | provider: resumed.provider, |
| 260 | strategy: "resume-thread", | 395 | strategy: "resume-thread", |
| ... | @@ -284,29 +419,49 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -284,29 +419,49 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 284 | `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, | 419 | `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, |
| 285 | ); | 420 | ); |
| 286 | } | 421 | } |
| 287 | const adapter = yield* registry.getByProvider(binding.provider); | 422 | const instanceId = yield* requireBindingInstanceId(input.operation, binding); |
| 423 | const adapter = yield* registry.getByInstance(instanceId); | ||
| 288 | 424 | ||
| 289 | const hasRequestedSession = yield* adapter.hasSession(input.threadId); | 425 | const hasRequestedSession = yield* adapter.hasSession(input.threadId); |
| 290 | if (hasRequestedSession) { | 426 | if (hasRequestedSession) { |
| 291 | return { adapter, threadId: input.threadId, isActive: true } as const; | 427 | return { |
| 428 | adapter, | ||
| 429 | instanceId, | ||
| 430 | threadId: input.threadId, | ||
| 431 | isActive: true, | ||
| 432 | } as const; | ||
| 292 | } | 433 | } |
| 293 | 434 | ||
| 294 | if (!input.allowRecovery) { | 435 | if (!input.allowRecovery) { |
| 295 | return { adapter, threadId: input.threadId, isActive: false } as const; | 436 | return { |
| 437 | adapter, | ||
| 438 | instanceId, | ||
| 439 | threadId: input.threadId, | ||
| 440 | isActive: false, | ||
| 441 | } as const; | ||
| 296 | } | 442 | } |
| 297 | 443 | ||
| 298 | const recovered = yield* recoverSessionForThread({ binding, operation: input.operation }); | 444 | const recovered = yield* recoverSessionForThread({ |
| 299 | return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; | 445 | binding, |
| 446 | operation: input.operation, | ||
| 447 | }); | ||
| 448 | return { | ||
| 449 | adapter: recovered.adapter, | ||
| 450 | instanceId, | ||
| 451 | threadId: input.threadId, | ||
| 452 | isActive: true, | ||
| 453 | } as const; | ||
| 300 | }); | 454 | }); |
| 301 | 455 | ||
| 302 | const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { | 456 | const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { |
| 303 | readonly threadId: ThreadId; | 457 | readonly threadId: ThreadId; |
| 304 | readonly currentProvider: ProviderSession["provider"]; | 458 | readonly currentInstanceId: ProviderInstanceId; |
| 305 | }) { | 459 | }) { |
| 460 | const currentAdapters = yield* getAdapterEntries; | ||
| 306 | yield* Effect.forEach( | 461 | yield* Effect.forEach( |
| 307 | adapters, | 462 | currentAdapters, |
| 308 | (adapter) => | 463 | ([instanceId, adapter]) => |
| 309 | adapter.provider === input.currentProvider | 464 | instanceId === input.currentInstanceId |
| 310 | ? Effect.void | 465 | ? Effect.void |
| 311 | : Effect.gen(function* () { | 466 | : Effect.gen(function* () { |
| 312 | const hasSession = yield* adapter.hasSession(input.threadId); | 467 | const hasSession = yield* adapter.hasSession(input.threadId); |
| ... | @@ -341,63 +496,72 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -341,63 +496,72 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 341 | payload: rawInput, | 496 | payload: rawInput, |
| 342 | }); | 497 | }); |
| 343 | 498 | ||
| 344 | const input = { | 499 | const resolvedInstanceId = yield* requireBindingInstanceId( |
| 345 | ...parsed, | 500 | "ProviderService.startSession", |
| 346 | threadId, | 501 | parsed, |
| 347 | provider: parsed.provider ?? "codex", | 502 | ); |
| 348 | }; | 503 | let metricProvider = parsed.provider ?? String(resolvedInstanceId); |
| 349 | yield* Effect.annotateCurrentSpan({ | 504 | yield* Effect.annotateCurrentSpan({ |
| 350 | "provider.operation": "start-session", | 505 | "provider.operation": "start-session", |
| 351 | "provider.kind": input.provider, | 506 | "provider.instance_id": resolvedInstanceId, |
| 352 | "provider.thread_id": threadId, | 507 | "provider.thread_id": threadId, |
| 353 | "provider.runtime_mode": input.runtimeMode, | 508 | "provider.runtime_mode": parsed.runtimeMode, |
| 354 | }); | 509 | }); |
| 355 | return yield* Effect.gen(function* () { | 510 | return yield* Effect.gen(function* () { |
| 356 | const settings = yield* serverSettings.getSettings.pipe( | 511 | const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); |
| 357 | Effect.mapError((error) => | 512 | const resolvedProvider = instanceInfo.driverKind; |
| 358 | toValidationError( | 513 | metricProvider = resolvedProvider; |
| 359 | "ProviderService.startSession", | 514 | if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { |
| 360 | `Failed to load provider settings: ${error.message}`, | ||
| 361 | error, | ||
| 362 | ), | ||
| 363 | ), | ||
| 364 | ); | ||
| 365 | if (!settings.providers[input.provider].enabled) { | ||
| 366 | return yield* toValidationError( | 515 | return yield* toValidationError( |
| 367 | "ProviderService.startSession", | 516 | "ProviderService.startSession", |
| 368 | `Provider '${input.provider}' is disabled in T3 Code settings.`, | 517 | `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, |
| 518 | ); | ||
| 519 | } | ||
| 520 | const input = { | ||
| 521 | ...parsed, | ||
| 522 | threadId, | ||
| 523 | provider: resolvedProvider, | ||
| 524 | }; | ||
| 525 | if (!instanceInfo.enabled) { | ||
| 526 | return yield* toValidationError( | ||
| 527 | "ProviderService.startSession", | ||
| 528 | `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, | ||
| 369 | ); | 529 | ); |
| 370 | } | 530 | } |
| 371 | const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); | 531 | const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); |
| 372 | const effectiveResumeCursor = | 532 | const effectiveResumeCursor = |
| 373 | input.resumeCursor ?? | 533 | input.resumeCursor ?? |
| 374 | (persistedBinding?.provider === input.provider | 534 | (persistedBinding?.providerInstanceId === resolvedInstanceId |
| 375 | ? persistedBinding.resumeCursor | 535 | ? persistedBinding.resumeCursor |
| 376 | : undefined); | 536 | : undefined); |
| 377 | const effectiveCwd = | 537 | const effectiveCwd = |
| 378 | input.cwd ?? | 538 | input.cwd ?? |
| 379 | (persistedBinding?.provider === input.provider | 539 | (persistedBinding?.providerInstanceId === resolvedInstanceId |
| 380 | ? readPersistedCwd(persistedBinding.runtimePayload) | 540 | ? readPersistedCwd(persistedBinding.runtimePayload) |
| 381 | : undefined); | 541 | : undefined); |
| 382 | yield* Effect.annotateCurrentSpan({ | 542 | yield* Effect.annotateCurrentSpan({ |
| 543 | "provider.kind": resolvedProvider, | ||
| 383 | "provider.resume_cursor.source": | 544 | "provider.resume_cursor.source": |
| 384 | input.resumeCursor !== undefined | 545 | input.resumeCursor !== undefined |
| 385 | ? "request" | 546 | ? "request" |
| 386 | : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider | 547 | : effectiveResumeCursor !== undefined && |
| 548 | persistedBinding?.providerInstanceId === resolvedInstanceId | ||
| 387 | ? "persisted" | 549 | ? "persisted" |
| 388 | : "none", | 550 | : "none", |
| 389 | "provider.resume_cursor.present": effectiveResumeCursor !== undefined, | 551 | "provider.resume_cursor.present": effectiveResumeCursor !== undefined, |
| 390 | "provider.cwd.source": | 552 | "provider.cwd.source": |
| 391 | input.cwd !== undefined | 553 | input.cwd !== undefined |
| 392 | ? "request" | 554 | ? "request" |
| 393 | : effectiveCwd !== undefined && persistedBinding?.provider === input.provider | 555 | : effectiveCwd !== undefined && |
| 556 | persistedBinding?.providerInstanceId === resolvedInstanceId | ||
| 394 | ? "persisted" | 557 | ? "persisted" |
| 395 | : "none", | 558 | : "none", |
| 396 | "provider.cwd.effective": effectiveCwd ?? "", | 559 | "provider.cwd.effective": effectiveCwd ?? "", |
| 397 | }); | 560 | }); |
| 398 | const adapter = yield* registry.getByProvider(input.provider); | 561 | const adapter = yield* registry.getByInstance(resolvedInstanceId); |
| 399 | const session = yield* adapter.startSession({ | 562 | const session = yield* adapter.startSession({ |
| 400 | ...input, | 563 | ...input, |
| 564 | providerInstanceId: resolvedInstanceId, | ||
| 401 | ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), | 565 | ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), |
| 402 | ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), | 566 | ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), |
| 403 | }); | 567 | }); |
| ... | @@ -408,31 +572,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -408,31 +572,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 408 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, | 572 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, |
| 409 | ); | 573 | ); |
| 410 | } | 574 | } |
| 575 | const sessionWithInstance = { | ||
| 576 | ...session, | ||
| 577 | providerInstanceId: resolvedInstanceId, | ||
| 578 | }; | ||
| 411 | 579 | ||
| 412 | yield* stopStaleSessionsForThread({ | 580 | yield* stopStaleSessionsForThread({ |
| 413 | threadId, | 581 | threadId, |
| 414 | currentProvider: adapter.provider, | 582 | currentInstanceId: resolvedInstanceId, |
| 415 | }); | 583 | }); |
| 416 | yield* upsertSessionBinding(session, threadId, { | 584 | yield* upsertSessionBinding(sessionWithInstance, threadId, { |
| 417 | modelSelection: input.modelSelection, | 585 | modelSelection: input.modelSelection, |
| 418 | }); | 586 | }); |
| 419 | yield* analytics.record("provider.session.started", { | 587 | yield* analytics.record("provider.session.started", { |
| 420 | provider: session.provider, | 588 | provider: sessionWithInstance.provider, |
| 421 | runtimeMode: input.runtimeMode, | 589 | runtimeMode: input.runtimeMode, |
| 422 | hasResumeCursor: session.resumeCursor !== undefined, | 590 | hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, |
| 423 | hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, | 591 | hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, |
| 424 | hasModel: | 592 | hasModel: |
| 425 | typeof input.modelSelection?.model === "string" && | 593 | typeof input.modelSelection?.model === "string" && |
| 426 | input.modelSelection.model.trim().length > 0, | 594 | input.modelSelection.model.trim().length > 0, |
| 427 | }); | 595 | }); |
| 428 | 596 | ||
| 429 | return session; | 597 | return sessionWithInstance; |
| 430 | }).pipe( | 598 | }).pipe( |
| 431 | withMetrics({ | 599 | withMetrics({ |
| 432 | counter: providerSessionsTotal, | 600 | counter: providerSessionsTotal, |
| 433 | attributes: providerMetricAttributes(input.provider, { | 601 | attributes: () => |
| 434 | operation: "start", | 602 | providerMetricAttributes(metricProvider, { |
| 435 | }), | 603 | operation: "start", |
| 604 | }), | ||
| 436 | }), | 605 | }), |
| 437 | ); | 606 | ); |
| 438 | }, | 607 | }, |
| ... | @@ -479,6 +648,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -479,6 +648,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 479 | yield* directory.upsert({ | 648 | yield* directory.upsert({ |
| 480 | threadId: input.threadId, | 649 | threadId: input.threadId, |
| 481 | provider: routed.adapter.provider, | 650 | provider: routed.adapter.provider, |
| 651 | providerInstanceId: routed.instanceId, | ||
| 482 | status: "running", | 652 | status: "running", |
| 483 | ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), | 653 | ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), |
| 484 | runtimePayload: { | 654 | runtimePayload: { |
| ... | @@ -647,6 +817,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -647,6 +817,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 647 | yield* directory.upsert({ | 817 | yield* directory.upsert({ |
| 648 | threadId: input.threadId, | 818 | threadId: input.threadId, |
| 649 | provider: routed.adapter.provider, | 819 | provider: routed.adapter.provider, |
| 820 | providerInstanceId: routed.instanceId, | ||
| 650 | status: "stopped", | 821 | status: "stopped", |
| 651 | runtimePayload: { | 822 | runtimePayload: { |
| 652 | activeTurnId: null, | 823 | activeTurnId: null, |
| ... | @@ -669,8 +840,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -669,8 +840,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 669 | 840 | ||
| 670 | const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")( | 841 | const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")( |
| 671 | function* () { | 842 | function* () { |
| 672 | const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => | 843 | const currentAdapters = yield* getAdapterEntries; |
| 673 | adapter.listSessions(), | 844 | const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => |
| 845 | adapter.listSessions().pipe( | ||
| 846 | Effect.map((sessions) => | ||
| 847 | sessions.map((session) => ({ | ||
| 848 | ...session, | ||
| 849 | providerInstanceId: instanceId, | ||
| 850 | })), | ||
| 851 | ), | ||
| 852 | ), | ||
| 674 | ); | 853 | ); |
| 675 | const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); | 854 | const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); |
| 676 | const persistedBindings = yield* directory.listThreadIds().pipe( | 855 | const persistedBindings = yield* directory.listThreadIds().pipe( |
| ... | @@ -694,29 +873,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -694,29 +873,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 694 | } | 873 | } |
| 695 | } | 874 | } |
| 696 | 875 | ||
| 697 | return activeSessions.map((session) => { | 876 | const sessions: ProviderSession[] = []; |
| 877 | for (const session of activeSessions) { | ||
| 698 | const binding = bindingsByThreadId.get(session.threadId); | 878 | const binding = bindingsByThreadId.get(session.threadId); |
| 699 | if (!binding) { | 879 | if (!binding) { |
| 700 | return session; | 880 | sessions.push(session); |
| 881 | continue; | ||
| 701 | } | 882 | } |
| 702 | 883 | ||
| 703 | const overrides: { | 884 | const overrides: { |
| 704 | resumeCursor?: ProviderSession["resumeCursor"]; | 885 | resumeCursor?: ProviderSession["resumeCursor"]; |
| 705 | runtimeMode?: ProviderSession["runtimeMode"]; | 886 | runtimeMode?: ProviderSession["runtimeMode"]; |
| 887 | providerInstanceId?: ProviderSession["providerInstanceId"]; | ||
| 706 | } = {}; | 888 | } = {}; |
| 889 | overrides.providerInstanceId = dieOnMissingBindingInstanceId( | ||
| 890 | "ProviderService.listSessions", | ||
| 891 | binding, | ||
| 892 | ); | ||
| 893 | if (binding.provider !== session.provider) { | ||
| 894 | return yield* Effect.die( | ||
| 895 | new Error( | ||
| 896 | `ProviderService.listSessions: thread '${session.threadId}' is active on provider '${session.provider}' but persisted binding names provider '${binding.provider}'.`, | ||
| 897 | ), | ||
| 898 | ); | ||
| 899 | } | ||
| 900 | if (overrides.providerInstanceId !== session.providerInstanceId) { | ||
| 901 | return yield* Effect.die( | ||
| 902 | new Error( | ||
| 903 | `ProviderService.listSessions: thread '${session.threadId}' is active on provider instance '${session.providerInstanceId}' but persisted binding names '${overrides.providerInstanceId}'.`, | ||
| 904 | ), | ||
| 905 | ); | ||
| 906 | } | ||
| 707 | if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { | 907 | if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { |
| 708 | overrides.resumeCursor = binding.resumeCursor; | 908 | overrides.resumeCursor = binding.resumeCursor; |
| 709 | } | 909 | } |
| 710 | if (binding.runtimeMode !== undefined) { | 910 | if (binding.runtimeMode !== undefined) { |
| 711 | overrides.runtimeMode = binding.runtimeMode; | 911 | overrides.runtimeMode = binding.runtimeMode; |
| 712 | } | 912 | } |
| 713 | return Object.assign({}, session, overrides); | 913 | sessions.push(Object.assign({}, session, overrides)); |
| 714 | }); | 914 | } |
| 915 | return sessions; | ||
| 715 | }, | 916 | }, |
| 716 | ); | 917 | ); |
| 717 | 918 | ||
| 718 | const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => | 919 | const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) => |
| 719 | registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); | 920 | registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); |
| 921 | |||
| 922 | const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) => | ||
| 923 | registry.getInstanceInfo(instanceId); | ||
| 720 | 924 | ||
| 721 | const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn( | 925 | const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn( |
| 722 | "rollbackConversation", | 926 | "rollbackConversation", |
| ... | @@ -761,8 +965,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -761,8 +965,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 761 | 965 | ||
| 762 | const runStopAll = Effect.fn("runStopAll")(function* () { | 966 | const runStopAll = Effect.fn("runStopAll")(function* () { |
| 763 | const threadIds = yield* directory.listThreadIds(); | 967 | const threadIds = yield* directory.listThreadIds(); |
| 764 | const activeSessions = yield* Effect.forEach(adapters, (adapter) => | 968 | const currentAdapters = yield* getAdapterEntries; |
| 765 | adapter.listSessions(), | 969 | const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => |
| 970 | adapter.listSessions().pipe( | ||
| 971 | Effect.map((sessions) => | ||
| 972 | sessions.map((session) => ({ | ||
| 973 | ...session, | ||
| 974 | providerInstanceId: instanceId, | ||
| 975 | })), | ||
| 976 | ), | ||
| 977 | ), | ||
| 766 | ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); | 978 | ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); |
| 767 | yield* Effect.forEach(activeSessions, (session) => | 979 | yield* Effect.forEach(activeSessions, (session) => |
| 768 | upsertSessionBinding(session, session.threadId, { | 980 | upsertSessionBinding(session, session.threadId, { |
| ... | @@ -770,23 +982,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -770,23 +982,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 770 | lastRuntimeEventAt: new Date().toISOString(), | 982 | lastRuntimeEventAt: new Date().toISOString(), |
| 771 | }), | 983 | }), |
| 772 | ).pipe(Effect.asVoid); | 984 | ).pipe(Effect.asVoid); |
| 773 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); | 985 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); |
| 774 | yield* Effect.forEach(threadIds, (threadId) => | 986 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); |
| 775 | directory.getProvider(threadId).pipe( | 987 | yield* Effect.forEach(bindings, (binding) => { |
| 776 | Effect.flatMap((provider) => | 988 | const providerInstanceId = dieOnMissingBindingInstanceId("ProviderService.stopAll", binding); |
| 777 | directory.upsert({ | 989 | return directory.upsert({ |
| 778 | threadId, | 990 | threadId: binding.threadId, |
| 779 | provider, | 991 | provider: binding.provider, |
| 780 | status: "stopped", | 992 | providerInstanceId, |
| 781 | runtimePayload: { | 993 | status: "stopped", |
| 782 | activeTurnId: null, | 994 | runtimePayload: { |
| 783 | lastRuntimeEvent: "provider.stopAll", | 995 | activeTurnId: null, |
| 784 | lastRuntimeEventAt: new Date().toISOString(), | 996 | lastRuntimeEvent: "provider.stopAll", |
| 785 | }, | 997 | lastRuntimeEventAt: new Date().toISOString(), |
| 786 | }), | 998 | }, |
| 787 | ), | 999 | }); |
| 788 | ), | 1000 | }).pipe(Effect.asVoid); |
| 789 | ).pipe(Effect.asVoid); | ||
| 790 | yield* analytics.record("provider.sessions.stopped_all", { | 1001 | yield* analytics.record("provider.sessions.stopped_all", { |
| 791 | sessionCount: threadIds.length, | 1002 | sessionCount: threadIds.length, |
| 792 | }); | 1003 | }); |
| ... | @@ -794,8 +1005,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -794,8 +1005,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 794 | }); | 1005 | }); |
| 795 | 1006 | ||
| 796 | yield* Effect.addFinalizer(() => | 1007 | yield* Effect.addFinalizer(() => |
| 797 | Effect.catch(runStopAll(), (cause) => | 1008 | runStopAll().pipe( |
| 798 | Effect.logWarning("failed to stop provider service", { cause }), | 1009 | Effect.catchCause((cause) => |
| 1010 | Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), | ||
| 1011 | ), | ||
| 799 | ), | 1012 | ), |
| 800 | ); | 1013 | ); |
| 801 | 1014 | ||
| ... | @@ -808,6 +1021,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -808,6 +1021,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 808 | stopSession, | 1021 | stopSession, |
| 809 | listSessions, | 1022 | listSessions, |
| 810 | getCapabilities, | 1023 | getCapabilities, |
| 1024 | getInstanceInfo, | ||
| 811 | rollbackConversation, | 1025 | rollbackConversation, |
| 812 | // Each access creates a fresh PubSub subscription so that multiple | 1026 | // Each access creates a fresh PubSub subscription so that multiple |
| 813 | // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each | 1027 | // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+14-11| ... | @@ -3,7 +3,7 @@ import os from "node:os"; | ... | @@ -3,7 +3,7 @@ import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | 6 | import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; |
| 7 | import { it, assert } from "@effect/vitest"; | 7 | import { it, assert } from "@effect/vitest"; |
| 8 | import { assertSome } from "@effect/vitest/utils"; | 8 | import { assertSome } from "@effect/vitest/utils"; |
| 9 | import { Effect, Layer, Option } from "effect"; | 9 | import { Effect, Layer, Option } from "effect"; |
| ... | @@ -38,7 +38,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -38,7 +38,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 38 | const initialThreadId = ThreadId.make("thread-1"); | 38 | const initialThreadId = ThreadId.make("thread-1"); |
| 39 | 39 | ||
| 40 | yield* directory.upsert({ | 40 | yield* directory.upsert({ |
| 41 | provider: "codex", | 41 | provider: ProviderDriverKind.make("codex"), |
| 42 | threadId: initialThreadId, | 42 | threadId: initialThreadId, |
| 43 | }); | 43 | }); |
| 44 | 44 | ||
| ... | @@ -47,7 +47,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -47,7 +47,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 47 | const resolvedBinding = yield* directory.getBinding(initialThreadId); | 47 | const resolvedBinding = yield* directory.getBinding(initialThreadId); |
| 48 | assertSome(resolvedBinding, { | 48 | assertSome(resolvedBinding, { |
| 49 | threadId: initialThreadId, | 49 | threadId: initialThreadId, |
| 50 | provider: "codex", | 50 | provider: ProviderDriverKind.make("codex"), |
| 51 | }); | 51 | }); |
| 52 | if (Option.isSome(resolvedBinding)) { | 52 | if (Option.isSome(resolvedBinding)) { |
| 53 | assert.equal(resolvedBinding.value.threadId, initialThreadId); | 53 | assert.equal(resolvedBinding.value.threadId, initialThreadId); |
| ... | @@ -56,7 +56,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -56,7 +56,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 56 | const nextThreadId = ThreadId.make("thread-2"); | 56 | const nextThreadId = ThreadId.make("thread-2"); |
| 57 | 57 | ||
| 58 | yield* directory.upsert({ | 58 | yield* directory.upsert({ |
| 59 | provider: "codex", | 59 | provider: ProviderDriverKind.make("codex"), |
| 60 | threadId: nextThreadId, | 60 | threadId: nextThreadId, |
| 61 | }); | 61 | }); |
| 62 | const updatedBinding = yield* directory.getBinding(nextThreadId); | 62 | const updatedBinding = yield* directory.getBinding(nextThreadId); |
| ... | @@ -85,7 +85,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -85,7 +85,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 85 | const threadId = ThreadId.make("thread-runtime"); | 85 | const threadId = ThreadId.make("thread-runtime"); |
| 86 | 86 | ||
| 87 | yield* directory.upsert({ | 87 | yield* directory.upsert({ |
| 88 | provider: "codex", | 88 | provider: ProviderDriverKind.make("codex"), |
| 89 | threadId, | 89 | threadId, |
| 90 | status: "starting", | 90 | status: "starting", |
| 91 | resumeCursor: { | 91 | resumeCursor: { |
| ... | @@ -98,7 +98,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -98,7 +98,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 98 | }); | 98 | }); |
| 99 | 99 | ||
| 100 | yield* directory.upsert({ | 100 | yield* directory.upsert({ |
| 101 | provider: "codex", | 101 | provider: ProviderDriverKind.make("codex"), |
| 102 | threadId, | 102 | threadId, |
| 103 | status: "running", | 103 | status: "running", |
| 104 | runtimePayload: { | 104 | runtimePayload: { |
| ... | @@ -133,6 +133,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -133,6 +133,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 133 | yield* runtimeRepository.upsert({ | 133 | yield* runtimeRepository.upsert({ |
| 134 | threadId: newerThreadId, | 134 | threadId: newerThreadId, |
| 135 | providerName: "codex", | 135 | providerName: "codex", |
| 136 | providerInstanceId: null, | ||
| 136 | adapterKey: "codex", | 137 | adapterKey: "codex", |
| 137 | runtimeMode: "full-access", | 138 | runtimeMode: "full-access", |
| 138 | status: "running", | 139 | status: "running", |
| ... | @@ -148,6 +149,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -148,6 +149,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 148 | yield* runtimeRepository.upsert({ | 149 | yield* runtimeRepository.upsert({ |
| 149 | threadId: olderThreadId, | 150 | threadId: olderThreadId, |
| 150 | providerName: "claudeAgent", | 151 | providerName: "claudeAgent", |
| 152 | providerInstanceId: null, | ||
| 151 | adapterKey: "claudeAgent", | 153 | adapterKey: "claudeAgent", |
| 152 | runtimeMode: "approval-required", | 154 | runtimeMode: "approval-required", |
| 153 | status: "starting", | 155 | status: "starting", |
| ... | @@ -165,7 +167,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -165,7 +167,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 165 | assert.deepEqual(bindings, [ | 167 | assert.deepEqual(bindings, [ |
| 166 | { | 168 | { |
| 167 | threadId: olderThreadId, | 169 | threadId: olderThreadId, |
| 168 | provider: "claudeAgent", | 170 | provider: ProviderDriverKind.make("claudeAgent"), |
| 169 | adapterKey: "claudeAgent", | 171 | adapterKey: "claudeAgent", |
| 170 | runtimeMode: "approval-required", | 172 | runtimeMode: "approval-required", |
| 171 | status: "starting", | 173 | status: "starting", |
| ... | @@ -179,7 +181,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -179,7 +181,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 179 | }, | 181 | }, |
| 180 | { | 182 | { |
| 181 | threadId: newerThreadId, | 183 | threadId: newerThreadId, |
| 182 | provider: "codex", | 184 | provider: ProviderDriverKind.make("codex"), |
| 183 | adapterKey: "codex", | 185 | adapterKey: "codex", |
| 184 | runtimeMode: "full-access", | 186 | runtimeMode: "full-access", |
| 185 | status: "running", | 187 | status: "running", |
| ... | @@ -203,6 +205,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -203,6 +205,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 203 | yield* runtimeRepository.upsert({ | 205 | yield* runtimeRepository.upsert({ |
| 204 | threadId, | 206 | threadId, |
| 205 | providerName: "claudeAgent", | 207 | providerName: "claudeAgent", |
| 208 | providerInstanceId: null, | ||
| 206 | adapterKey: "claudeAgent", | 209 | adapterKey: "claudeAgent", |
| 207 | runtimeMode: "full-access", | 210 | runtimeMode: "full-access", |
| 208 | status: "running", | 211 | status: "running", |
| ... | @@ -212,7 +215,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -212,7 +215,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 212 | }); | 215 | }); |
| 213 | 216 | ||
| 214 | yield* directory.upsert({ | 217 | yield* directory.upsert({ |
| 215 | provider: "codex", | 218 | provider: ProviderDriverKind.make("codex"), |
| 216 | threadId, | 219 | threadId, |
| 217 | }); | 220 | }); |
| 218 | 221 | ||
| ... | @@ -235,7 +238,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -235,7 +238,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 235 | yield* Effect.gen(function* () { | 238 | yield* Effect.gen(function* () { |
| 236 | const directory = yield* ProviderSessionDirectory; | 239 | const directory = yield* ProviderSessionDirectory; |
| 237 | yield* directory.upsert({ | 240 | yield* directory.upsert({ |
| 238 | provider: "codex", | 241 | provider: ProviderDriverKind.make("codex"), |
| 239 | threadId, | 242 | threadId, |
| 240 | }); | 243 | }); |
| 241 | }).pipe(Effect.provide(directoryLayer)); | 244 | }).pipe(Effect.provide(directoryLayer)); |
| ... | @@ -249,7 +252,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -249,7 +252,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 249 | const resolvedBinding = yield* directory.getBinding(threadId); | 252 | const resolvedBinding = yield* directory.getBinding(threadId); |
| 250 | assertSome(resolvedBinding, { | 253 | assertSome(resolvedBinding, { |
| 251 | threadId, | 254 | threadId, |
| 252 | provider: "codex", | 255 | provider: ProviderDriverKind.make("codex"), |
| 253 | }); | 256 | }); |
| 254 | if (Option.isSome(resolvedBinding)) { | 257 | if (Option.isSome(resolvedBinding)) { |
| 255 | assert.equal(resolvedBinding.value.threadId, threadId); | 258 | assert.equal(resolvedBinding.value.threadId, threadId); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+19-5| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProviderKind, type ThreadId } from "@t3tools/contracts"; | 1 | import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, Option, Schema } from "effect"; | 2 | import { Effect, Layer, Option, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; | 4 | import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| ... | @@ -20,11 +20,11 @@ function toPersistenceError(operation: string) { | ... | @@ -20,11 +20,11 @@ function toPersistenceError(operation: string) { |
| 20 | }); | 20 | }); |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | function decodeProviderKind( | 23 | function decodeProviderDriverKind( |
| 24 | providerName: string, | 24 | providerName: string, |
| 25 | operation: string, | 25 | operation: string, |
| 26 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { | 26 | ): Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryPersistenceError> { |
| 27 | return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe( | 27 | return Schema.decodeUnknownEffect(ProviderDriverKind)(providerName).pipe( |
| 28 | Effect.mapError( | 28 | Effect.mapError( |
| 29 | (cause) => | 29 | (cause) => |
| 30 | new ProviderSessionDirectoryPersistenceError({ | 30 | new ProviderSessionDirectoryPersistenceError({ |
| ... | @@ -57,12 +57,17 @@ function toRuntimeBinding( | ... | @@ -57,12 +57,17 @@ function toRuntimeBinding( |
| 57 | runtime: ProviderSessionRuntime, | 57 | runtime: ProviderSessionRuntime, |
| 58 | operation: string, | 58 | operation: string, |
| 59 | ): Effect.Effect<ProviderRuntimeBindingWithMetadata, ProviderSessionDirectoryPersistenceError> { | 59 | ): Effect.Effect<ProviderRuntimeBindingWithMetadata, ProviderSessionDirectoryPersistenceError> { |
| 60 | return decodeProviderKind(runtime.providerName, operation).pipe( | 60 | return decodeProviderDriverKind(runtime.providerName, operation).pipe( |
| 61 | Effect.map( | 61 | Effect.map( |
| 62 | (provider) => | 62 | (provider) => |
| 63 | ({ | 63 | ({ |
| 64 | threadId: runtime.threadId, | 64 | threadId: runtime.threadId, |
| 65 | provider, | 65 | provider, |
| 66 | // Migration boundary only: rows written before the instance split | ||
| 67 | // have a null provider_instance_id. Promote them as they leave | ||
| 68 | // persistence so hot routing code never has to infer an instance | ||
| 69 | // from a driver kind. | ||
| 70 | providerInstanceId: runtime.providerInstanceId ?? defaultInstanceIdForDriver(provider), | ||
| 66 | adapterKey: runtime.adapterKey, | 71 | adapterKey: runtime.adapterKey, |
| 67 | runtimeMode: runtime.runtimeMode, | 72 | runtimeMode: runtime.runtimeMode, |
| 68 | status: runtime.status, | 73 | status: runtime.status, |
| ... | @@ -108,10 +113,19 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -108,10 +113,19 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 108 | const now = new Date().toISOString(); | 113 | const now = new Date().toISOString(); |
| 109 | const providerChanged = | 114 | const providerChanged = |
| 110 | existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; | 115 | existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; |
| 116 | const providerInstanceId = | ||
| 117 | binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); | ||
| 118 | if (providerInstanceId === null || providerInstanceId === undefined) { | ||
| 119 | return yield* new ProviderValidationError({ | ||
| 120 | operation: "ProviderSessionDirectory.upsert", | ||
| 121 | issue: "providerInstanceId is required for provider session runtime bindings.", | ||
| 122 | }); | ||
| 123 | } | ||
| 111 | yield* repository | 124 | yield* repository |
| 112 | .upsert({ | 125 | .upsert({ |
| 113 | threadId: resolvedThreadId, | 126 | threadId: resolvedThreadId, |
| 114 | providerName: binding.provider, | 127 | providerName: binding.provider, |
| 128 | providerInstanceId, | ||
| 115 | adapterKey: | 129 | adapterKey: |
| 116 | binding.adapterKey ?? | 130 | binding.adapterKey ?? |
| 117 | (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), | 131 | (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+29-2| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | 2 | import { |
| 3 | ProjectId, | ||
| 4 | ThreadId, | ||
| 5 | TurnId, | ||
| 6 | ProviderDriverKind, | ||
| 7 | ProviderInstanceId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 3 | import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; | 9 | import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; |
| 4 | import { afterEach, describe, expect, it, vi } from "vitest"; | 10 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 5 | 11 | ||
| ... | @@ -17,7 +23,7 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | ... | @@ -17,7 +23,7 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 17 | import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; | 23 | import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; |
| 18 | 24 | ||
| 19 | const defaultModelSelection = { | 25 | const defaultModelSelection = { |
| 20 | provider: "codex", | 26 | instanceId: ProviderInstanceId.make("codex"), |
| 21 | model: "gpt-5-codex", | 27 | model: "gpt-5-codex", |
| 22 | } as const; | 28 | } as const; |
| 23 | 29 | ||
| ... | @@ -140,6 +146,19 @@ describe("ProviderSessionReaper", () => { | ... | @@ -140,6 +146,19 @@ describe("ProviderSessionReaper", () => { |
| 140 | stopSession, | 146 | stopSession, |
| 141 | listSessions: () => Effect.succeed([]), | 147 | listSessions: () => Effect.succeed([]), |
| 142 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | 148 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| 149 | getInstanceInfo: (instanceId) => { | ||
| 150 | const driverKind = ProviderDriverKind.make(String(instanceId)); | ||
| 151 | return Effect.succeed({ | ||
| 152 | instanceId, | ||
| 153 | driverKind, | ||
| 154 | displayName: undefined, | ||
| 155 | enabled: true, | ||
| 156 | continuationIdentity: { | ||
| 157 | driverKind, | ||
| 158 | continuationKey: `${driverKind}:instance:${instanceId}`, | ||
| 159 | }, | ||
| 160 | }); | ||
| 161 | }, | ||
| 143 | rollbackConversation: () => unsupported(), | 162 | rollbackConversation: () => unsupported(), |
| 144 | streamEvents: Stream.empty, | 163 | streamEvents: Stream.empty, |
| 145 | }; | 164 | }; |
| ... | @@ -197,6 +216,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -197,6 +216,7 @@ describe("ProviderSessionReaper", () => { |
| 197 | repository.upsert({ | 216 | repository.upsert({ |
| 198 | threadId, | 217 | threadId, |
| 199 | providerName: "claudeAgent", | 218 | providerName: "claudeAgent", |
| 219 | providerInstanceId: null, | ||
| 200 | adapterKey: "claudeAgent", | 220 | adapterKey: "claudeAgent", |
| 201 | runtimeMode: "full-access", | 221 | runtimeMode: "full-access", |
| 202 | status: "running", | 222 | status: "running", |
| ... | @@ -244,6 +264,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -244,6 +264,7 @@ describe("ProviderSessionReaper", () => { |
| 244 | repository.upsert({ | 264 | repository.upsert({ |
| 245 | threadId, | 265 | threadId, |
| 246 | providerName: "claudeAgent", | 266 | providerName: "claudeAgent", |
| 267 | providerInstanceId: null, | ||
| 247 | adapterKey: "claudeAgent", | 268 | adapterKey: "claudeAgent", |
| 248 | runtimeMode: "full-access", | 269 | runtimeMode: "full-access", |
| 249 | status: "running", | 270 | status: "running", |
| ... | @@ -290,6 +311,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -290,6 +311,7 @@ describe("ProviderSessionReaper", () => { |
| 290 | repository.upsert({ | 311 | repository.upsert({ |
| 291 | threadId, | 312 | threadId, |
| 292 | providerName: "claudeAgent", | 313 | providerName: "claudeAgent", |
| 314 | providerInstanceId: null, | ||
| 293 | adapterKey: "claudeAgent", | 315 | adapterKey: "claudeAgent", |
| 294 | runtimeMode: "full-access", | 316 | runtimeMode: "full-access", |
| 295 | status: "running", | 317 | status: "running", |
| ... | @@ -336,6 +358,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -336,6 +358,7 @@ describe("ProviderSessionReaper", () => { |
| 336 | repository.upsert({ | 358 | repository.upsert({ |
| 337 | threadId, | 359 | threadId, |
| 338 | providerName: "claudeAgent", | 360 | providerName: "claudeAgent", |
| 361 | providerInstanceId: null, | ||
| 339 | adapterKey: "claudeAgent", | 362 | adapterKey: "claudeAgent", |
| 340 | runtimeMode: "full-access", | 363 | runtimeMode: "full-access", |
| 341 | status: "stopped", | 364 | status: "stopped", |
| ... | @@ -404,6 +427,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -404,6 +427,7 @@ describe("ProviderSessionReaper", () => { |
| 404 | repository.upsert({ | 427 | repository.upsert({ |
| 405 | threadId: failedThreadId, | 428 | threadId: failedThreadId, |
| 406 | providerName: "claudeAgent", | 429 | providerName: "claudeAgent", |
| 430 | providerInstanceId: null, | ||
| 407 | adapterKey: "claudeAgent", | 431 | adapterKey: "claudeAgent", |
| 408 | runtimeMode: "full-access", | 432 | runtimeMode: "full-access", |
| 409 | status: "running", | 433 | status: "running", |
| ... | @@ -418,6 +442,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -418,6 +442,7 @@ describe("ProviderSessionReaper", () => { |
| 418 | repository.upsert({ | 442 | repository.upsert({ |
| 419 | threadId: reapedThreadId, | 443 | threadId: reapedThreadId, |
| 420 | providerName: "codex", | 444 | providerName: "codex", |
| 445 | providerInstanceId: null, | ||
| 421 | adapterKey: "codex", | 446 | adapterKey: "codex", |
| 422 | runtimeMode: "full-access", | 447 | runtimeMode: "full-access", |
| 423 | status: "running", | 448 | status: "running", |
| ... | @@ -483,6 +508,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -483,6 +508,7 @@ describe("ProviderSessionReaper", () => { |
| 483 | repository.upsert({ | 508 | repository.upsert({ |
| 484 | threadId: defectThreadId, | 509 | threadId: defectThreadId, |
| 485 | providerName: "claudeAgent", | 510 | providerName: "claudeAgent", |
| 511 | providerInstanceId: null, | ||
| 486 | adapterKey: "claudeAgent", | 512 | adapterKey: "claudeAgent", |
| 487 | runtimeMode: "full-access", | 513 | runtimeMode: "full-access", |
| 488 | status: "running", | 514 | status: "running", |
| ... | @@ -497,6 +523,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -497,6 +523,7 @@ describe("ProviderSessionReaper", () => { |
| 497 | repository.upsert({ | 523 | repository.upsert({ |
| 498 | threadId: reapedThreadId, | 524 | threadId: reapedThreadId, |
| 499 | providerName: "codex", | 525 | providerName: "codex", |
| 526 | providerInstanceId: null, | ||
| 500 | adapterKey: "codex", | 527 | adapterKey: "codex", |
| 501 | runtimeMode: "full-access", | 528 | runtimeMode: "full-access", |
| 502 | status: "running", | 529 | status: "running", |
apps/server/src/provider/Layers/scopedSafeTeardown.test.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | import { it } from "@effect/vitest"; | ||
| 2 | import { Cause, Effect, Exit } from "effect"; | ||
| 3 | import { describe, expect } from "vitest"; | ||
| 4 | |||
| 5 | import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; | ||
| 6 | |||
| 7 | describe("scopedSafeTeardown", () => { | ||
| 8 | it.effect("returns the body's value when teardown is clean", () => | ||
| 9 | Effect.gen(function* () { | ||
| 10 | const finalizers: string[] = []; | ||
| 11 | const wrapped = Effect.gen(function* () { | ||
| 12 | yield* Effect.addFinalizer(() => | ||
| 13 | Effect.sync(() => { | ||
| 14 | finalizers.push("clean"); | ||
| 15 | }), | ||
| 16 | ); | ||
| 17 | return "body-ok"; | ||
| 18 | }).pipe(scopedSafeTeardown("test")); | ||
| 19 | |||
| 20 | const value = yield* wrapped; | ||
| 21 | expect(value).toBe("body-ok"); | ||
| 22 | expect(finalizers).toEqual(["clean"]); | ||
| 23 | }), | ||
| 24 | ); | ||
| 25 | |||
| 26 | it.effect("preserves body success when a finalizer dies", () => | ||
| 27 | // The production failure mode: `Layer.build(...)` registers a finalizer | ||
| 28 | // that kills a subprocess; if the kill fails, the defect would otherwise | ||
| 29 | // override a successful probe body. | ||
| 30 | Effect.gen(function* () { | ||
| 31 | const finalizers: string[] = []; | ||
| 32 | const wrapped = Effect.gen(function* () { | ||
| 33 | yield* Effect.addFinalizer(() => | ||
| 34 | Effect.sync(() => { | ||
| 35 | finalizers.push("ran-before-die"); | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | yield* Effect.addFinalizer(() => | ||
| 39 | Effect.die(new Error("simulated subprocess kill failure")), | ||
| 40 | ); | ||
| 41 | return "body-ok"; | ||
| 42 | }).pipe(scopedSafeTeardown("test")); | ||
| 43 | |||
| 44 | const value = yield* wrapped; | ||
| 45 | expect(value).toBe("body-ok"); | ||
| 46 | // The clean finalizer still ran; teardown defect was logged + swallowed. | ||
| 47 | expect(finalizers).toEqual(["ran-before-die"]); | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | it.effect("preserves typed body failures even when teardown is clean", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | class BodyError { | ||
| 54 | readonly _tag = "BodyError" as const; | ||
| 55 | } | ||
| 56 | const wrapped = Effect.gen(function* () { | ||
| 57 | yield* Effect.addFinalizer(() => Effect.void); | ||
| 58 | return yield* Effect.fail(new BodyError()); | ||
| 59 | }).pipe(scopedSafeTeardown("test")); | ||
| 60 | |||
| 61 | const exit = yield* Effect.exit(wrapped); | ||
| 62 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 63 | if (Exit.isFailure(exit)) { | ||
| 64 | // Body's typed failure should surface, not a defect. | ||
| 65 | const squashed = Cause.squash(exit.cause); | ||
| 66 | expect(squashed).toBeInstanceOf(BodyError); | ||
| 67 | } | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("prefers the body's typed failure over a teardown defect", () => | ||
| 72 | // Even when both the body fails AND teardown defects, the body's typed | ||
| 73 | // failure is what callers see. This matters because `Effect.result` / | ||
| 74 | // `.pipe(Effect.exit)` in callers expects a typed Failure, not a Die. | ||
| 75 | Effect.gen(function* () { | ||
| 76 | class BodyError { | ||
| 77 | readonly _tag = "BodyError" as const; | ||
| 78 | } | ||
| 79 | const wrapped = Effect.gen(function* () { | ||
| 80 | yield* Effect.addFinalizer(() => | ||
| 81 | Effect.die(new Error("simulated subprocess kill failure")), | ||
| 82 | ); | ||
| 83 | return yield* Effect.fail(new BodyError()); | ||
| 84 | }).pipe(scopedSafeTeardown("test")); | ||
| 85 | |||
| 86 | const exit = yield* Effect.exit(wrapped); | ||
| 87 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 88 | if (Exit.isFailure(exit)) { | ||
| 89 | const squashed = Cause.squash(exit.cause); | ||
| 90 | expect(squashed).toBeInstanceOf(BodyError); | ||
| 91 | } | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | }); | ||
apps/server/src/provider/Layers/scopedSafeTeardown.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | /** | ||
| 2 | * scopedSafeTeardown — run a scope-requiring effect so that finalizer | ||
| 3 | * failures during scope close cannot override the body's Exit. | ||
| 4 | * | ||
| 5 | * Motivation | ||
| 6 | * ---------- | ||
| 7 | * The obvious pattern is `body.pipe(Effect.scoped)`: provide a fresh | ||
| 8 | * Scope, run the body, close the scope with the body's Exit. If a | ||
| 9 | * finalizer (e.g. `ChildProcess.kill` from an effect-codex-app-server | ||
| 10 | * spawn) dies during that close, the combined Exit becomes the | ||
| 11 | * finalizer's defect — even when the body already succeeded. | ||
| 12 | * | ||
| 13 | * Concretely this bit us in the Codex provider probe: a successful | ||
| 14 | * `initialize` → `account/read` → `skills/list` → `model/list` | ||
| 15 | * round-trip produced a `CodexAppServerProviderSnapshot`, but the | ||
| 16 | * `Layer.build(CodexClient.layerCommand(...))` finalizer then failed to | ||
| 17 | * kill the `codex app-server` subprocess with a `PlatformError`. The | ||
| 18 | * defect bubbled past `Effect.result` in `checkCodexProviderStatus`, | ||
| 19 | * died `refreshOneSource`, and `providersRef` never saw the snapshot. | ||
| 20 | * | ||
| 21 | * Strategy | ||
| 22 | * -------- | ||
| 23 | * 1. Make a fresh scope manually. | ||
| 24 | * 2. Run the body against that scope, capturing its Exit via | ||
| 25 | * `Effect.exit`. | ||
| 26 | * 3. Close the scope, catching any cause (typed failure *or* defect) | ||
| 27 | * with a log. | ||
| 28 | * 4. Replay the captured Exit so typed body failures still surface and | ||
| 29 | * successes still return their value. | ||
| 30 | * | ||
| 31 | * The helper deliberately logs teardown causes at `Warning` level — | ||
| 32 | * silently swallowing them is dangerous because they usually indicate a | ||
| 33 | * real bug in a downstream Layer's finalizer. | ||
| 34 | * | ||
| 35 | * @module provider/Layers/scopedSafeTeardown | ||
| 36 | */ | ||
| 37 | import { Effect, Exit, Scope } from "effect"; | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Run `effect` with a freshly made `Scope.Scope`, guaranteeing that | ||
| 41 | * teardown failures cannot override the body's Exit. | ||
| 42 | * | ||
| 43 | * Shape matches `Effect.scoped`: takes an effect whose env includes | ||
| 44 | * `Scope.Scope`, returns one whose env excludes it. | ||
| 45 | * | ||
| 46 | * @param label Short label for the warning log emitted when teardown | ||
| 47 | * fails. Use something like `"codex-probe"`. | ||
| 48 | */ | ||
| 49 | export const scopedSafeTeardown = | ||
| 50 | (label: string) => | ||
| 51 | <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, Scope.Scope>> => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const scope = yield* Scope.make(); | ||
| 54 | const bodyExit = yield* effect.pipe(Effect.provideService(Scope.Scope, scope), Effect.exit); | ||
| 55 | yield* Scope.close(scope, Exit.void).pipe( | ||
| 56 | Effect.catchCause((cause) => | ||
| 57 | Effect.logWarning(`${label} teardown errored; preserving body result`, cause), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | return yield* bodyExit; | ||
| 61 | }) as Effect.Effect<A, E, Exclude<R, Scope.Scope>>; | ||
apps/server/src/provider/ProviderDriver.ts created+167| ... | @@ -0,0 +1,167 @@ | ||
| 1 | /** | ||
| 2 | * ProviderDriver / ProviderInstance — driver SPI as plain values. | ||
| 3 | * | ||
| 4 | * `ProviderDriver` is a record, not a Context.Service. The thing it produces | ||
| 5 | * (`ProviderInstance`) is also a record — three captured closures | ||
| 6 | * (`snapshot`, `adapter`, `textGeneration`), an id, and a driver kind. There | ||
| 7 | * are intentionally no per-driver Context tags because tags are | ||
| 8 | * singleton-per-runtime and we need many instances of the same driver. | ||
| 9 | * | ||
| 10 | * The only Effect service involved is `ProviderInstanceRegistry`, which | ||
| 11 | * owns the live `Map<InstanceId, ProviderInstance>` and is itself a | ||
| 12 | * singleton. | ||
| 13 | * | ||
| 14 | * Driver factories are functions of `(typed config, env)` where: | ||
| 15 | * - `typed config` is decoded once by the registry via `configSchema`, | ||
| 16 | * so drivers never deal with raw `unknown`. | ||
| 17 | * - `env` flows through Effect's R channel. Each driver declares the | ||
| 18 | * subset of infrastructure services it needs (FileSystem, | ||
| 19 | * ChildProcessSpawner, …) on its `create` return type; the registry | ||
| 20 | * layer's R is the union of those, and the runtime layer satisfies it. | ||
| 21 | * | ||
| 22 | * @module provider/ProviderDriver | ||
| 23 | */ | ||
| 24 | import type { | ||
| 25 | ProviderDriverKind, | ||
| 26 | ProviderInstanceEnvironment, | ||
| 27 | ProviderInstanceId, | ||
| 28 | } from "@t3tools/contracts"; | ||
| 29 | import type { Effect, Schema, Scope } from "effect"; | ||
| 30 | |||
| 31 | import type { TextGenerationShape } from "../git/Services/TextGeneration.ts"; | ||
| 32 | import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; | ||
| 33 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; | ||
| 34 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Static metadata advertised by a driver. Used for default presentation | ||
| 38 | * and (later) settings UI. Doesn't need to be Effect-typed because nothing | ||
| 39 | * about it is dynamic — drivers are registered at startup. | ||
| 40 | */ | ||
| 41 | export interface ProviderDriverMetadata { | ||
| 42 | /** Human-readable name for the driver itself (e.g. "Codex"). */ | ||
| 43 | readonly displayName: string; | ||
| 44 | /** | ||
| 45 | * Whether the driver may be instantiated more than once concurrently. | ||
| 46 | * Defaults to `true`. Set to `false` for drivers that wrap a global | ||
| 47 | * resource (e.g. a single desktop app socket) — the registry then | ||
| 48 | * rejects multi-instance configurations with a clear error. | ||
| 49 | */ | ||
| 50 | readonly supportsMultipleInstances?: boolean; | ||
| 51 | } | ||
| 52 | |||
| 53 | /** | ||
| 54 | * One materialized provider instance. Held by the registry, looked up by | ||
| 55 | * `instanceId`, torn down by closing the scope it was created in. | ||
| 56 | * | ||
| 57 | * The three "shape" fields are captured closures owned by this instance — | ||
| 58 | * stopping one instance cannot affect another, and starting a second | ||
| 59 | * instance of the same driver does not reach into the first instance's | ||
| 60 | * state. | ||
| 61 | */ | ||
| 62 | export interface ProviderInstance { | ||
| 63 | readonly instanceId: ProviderInstanceId; | ||
| 64 | readonly driverKind: ProviderDriverKind; | ||
| 65 | readonly continuationIdentity: ProviderContinuationIdentity; | ||
| 66 | readonly displayName: string | undefined; | ||
| 67 | readonly accentColor?: string | undefined; | ||
| 68 | readonly enabled: boolean; | ||
| 69 | readonly snapshot: ServerProviderShape; | ||
| 70 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; | ||
| 71 | readonly textGeneration: TextGenerationShape; | ||
| 72 | } | ||
| 73 | |||
| 74 | export interface ProviderContinuationIdentity { | ||
| 75 | readonly driverKind: ProviderDriverKind; | ||
| 76 | readonly continuationKey: string; | ||
| 77 | } | ||
| 78 | |||
| 79 | export function defaultProviderContinuationIdentity(input: { | ||
| 80 | readonly driverKind: ProviderDriverKind; | ||
| 81 | readonly instanceId: ProviderInstanceId; | ||
| 82 | }): ProviderContinuationIdentity { | ||
| 83 | return { | ||
| 84 | driverKind: input.driverKind, | ||
| 85 | continuationKey: `${input.driverKind}:instance:${input.instanceId}`, | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | |||
| 89 | /** | ||
| 90 | * Inputs the registry passes to a driver's `create` function. | ||
| 91 | * | ||
| 92 | * `config` is the typed payload — already decoded by the registry through | ||
| 93 | * `driver.configSchema`. Drivers never decode their own raw envelope. | ||
| 94 | */ | ||
| 95 | export interface ProviderDriverCreateInput<Config> { | ||
| 96 | readonly instanceId: ProviderInstanceId; | ||
| 97 | readonly displayName: string | undefined; | ||
| 98 | readonly accentColor?: string | undefined; | ||
| 99 | readonly environment: ProviderInstanceEnvironment; | ||
| 100 | readonly enabled: boolean; | ||
| 101 | readonly config: Config; | ||
| 102 | } | ||
| 103 | |||
| 104 | /** | ||
| 105 | * Driver SPI — registered as a plain value, not a Layer. | ||
| 106 | * | ||
| 107 | * `Config` is whatever the driver decoded from | ||
| 108 | * `ProviderInstanceConfig.config`. `R` is the union of infrastructure | ||
| 109 | * services the driver depends on; the registry layer aggregates `R` across | ||
| 110 | * all registered drivers and the runtime supplies them. | ||
| 111 | * | ||
| 112 | * `create` is responsible for *all* per-instance state — process handles, | ||
| 113 | * pubsub topics, refs, file watchers — and must release them when its | ||
| 114 | * scope closes. Two calls to `create` with different `instanceId` / | ||
| 115 | * `config` MUST yield instances with no shared mutable state. | ||
| 116 | */ | ||
| 117 | export interface ProviderDriver<Config, R = never> { | ||
| 118 | readonly driverKind: ProviderDriverKind; | ||
| 119 | readonly metadata: ProviderDriverMetadata; | ||
| 120 | /** | ||
| 121 | * Decoder for the opaque `ProviderInstanceConfig.config` envelope. The | ||
| 122 | * registry runs this exactly once per (re)load of an instance; a decode | ||
| 123 | * failure is surfaced as `ProviderDriverError` and downgraded to an | ||
| 124 | * unavailable shadow snapshot. | ||
| 125 | * | ||
| 126 | * The `Encoded` parameter is intentionally left as `unknown` (not | ||
| 127 | * `Config`) so schemas with `withDecodingDefault` / transformations — where | ||
| 128 | * the encoded shape differs from the decoded shape — satisfy the SPI | ||
| 129 | * without casts. The registry only ever decodes `unknown` envelopes here, | ||
| 130 | * so the precise encoded type is irrelevant at this boundary. | ||
| 131 | * | ||
| 132 | * Using `Codec` rather than `Schema` pins `DecodingServices = never` — if | ||
| 133 | * we used `Schema<Config>`, the erased `any` in `AnyProviderDriver` would | ||
| 134 | * widen `DecodingServices` to `unknown` and poison the R channel of every | ||
| 135 | * caller of `decodeUnknownEffect`. | ||
| 136 | */ | ||
| 137 | readonly configSchema: Schema.Codec<Config, unknown>; | ||
| 138 | /** | ||
| 139 | * Default config payload used when the legacy | ||
| 140 | * `ServerSettings.providers.<kind>` entry is empty or when the driver | ||
| 141 | * is auto-bootstrapped without user configuration. Returning a typed | ||
| 142 | * default keeps the migration path simple — no special-casing needed | ||
| 143 | * to construct a "blank" instance. | ||
| 144 | */ | ||
| 145 | readonly defaultConfig: () => Config; | ||
| 146 | /** | ||
| 147 | * Materialize one instance. The returned effect runs in a scope owned | ||
| 148 | * by the registry; closing that scope releases every resource the | ||
| 149 | * driver opened. Failures become unavailable shadow snapshots — the | ||
| 150 | * driver MUST NOT throw defects. | ||
| 151 | */ | ||
| 152 | readonly create: ( | ||
| 153 | input: ProviderDriverCreateInput<Config>, | ||
| 154 | ) => Effect.Effect<ProviderInstance, ProviderDriverError, R | Scope.Scope>; | ||
| 155 | } | ||
| 156 | |||
| 157 | /** | ||
| 158 | * Heterogeneous-array convenience: the registry stores drivers as | ||
| 159 | * `ReadonlyArray<AnyProviderDriver<R>>` where `R` is the union of all | ||
| 160 | * registered drivers' env requirements. | ||
| 161 | */ | ||
| 162 | // `any` here intentionally erases the per-driver Config; the registry | ||
| 163 | // already decoded it before invoking `create`, so downstream code never | ||
| 164 | // needs the original `Config` type. Using `unknown` instead would force | ||
| 165 | // `create` callers into casts since `unknown` is not assignable to a | ||
| 166 | // concrete `Config` from inside the driver body. | ||
| 167 | export type AnyProviderDriver<R = never> = ProviderDriver<any, R>; | ||
apps/server/src/provider/ProviderInstanceEnvironment.test.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; | ||
| 4 | |||
| 5 | describe("mergeProviderInstanceEnvironment", () => { | ||
| 6 | it("overrides inherited environment values and preserves empty strings", () => { | ||
| 7 | expect( | ||
| 8 | mergeProviderInstanceEnvironment( | ||
| 9 | [ | ||
| 10 | { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, | ||
| 11 | { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, | ||
| 12 | ], | ||
| 13 | { ANTHROPIC_API_KEY: "inherited", PATH: "/bin" }, | ||
| 14 | ), | ||
| 15 | ).toMatchObject({ | ||
| 16 | OPENROUTER_API_KEY: "sk-or-test", | ||
| 17 | ANTHROPIC_API_KEY: "", | ||
| 18 | PATH: "/bin", | ||
| 19 | }); | ||
| 20 | }); | ||
| 21 | }); | ||
apps/server/src/provider/ProviderInstanceEnvironment.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export function mergeProviderInstanceEnvironment( | ||
| 4 | environment: ProviderInstanceEnvironment | undefined, | ||
| 5 | baseEnv: NodeJS.ProcessEnv = process.env, | ||
| 6 | ): NodeJS.ProcessEnv { | ||
| 7 | if (!environment || environment.length === 0) { | ||
| 8 | return baseEnv; | ||
| 9 | } | ||
| 10 | |||
| 11 | const next: NodeJS.ProcessEnv = { ...baseEnv }; | ||
| 12 | for (const variable of environment) { | ||
| 13 | next[variable.name] = variable.value; | ||
| 14 | } | ||
| 15 | return next; | ||
| 16 | } | ||
apps/server/src/provider/Services/ClaudeAdapter.ts+9-20| ... | @@ -1,30 +1,19 @@ | ... | @@ -1,30 +1,19 @@ |
| 1 | /** | 1 | /** |
| 2 | * ClaudeAdapter - Claude Agent implementation of the generic provider adapter contract. | 2 | * ClaudeAdapter — shape type for the Claude provider adapter. |
| 3 | * | 3 | * |
| 4 | * This service owns Claude runtime/session semantics and emits canonical | 4 | * Historically this module exposed a `Context.Service` tag so consumers |
| 5 | * provider runtime events. It does not perform cross-provider routing, shared | 5 | * could inject the adapter through the Effect layer graph. The driver |
| 6 | * event fan-out, or checkpoint orchestration. | 6 | * model ({@link ../Drivers/ClaudeDriver}) bundles one adapter per |
| 7 | * | 7 | * instance as a captured closure instead, so the tag is gone — we only |
| 8 | * Uses Effect `Context.Service` for dependency injection and returns the | 8 | * retain the shape interface as a naming anchor for the driver bundle. |
| 9 | * shared provider-adapter error channel with `provider: "claudeAgent"` context. | ||
| 10 | * | 9 | * |
| 11 | * @module ClaudeAdapter | 10 | * @module ClaudeAdapter |
| 12 | */ | 11 | */ |
| 13 | import { Context } from "effect"; | ||
| 14 | |||
| 15 | import type { ProviderAdapterError } from "../Errors.ts"; | 12 | import type { ProviderAdapterError } from "../Errors.ts"; |
| 16 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | 13 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; |
| 17 | 14 | ||
| 18 | /** | 15 | /** |
| 19 | * ClaudeAdapterShape - Service API for the Claude Agent provider adapter. | 16 | * ClaudeAdapterShape — per-instance Claude adapter contract. Carries |
| 20 | */ | 17 | * a branded driver kind as the nominal discriminant. |
| 21 | export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | ||
| 22 | readonly provider: "claudeAgent"; | ||
| 23 | } | ||
| 24 | |||
| 25 | /** | ||
| 26 | * ClaudeAdapter - Service tag for Claude Agent provider adapter operations. | ||
| 27 | */ | 18 | */ |
| 28 | export class ClaudeAdapter extends Context.Service<ClaudeAdapter, ClaudeAdapterShape>()( | 19 | export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {} |
| 29 | "t3/provider/Services/ClaudeAdapter", | ||
| 30 | ) {} |
apps/server/src/provider/Services/ClaudeProvider.ts deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | import { Context } from "effect"; | ||
| 2 | |||
| 3 | import type { ServerProviderShape } from "./ServerProvider.ts"; | ||
| 4 | |||
| 5 | export interface ClaudeProviderShape extends ServerProviderShape {} | ||
| 6 | |||
| 7 | export class ClaudeProvider extends Context.Service<ClaudeProvider, ClaudeProviderShape>()( | ||
| 8 | "t3/provider/Services/ClaudeProvider", | ||
| 9 | ) {} | ||
apps/server/src/provider/Services/CodexAdapter.ts+9-20| ... | @@ -1,30 +1,19 @@ | ... | @@ -1,30 +1,19 @@ |
| 1 | /** | 1 | /** |
| 2 | * CodexAdapter - Codex implementation of the generic provider adapter contract. | 2 | * CodexAdapter — shape type for the Codex provider adapter. |
| 3 | * | 3 | * |
| 4 | * This service owns Codex app-server process / JSON-RPC semantics and emits | 4 | * Historically this module exposed a `Context.Service` tag so consumers |
| 5 | * Codex provider events. It does not perform cross-provider routing, shared | 5 | * could inject the adapter through the Effect layer graph. The driver |
| 6 | * event fan-out, or checkpoint orchestration. | 6 | * model ({@link ../Drivers/CodexDriver}) bundles one adapter per |
| 7 | * | 7 | * instance as a captured closure instead, so the tag is gone — we only |
| 8 | * Uses Effect `Context.Service` for dependency injection and returns the | 8 | * retain the shape interface as a naming anchor for the driver bundle. |
| 9 | * shared provider-adapter error channel with `provider: "codex"` context. | ||
| 10 | * | 9 | * |
| 11 | * @module CodexAdapter | 10 | * @module CodexAdapter |
| 12 | */ | 11 | */ |
| 13 | import { Context } from "effect"; | ||
| 14 | |||
| 15 | import type { ProviderAdapterError } from "../Errors.ts"; | 12 | import type { ProviderAdapterError } from "../Errors.ts"; |
| 16 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | 13 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; |
| 17 | 14 | ||
| 18 | /** | 15 | /** |
| 19 | * CodexAdapterShape - Service API for the Codex provider adapter. | 16 | * CodexAdapterShape — per-instance Codex adapter contract. Carries |
| 20 | */ | 17 | * a branded driver kind as the nominal discriminant. |
| 21 | export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | ||
| 22 | readonly provider: "codex"; | ||
| 23 | } | ||
| 24 | |||
| 25 | /** | ||
| 26 | * CodexAdapter - Service tag for Codex provider adapter operations. | ||
| 27 | */ | 18 | */ |
| 28 | export class CodexAdapter extends Context.Service<CodexAdapter, CodexAdapterShape>()( | 19 | export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {} |
| 29 | "t3/provider/Services/CodexAdapter", | ||
| 30 | ) {} |
apps/server/src/provider/Services/CodexProvider.ts deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | import { Context } from "effect"; | ||
| 2 | |||
| 3 | import type { ServerProviderShape } from "./ServerProvider.ts"; | ||
| 4 | |||
| 5 | export interface CodexProviderShape extends ServerProviderShape {} | ||
| 6 | |||
| 7 | export class CodexProvider extends Context.Service<CodexProvider, CodexProviderShape>()( | ||
| 8 | "t3/provider/Services/CodexProvider", | ||
| 9 | ) {} | ||
apps/server/src/provider/Services/CursorAdapter.ts+16-9| ... | @@ -1,12 +1,19 @@ | ... | @@ -1,12 +1,19 @@ |
| 1 | import { Context } from "effect"; | 1 | /** |
| 2 | 2 | * CursorAdapter — shape type for the Cursor provider adapter. | |
| 3 | * | ||
| 4 | * Historically this module exposed a `Context.Service` tag so consumers | ||
| 5 | * could inject the adapter through the Effect layer graph. The driver | ||
| 6 | * model ({@link ../Drivers/CursorDriver}) bundles one adapter per | ||
| 7 | * instance as a captured closure instead, so the tag is gone — we only | ||
| 8 | * retain the shape interface as a naming anchor for the driver bundle. | ||
| 9 | * | ||
| 10 | * @module CursorAdapter | ||
| 11 | */ | ||
| 3 | import type { ProviderAdapterError } from "../Errors.ts"; | 12 | import type { ProviderAdapterError } from "../Errors.ts"; |
| 4 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | 13 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; |
| 5 | 14 | ||
| 6 | export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | 15 | /** |
| 7 | readonly provider: "cursor"; | 16 | * CursorAdapterShape — per-instance Cursor adapter contract. Carries |
| 8 | } | 17 | * a branded driver kind as the nominal discriminant. |
| 9 | 18 | */ | |
| 10 | export class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()( | 19 | export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {} |
| 11 | "t3/provider/Services/CursorAdapter", | ||
| 12 | ) {} |
apps/server/src/provider/Services/CursorProvider.ts deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | import { Context } from "effect"; | ||
| 2 | |||
| 3 | import type { ServerProviderShape } from "./ServerProvider.ts"; | ||
| 4 | |||
| 5 | export interface CursorProviderShape extends ServerProviderShape {} | ||
| 6 | |||
| 7 | export class CursorProvider extends Context.Service<CursorProvider, CursorProviderShape>()( | ||
| 8 | "t3/provider/Services/CursorProvider", | ||
| 9 | ) {} | ||
apps/server/src/provider/Services/OpenCodeAdapter.ts+16-9| ... | @@ -1,12 +1,19 @@ | ... | @@ -1,12 +1,19 @@ |
| 1 | import { Context } from "effect"; | 1 | /** |
| 2 | 2 | * OpenCodeAdapter — shape type for the OpenCode provider adapter. | |
| 3 | * | ||
| 4 | * Historically this module exposed a `Context.Service` tag so consumers | ||
| 5 | * could inject the adapter through the Effect layer graph. The driver | ||
| 6 | * model ({@link ../Drivers/OpenCodeDriver}) bundles one adapter per | ||
| 7 | * instance as a captured closure instead, so the tag is gone — we only | ||
| 8 | * retain the shape interface as a naming anchor for the driver bundle. | ||
| 9 | * | ||
| 10 | * @module OpenCodeAdapter | ||
| 11 | */ | ||
| 3 | import type { ProviderAdapterError } from "../Errors.ts"; | 12 | import type { ProviderAdapterError } from "../Errors.ts"; |
| 4 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | 13 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; |
| 5 | 14 | ||
| 6 | export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | 15 | /** |
| 7 | readonly provider: "opencode"; | 16 | * OpenCodeAdapterShape — per-instance OpenCode adapter contract. Carries |
| 8 | } | 17 | * a branded driver kind as the nominal discriminant. |
| 9 | 18 | */ | |
| 10 | export class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()( | 19 | export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {} |
| 11 | "t3/provider/Services/OpenCodeAdapter", | ||
| 12 | ) {} |
apps/server/src/provider/Services/OpenCodeProvider.ts deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | import { Context } from "effect"; | ||
| 2 | |||
| 3 | import type { ServerProviderShape } from "./ServerProvider.ts"; | ||
| 4 | |||
| 5 | export interface OpenCodeProviderShape extends ServerProviderShape {} | ||
| 6 | |||
| 7 | export class OpenCodeProvider extends Context.Service<OpenCodeProvider, OpenCodeProviderShape>()( | ||
| 8 | "t3/provider/Services/OpenCodeProvider", | ||
| 9 | ) {} | ||
apps/server/src/provider/Services/ProviderAdapter.ts+2-2| ... | @@ -10,7 +10,7 @@ | ... | @@ -10,7 +10,7 @@ |
| 10 | import type { | 10 | import type { |
| 11 | ApprovalRequestId, | 11 | ApprovalRequestId, |
| 12 | ProviderApprovalDecision, | 12 | ProviderApprovalDecision, |
| 13 | ProviderKind, | 13 | ProviderDriverKind, |
| 14 | ProviderUserInputAnswers, | 14 | ProviderUserInputAnswers, |
| 15 | ProviderRuntimeEvent, | 15 | ProviderRuntimeEvent, |
| 16 | ProviderSendTurnInput, | 16 | ProviderSendTurnInput, |
| ... | @@ -46,7 +46,7 @@ export interface ProviderAdapterShape<TError> { | ... | @@ -46,7 +46,7 @@ export interface ProviderAdapterShape<TError> { |
| 46 | /** | 46 | /** |
| 47 | * Provider kind implemented by this adapter. | 47 | * Provider kind implemented by this adapter. |
| 48 | */ | 48 | */ |
| 49 | readonly provider: ProviderKind; | 49 | readonly provider: ProviderDriverKind; |
| 50 | readonly capabilities: ProviderAdapterCapabilities; | 50 | readonly capabilities: ProviderAdapterCapabilities; |
| 51 | 51 | ||
| 52 | /** | 52 | /** |
apps/server/src/provider/Services/ProviderAdapterRegistry.ts+68-13| ... | @@ -1,34 +1,91 @@ | ... | @@ -1,34 +1,91 @@ |
| 1 | /** | 1 | /** |
| 2 | * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. | 2 | * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. |
| 3 | * | 3 | * |
| 4 | * Maps a provider kind to the concrete adapter service (Codex, Claude, etc). | 4 | * Maps a `ProviderInstanceId` (the new per-instance routing key) or a |
| 5 | * It does not own session lifecycle or routing rules; `ProviderService` uses | 5 | * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete |
| 6 | * this registry together with `ProviderSessionDirectory`. | 6 | * adapter service (Codex, Claude, etc). It does not own session lifecycle |
| 7 | * or routing rules; `ProviderService` uses this registry together with | ||
| 8 | * `ProviderSessionDirectory`. | ||
| 9 | * | ||
| 10 | * During the driver/instance migration this tag exposes both flavours: | ||
| 11 | * | ||
| 12 | * - `getByInstance` / `listInstances` — new per-instance routing. Callers | ||
| 13 | * that already know an `instanceId` (threads, sessions, events) | ||
| 14 | * should prefer these. | ||
| 15 | * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D | ||
| 16 | * behaviour. New code should not grow additional callers of the kind-keyed | ||
| 17 | * methods; they exist so the settings UI, WS refresh RPC, and a handful | ||
| 18 | * of legacy persisted rows can still be routed during the rollout. | ||
| 7 | * | 19 | * |
| 8 | * @module ProviderAdapterRegistry | 20 | * @module ProviderAdapterRegistry |
| 9 | */ | 21 | */ |
| 10 | import type { ProviderKind } from "@t3tools/contracts"; | 22 | import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; |
| 11 | import { Context } from "effect"; | 23 | import { Context } from "effect"; |
| 12 | import type { Effect } from "effect"; | 24 | import type { Effect, PubSub, Scope, Stream } from "effect"; |
| 13 | 25 | ||
| 14 | import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; | 26 | import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; |
| 15 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | 27 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; |
| 28 | import type { ProviderContinuationIdentity } from "../ProviderDriver.ts"; | ||
| 29 | |||
| 30 | export interface ProviderInstanceRoutingInfo { | ||
| 31 | readonly instanceId: ProviderInstanceId; | ||
| 32 | readonly driverKind: ProviderDriverKind; | ||
| 33 | readonly displayName: string | undefined; | ||
| 34 | readonly accentColor?: string | undefined; | ||
| 35 | readonly enabled: boolean; | ||
| 36 | readonly continuationIdentity: ProviderContinuationIdentity; | ||
| 37 | } | ||
| 16 | 38 | ||
| 17 | /** | 39 | /** |
| 18 | * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind. | 40 | * ProviderAdapterRegistryShape - Service API for adapter lookup. |
| 19 | */ | 41 | */ |
| 20 | export interface ProviderAdapterRegistryShape { | 42 | export interface ProviderAdapterRegistryShape { |
| 21 | /** | 43 | /** |
| 22 | * Resolve the adapter for a provider kind. | 44 | * Resolve the adapter for a specific instance id. Returns |
| 45 | * `ProviderUnsupportedError` if no such instance is currently registered | ||
| 46 | * (which covers "never configured" *and* "configured but the driver is | ||
| 47 | * unavailable in this build" — both surface the same failure to callers | ||
| 48 | * that expect a working adapter). | ||
| 23 | */ | 49 | */ |
| 24 | readonly getByProvider: ( | 50 | readonly getByInstance: ( |
| 25 | provider: ProviderKind, | 51 | instanceId: ProviderInstanceId, |
| 26 | ) => Effect.Effect<ProviderAdapterShape<ProviderAdapterError>, ProviderUnsupportedError>; | 52 | ) => Effect.Effect<ProviderAdapterShape<ProviderAdapterError>, ProviderUnsupportedError>; |
| 27 | 53 | ||
| 54 | readonly getInstanceInfo: ( | ||
| 55 | instanceId: ProviderInstanceId, | ||
| 56 | ) => Effect.Effect<ProviderInstanceRoutingInfo, ProviderUnsupportedError>; | ||
| 57 | |||
| 58 | /** | ||
| 59 | * List all live instance ids. Excludes unavailable/shadow instances — | ||
| 60 | * callers of this method want something they can pass to `getByInstance`. | ||
| 61 | */ | ||
| 62 | readonly listInstances: () => Effect.Effect<ReadonlyArray<ProviderInstanceId>>; | ||
| 63 | |||
| 28 | /** | 64 | /** |
| 29 | * List provider kinds currently registered. | 65 | * Legacy: list provider kinds whose default instance is currently |
| 66 | * registered. | ||
| 67 | * | ||
| 68 | * @deprecated Prefer `listInstances`. Retained for migration-era call | ||
| 69 | * sites that iterate providers to build UI/metrics. | ||
| 30 | */ | 70 | */ |
| 31 | readonly listProviders: () => Effect.Effect<ReadonlyArray<ProviderKind>>; | 71 | readonly listProviders: () => Effect.Effect<ReadonlyArray<ProviderDriverKind>>; |
| 72 | |||
| 73 | /** | ||
| 74 | * Change notification stream mirroring `ProviderInstanceRegistry.streamChanges`. | ||
| 75 | * Emits one `void` tick whenever the set of live instances changes | ||
| 76 | * (instance added, removed, or rebuilt after a settings edit). Consumers | ||
| 77 | * that fan out `adapter.streamEvents` per instance — e.g. `ProviderService`'s | ||
| 78 | * runtime event bus — re-pull `listInstances` on each tick and fork new | ||
| 79 | * subscriptions for instances they haven't seen yet. | ||
| 80 | */ | ||
| 81 | readonly streamChanges: Stream.Stream<void>; | ||
| 82 | |||
| 83 | /** | ||
| 84 | * Acquire a change subscription synchronously in the caller's current fiber. | ||
| 85 | * Consumers that must avoid missing a publish between initial reconciliation | ||
| 86 | * and watcher startup should use this, then fork `Stream.fromSubscription`. | ||
| 87 | */ | ||
| 88 | readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>; | ||
| 32 | } | 89 | } |
| 33 | 90 | ||
| 34 | /** | 91 | /** |
| ... | @@ -38,5 +95,3 @@ export class ProviderAdapterRegistry extends Context.Service< | ... | @@ -38,5 +95,3 @@ export class ProviderAdapterRegistry extends Context.Service< |
| 38 | ProviderAdapterRegistry, | 95 | ProviderAdapterRegistry, |
| 39 | ProviderAdapterRegistryShape | 96 | ProviderAdapterRegistryShape |
| 40 | >()("t3/provider/Services/ProviderAdapterRegistry") {} | 97 | >()("t3/provider/Services/ProviderAdapterRegistry") {} |
| 41 | |||
| 42 | // Dummy comment for workflow testing. |
apps/server/src/provider/Services/ProviderInstanceRegistry.ts created+84| ... | @@ -0,0 +1,84 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistry — the single Effect service in the new model. | ||
| 3 | * | ||
| 4 | * Owns a `Map<ProviderInstanceId, ProviderInstance>` produced by running | ||
| 5 | * registered driver factories against `ServerSettings.providerInstances`. | ||
| 6 | * The registry watches settings; when an instance's config changes (or | ||
| 7 | * the entry disappears), the registry tears down the affected instance's | ||
| 8 | * scope and rebuilds — that's the entire hot-reload story. | ||
| 9 | * | ||
| 10 | * What rest-of-server reads from here: | ||
| 11 | * - `getInstance(instanceId)` — for routing turn/session calls. | ||
| 12 | * - `listInstances` — for snapshot aggregation in `ProviderRegistry`. | ||
| 13 | * - `listUnavailable` — `ServerProvider` shadows for instances whose | ||
| 14 | * driver is not registered in this build (rollback / fork tolerance). | ||
| 15 | * - `streamChanges` — coalesced "registry mutated" pings so consumers | ||
| 16 | * can re-pull lists or re-broadcast. | ||
| 17 | * | ||
| 18 | * @module provider/Services/ProviderInstanceRegistry | ||
| 19 | */ | ||
| 20 | import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; | ||
| 21 | import { Context } from "effect"; | ||
| 22 | import type { Effect, PubSub, Scope, Stream } from "effect"; | ||
| 23 | |||
| 24 | import type { ProviderInstance } from "../ProviderDriver.ts"; | ||
| 25 | |||
| 26 | export interface ProviderInstanceRegistryShape { | ||
| 27 | /** | ||
| 28 | * Look up one instance by id. Returns `undefined` (not Option) when the | ||
| 29 | * id is unknown — callers branch on falsy and emit | ||
| 30 | * `ProviderInstanceNotFoundError`. | ||
| 31 | */ | ||
| 32 | readonly getInstance: ( | ||
| 33 | instanceId: ProviderInstanceId, | ||
| 34 | ) => Effect.Effect<ProviderInstance | undefined>; | ||
| 35 | /** | ||
| 36 | * Every available (driver-registered, successfully created) instance, | ||
| 37 | * in stable settings-author order. | ||
| 38 | */ | ||
| 39 | readonly listInstances: Effect.Effect<ReadonlyArray<ProviderInstance>>; | ||
| 40 | /** | ||
| 41 | * Wire-shape shadow snapshots for instances whose driver is unknown to | ||
| 42 | * this build (or whose config failed to decode). Suitable for merging | ||
| 43 | * directly into `ProviderRegistry` output. | ||
| 44 | */ | ||
| 45 | readonly listUnavailable: Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 46 | /** | ||
| 47 | * Push notification stream emitted whenever the registry's contents | ||
| 48 | * change — instance added, removed, or rebuilt. The payload is `void` | ||
| 49 | * because consumers always want to re-pull `listInstances` / | ||
| 50 | * `listUnavailable` together. | ||
| 51 | * | ||
| 52 | * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the | ||
| 53 | * stream starts running, forking a consumer via | ||
| 54 | * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next | ||
| 55 | * publish — the forked fiber may not have subscribed yet when the | ||
| 56 | * publish lands. Hot-reload consumers that must not miss a publish | ||
| 57 | * should use `subscribeChanges` below instead, which acquires the | ||
| 58 | * subscription synchronously in the caller's fiber before the consumer | ||
| 59 | * loop is forked. | ||
| 60 | */ | ||
| 61 | readonly streamChanges: Stream.Stream<void>; | ||
| 62 | /** | ||
| 63 | * Acquire a subscription to the registry's change channel synchronously | ||
| 64 | * in the caller's fiber. Returns a `PubSub.Subscription<void>` whose | ||
| 65 | * lifetime is scoped to the provided `Scope` (the subscription is | ||
| 66 | * released when the scope closes). Consumers typically `yield*` this | ||
| 67 | * in the same fiber that forks their consumer loop, then drain with | ||
| 68 | * `PubSub.take(subscription)` inside `Effect.forever`. Because the | ||
| 69 | * subscription is registered with the PubSub before this `yield*` | ||
| 70 | * returns, no subsequent publish can land in a gap. | ||
| 71 | * | ||
| 72 | * This exists because the `ProviderInstanceRegistry` publishes on a | ||
| 73 | * PubSub and `Stream.fromPubSub` defers subscription until the stream | ||
| 74 | * starts executing — a consumer that `forkScoped`s the stream | ||
| 75 | * consumption can miss a publish that lands in the narrow window | ||
| 76 | * between "fiber scheduled" and "fiber starts running". | ||
| 77 | */ | ||
| 78 | readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>; | ||
| 79 | } | ||
| 80 | |||
| 81 | export class ProviderInstanceRegistry extends Context.Service< | ||
| 82 | ProviderInstanceRegistry, | ||
| 83 | ProviderInstanceRegistryShape | ||
| 84 | >()("t3/provider/Services/ProviderInstanceRegistry") {} | ||
apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryMutator — internal handle used by the hydration | ||
| 3 | * layer to reconcile the live registry with a fresh | ||
| 4 | * `ProviderInstanceConfigMap`. | ||
| 5 | * | ||
| 6 | * Kept separate from the public `ProviderInstanceRegistry` service tag so | ||
| 7 | * downstream consumers (drivers, reactors, `ProviderService`) can only read | ||
| 8 | * from the registry. Only the hydration layer — which watches | ||
| 9 | * `ServerSettingsService.streamChanges` and applies diffs — imports this | ||
| 10 | * tag. | ||
| 11 | * | ||
| 12 | * The mutator exposes a single entry point, `reconcile(configMap)`, which: | ||
| 13 | * | ||
| 14 | * 1. Diffs the incoming map against the live one keyed by instance id. | ||
| 15 | * 2. Closes the per-instance `Scope` of every removed or replaced entry | ||
| 16 | * (tearing down adapter processes, refresh fibres, temp files) BEFORE | ||
| 17 | * creating the replacement — `reconcile` guarantees "at most one live | ||
| 18 | * instance per id" at all times. | ||
| 19 | * 3. Opens a fresh child `Scope` for every added or replaced entry, runs | ||
| 20 | * the driver's `create`, and stores the resulting `ProviderInstance` | ||
| 21 | * plus its scope. | ||
| 22 | * 4. Publishes one `void` tick on the registry's `streamChanges` PubSub at | ||
| 23 | * the end of the batch — consumers re-pull `listInstances` / | ||
| 24 | * `listUnavailable`. | ||
| 25 | * | ||
| 26 | * `reconcile` is idempotent: calling it with an unchanged config map is a | ||
| 27 | * no-op (no scope churn, no pubsub emission). | ||
| 28 | * | ||
| 29 | * @module provider/Services/ProviderInstanceRegistryMutator | ||
| 30 | */ | ||
| 31 | import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; | ||
| 32 | import { Context } from "effect"; | ||
| 33 | import type { Effect } from "effect"; | ||
| 34 | |||
| 35 | export interface ProviderInstanceRegistryMutatorShape { | ||
| 36 | /** | ||
| 37 | * Bring the live registry in line with the supplied config map. See | ||
| 38 | * module docs for the add / remove / replace semantics. | ||
| 39 | * | ||
| 40 | * The effect never fails: individual driver `create` failures are | ||
| 41 | * captured as "unavailable" shadow snapshots inside the registry, the | ||
| 42 | * same way boot-time failures are handled by | ||
| 43 | * `makeProviderInstanceRegistry`. This keeps settings-watcher loops from | ||
| 44 | * erroring out on a single bad entry. | ||
| 45 | */ | ||
| 46 | readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect<void>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export class ProviderInstanceRegistryMutator extends Context.Service< | ||
| 50 | ProviderInstanceRegistryMutator, | ||
| 51 | ProviderInstanceRegistryMutatorShape | ||
| 52 | >()("t3/provider/Services/ProviderInstanceRegistryMutator") {} | ||
apps/server/src/provider/Services/ProviderRegistry.ts+24-5| ... | @@ -6,23 +6,42 @@ | ... | @@ -6,23 +6,42 @@ |
| 6 | * | 6 | * |
| 7 | * @module ProviderRegistry | 7 | * @module ProviderRegistry |
| 8 | */ | 8 | */ |
| 9 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | 9 | import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; |
| 10 | import { Context } from "effect"; | 10 | import { Context } from "effect"; |
| 11 | import type { Effect, Stream } from "effect"; | 11 | import type { Effect, Stream } from "effect"; |
| 12 | 12 | ||
| 13 | export interface ProviderRegistryShape { | 13 | export interface ProviderRegistryShape { |
| 14 | /** | 14 | /** |
| 15 | * Read the latest provider snapshots. | 15 | * Read the latest provider snapshots for every configured instance. |
| 16 | * Multiple snapshots may share the same `provider` kind (multiple | ||
| 17 | * instances of the same driver) and disambiguate via `instanceId`. | ||
| 16 | */ | 18 | */ |
| 17 | readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>; | 19 | readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>; |
| 18 | 20 | ||
| 19 | /** | 21 | /** |
| 20 | * Refresh all providers, or a single provider when specified. | 22 | * Refresh all providers, or the default instance of the specified |
| 23 | * kind when supplied. | ||
| 24 | * | ||
| 25 | * Retained for back-compat with legacy call sites (WS refresh RPC, | ||
| 26 | * orchestration metrics). New code should prefer `refreshInstance`. | ||
| 27 | * | ||
| 28 | * @deprecated prefer `refreshInstance` for new call sites. | ||
| 21 | */ | 29 | */ |
| 22 | readonly refresh: (provider?: ProviderKind) => Effect.Effect<ReadonlyArray<ServerProvider>>; | 30 | readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect<ReadonlyArray<ServerProvider>>; |
| 23 | 31 | ||
| 24 | /** | 32 | /** |
| 25 | * Stream of provider snapshot updates. | 33 | * Refresh the specific configured instance. Returns the updated snapshot |
| 34 | * list. When the instance id is unknown the call resolves with the | ||
| 35 | * currently cached list (no error) — matching the legacy `refresh` shim | ||
| 36 | * behaviour so transport layers don't have to special-case unknowns. | ||
| 37 | */ | ||
| 38 | readonly refreshInstance: ( | ||
| 39 | instanceId: ProviderInstanceId, | ||
| 40 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 41 | |||
| 42 | /** | ||
| 43 | * Stream of provider snapshot updates — one emission per aggregated | ||
| 44 | * change. The array contains the full current state. | ||
| 26 | */ | 45 | */ |
| 27 | readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>; | 46 | readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>; |
| 28 | } | 47 | } |
apps/server/src/provider/Services/ProviderService.ts+8-3| ... | @@ -13,7 +13,7 @@ | ... | @@ -13,7 +13,7 @@ |
| 13 | */ | 13 | */ |
| 14 | import type { | 14 | import type { |
| 15 | ProviderInterruptTurnInput, | 15 | ProviderInterruptTurnInput, |
| 16 | ProviderKind, | 16 | ProviderInstanceId, |
| 17 | ProviderRespondToRequestInput, | 17 | ProviderRespondToRequestInput, |
| 18 | ProviderRespondToUserInputInput, | 18 | ProviderRespondToUserInputInput, |
| 19 | ProviderRuntimeEvent, | 19 | ProviderRuntimeEvent, |
| ... | @@ -29,6 +29,7 @@ import type { Effect, Stream } from "effect"; | ... | @@ -29,6 +29,7 @@ import type { Effect, Stream } from "effect"; |
| 29 | 29 | ||
| 30 | import type { ProviderServiceError } from "../Errors.ts"; | 30 | import type { ProviderServiceError } from "../Errors.ts"; |
| 31 | import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; | 31 | import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; |
| 32 | import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; | ||
| 32 | 33 | ||
| 33 | /** | 34 | /** |
| 34 | * ProviderServiceShape - Service API for provider session and turn orchestration. | 35 | * ProviderServiceShape - Service API for provider session and turn orchestration. |
| ... | @@ -85,12 +86,16 @@ export interface ProviderServiceShape { | ... | @@ -85,12 +86,16 @@ export interface ProviderServiceShape { |
| 85 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; | 86 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; |
| 86 | 87 | ||
| 87 | /** | 88 | /** |
| 88 | * Read static capabilities for a provider adapter. | 89 | * Read capabilities for the adapter bound to a configured provider instance. |
| 89 | */ | 90 | */ |
| 90 | readonly getCapabilities: ( | 91 | readonly getCapabilities: ( |
| 91 | provider: ProviderKind, | 92 | instanceId: ProviderInstanceId, |
| 92 | ) => Effect.Effect<ProviderAdapterCapabilities, ProviderServiceError>; | 93 | ) => Effect.Effect<ProviderAdapterCapabilities, ProviderServiceError>; |
| 93 | 94 | ||
| 95 | readonly getInstanceInfo: ( | ||
| 96 | instanceId: ProviderInstanceId, | ||
| 97 | ) => Effect.Effect<ProviderInstanceRoutingInfo, ProviderServiceError>; | ||
| 98 | |||
| 94 | /** | 99 | /** |
| 95 | * Roll back provider conversation state by a number of turns. | 100 | * Roll back provider conversation state by a number of turns. |
| 96 | */ | 101 | */ |
apps/server/src/provider/Services/ProviderSessionDirectory.ts+10-3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ProviderKind, | 2 | ProviderInstanceId, |
| 3 | ProviderDriverKind, | ||
| 3 | ProviderSessionRuntimeStatus, | 4 | ProviderSessionRuntimeStatus, |
| 4 | RuntimeMode, | 5 | RuntimeMode, |
| 5 | ThreadId, | 6 | ThreadId, |
| ... | @@ -14,7 +15,13 @@ import type { | ... | @@ -14,7 +15,13 @@ import type { |
| 14 | 15 | ||
| 15 | export interface ProviderRuntimeBinding { | 16 | export interface ProviderRuntimeBinding { |
| 16 | readonly threadId: ThreadId; | 17 | readonly threadId: ThreadId; |
| 17 | readonly provider: ProviderKind; | 18 | readonly provider: ProviderDriverKind; |
| 19 | /** | ||
| 20 | * Routing key for the configured provider instance that owns this | ||
| 21 | * session. The persistence layer promotes legacy null rows before | ||
| 22 | * exposing bindings; runtime callers must not infer this from `provider`. | ||
| 23 | */ | ||
| 24 | readonly providerInstanceId?: ProviderInstanceId; | ||
| 18 | readonly adapterKey?: string; | 25 | readonly adapterKey?: string; |
| 19 | readonly status?: ProviderSessionRuntimeStatus; | 26 | readonly status?: ProviderSessionRuntimeStatus; |
| 20 | readonly resumeCursor?: unknown | null; | 27 | readonly resumeCursor?: unknown | null; |
| ... | @@ -39,7 +46,7 @@ export interface ProviderSessionDirectoryShape { | ... | @@ -39,7 +46,7 @@ export interface ProviderSessionDirectoryShape { |
| 39 | 46 | ||
| 40 | readonly getProvider: ( | 47 | readonly getProvider: ( |
| 41 | threadId: ThreadId, | 48 | threadId: ThreadId, |
| 42 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; | 49 | ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>; |
| 43 | 50 | ||
| 44 | readonly getBinding: ( | 51 | readonly getBinding: ( |
| 45 | threadId: ThreadId, | 52 | threadId: ThreadId, |
apps/server/src/provider/acp/AcpAdapterSupport.test.ts+2-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import * as EffectAcpErrors from "effect-acp/errors"; | 2 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 3 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 3 | 4 | ||
| 4 | import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; | 5 | import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; |
| 5 | 6 | ||
| ... | @@ -12,7 +13,7 @@ describe("AcpAdapterSupport", () => { | ... | @@ -12,7 +13,7 @@ describe("AcpAdapterSupport", () => { |
| 12 | 13 | ||
| 13 | it("maps ACP request errors to provider adapter request errors", () => { | 14 | it("maps ACP request errors to provider adapter request errors", () => { |
| 14 | const error = mapAcpToAdapterError( | 15 | const error = mapAcpToAdapterError( |
| 15 | "cursor", | 16 | ProviderDriverKind.make("cursor"), |
| 16 | "thread-1" as never, | 17 | "thread-1" as never, |
| 17 | "session/prompt", | 18 | "session/prompt", |
| 18 | new EffectAcpErrors.AcpRequestError({ | 19 | new EffectAcpErrors.AcpRequestError({ |
apps/server/src/provider/acp/AcpAdapterSupport.ts+2-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderApprovalDecision, | 2 | type ProviderApprovalDecision, |
| 3 | type ProviderKind, | 3 | type ProviderDriverKind, |
| 4 | type ThreadId, | 4 | type ThreadId, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { Schema } from "effect"; | 6 | import { Schema } from "effect"; |
| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | } from "../Errors.ts"; | 13 | } from "../Errors.ts"; |
| 14 | 14 | ||
| 15 | export function mapAcpToAdapterError( | 15 | export function mapAcpToAdapterError( |
| 16 | provider: ProviderKind, | 16 | provider: ProviderDriverKind, |
| 17 | threadId: ThreadId, | 17 | threadId: ThreadId, |
| 18 | method: string, | 18 | method: string, |
| 19 | error: EffectAcpErrors.AcpError, | 19 | error: EffectAcpErrors.AcpError, |
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts+7-7| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -30,7 +30,7 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -30,7 +30,7 @@ describe("AcpCoreRuntimeEvents", () => { |
| 30 | expect( | 30 | expect( |
| 31 | makeAcpRequestOpenedEvent({ | 31 | makeAcpRequestOpenedEvent({ |
| 32 | stamp, | 32 | stamp, |
| 33 | provider: "cursor", | 33 | provider: ProviderDriverKind.make("cursor"), |
| 34 | threadId: "thread-1" as never, | 34 | threadId: "thread-1" as never, |
| 35 | turnId, | 35 | turnId, |
| 36 | requestId: RuntimeRequestId.make("request-1"), | 36 | requestId: RuntimeRequestId.make("request-1"), |
| ... | @@ -52,7 +52,7 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -52,7 +52,7 @@ describe("AcpCoreRuntimeEvents", () => { |
| 52 | expect( | 52 | expect( |
| 53 | makeAcpRequestResolvedEvent({ | 53 | makeAcpRequestResolvedEvent({ |
| 54 | stamp, | 54 | stamp, |
| 55 | provider: "cursor", | 55 | provider: ProviderDriverKind.make("cursor"), |
| 56 | threadId: "thread-1" as never, | 56 | threadId: "thread-1" as never, |
| 57 | turnId, | 57 | turnId, |
| 58 | requestId: RuntimeRequestId.make("request-1"), | 58 | requestId: RuntimeRequestId.make("request-1"), |
| ... | @@ -75,7 +75,7 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -75,7 +75,7 @@ describe("AcpCoreRuntimeEvents", () => { |
| 75 | expect( | 75 | expect( |
| 76 | makeAcpPlanUpdatedEvent({ | 76 | makeAcpPlanUpdatedEvent({ |
| 77 | stamp, | 77 | stamp, |
| 78 | provider: "cursor", | 78 | provider: ProviderDriverKind.make("cursor"), |
| 79 | threadId: "thread-1" as never, | 79 | threadId: "thread-1" as never, |
| 80 | turnId, | 80 | turnId, |
| 81 | payload: { | 81 | payload: { |
| ... | @@ -95,7 +95,7 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -95,7 +95,7 @@ describe("AcpCoreRuntimeEvents", () => { |
| 95 | expect( | 95 | expect( |
| 96 | makeAcpToolCallEvent({ | 96 | makeAcpToolCallEvent({ |
| 97 | stamp, | 97 | stamp, |
| 98 | provider: "cursor", | 98 | provider: ProviderDriverKind.make("cursor"), |
| 99 | threadId: "thread-1" as never, | 99 | threadId: "thread-1" as never, |
| 100 | turnId, | 100 | turnId, |
| 101 | toolCall: { | 101 | toolCall: { |
| ... | @@ -119,7 +119,7 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -119,7 +119,7 @@ describe("AcpCoreRuntimeEvents", () => { |
| 119 | expect( | 119 | expect( |
| 120 | makeAcpContentDeltaEvent({ | 120 | makeAcpContentDeltaEvent({ |
| 121 | stamp, | 121 | stamp, |
| 122 | provider: "cursor", | 122 | provider: ProviderDriverKind.make("cursor"), |
| 123 | threadId: "thread-1" as never, | 123 | threadId: "thread-1" as never, |
| 124 | turnId, | 124 | turnId, |
| 125 | itemId: "assistant:session-1:segment:0", | 125 | itemId: "assistant:session-1:segment:0", |
| ... | @@ -137,7 +137,7 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -137,7 +137,7 @@ describe("AcpCoreRuntimeEvents", () => { |
| 137 | expect( | 137 | expect( |
| 138 | makeAcpAssistantItemEvent({ | 138 | makeAcpAssistantItemEvent({ |
| 139 | stamp, | 139 | stamp, |
| 140 | provider: "cursor", | 140 | provider: ProviderDriverKind.make("cursor"), |
| 141 | threadId: "thread-1" as never, | 141 | threadId: "thread-1" as never, |
| 142 | turnId, | 142 | turnId, |
| 143 | itemId: "assistant:session-1:segment:0", | 143 | itemId: "assistant:session-1:segment:0", |
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts+7-7| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | type CanonicalRequestType, | 4 | type CanonicalRequestType, |
| 5 | type EventId, | 5 | type EventId, |
| 6 | type ProviderApprovalDecision, | 6 | type ProviderApprovalDecision, |
| 7 | type ProviderKind, | 7 | type ProviderDriverKind, |
| 8 | type ProviderRuntimeEvent, | 8 | type ProviderRuntimeEvent, |
| 9 | type RuntimeRequestId, | 9 | type RuntimeRequestId, |
| 10 | type ThreadId, | 10 | type ThreadId, |
| ... | @@ -78,7 +78,7 @@ function runtimeItemStatusFromAcpToolStatus( | ... | @@ -78,7 +78,7 @@ function runtimeItemStatusFromAcpToolStatus( |
| 78 | 78 | ||
| 79 | export function makeAcpRequestOpenedEvent(input: { | 79 | export function makeAcpRequestOpenedEvent(input: { |
| 80 | readonly stamp: AcpEventStamp; | 80 | readonly stamp: AcpEventStamp; |
| 81 | readonly provider: ProviderKind; | 81 | readonly provider: ProviderDriverKind; |
| 82 | readonly threadId: ThreadId; | 82 | readonly threadId: ThreadId; |
| 83 | readonly turnId: TurnId | undefined; | 83 | readonly turnId: TurnId | undefined; |
| 84 | readonly requestId: RuntimeRequestId; | 84 | readonly requestId: RuntimeRequestId; |
| ... | @@ -111,7 +111,7 @@ export function makeAcpRequestOpenedEvent(input: { | ... | @@ -111,7 +111,7 @@ export function makeAcpRequestOpenedEvent(input: { |
| 111 | 111 | ||
| 112 | export function makeAcpRequestResolvedEvent(input: { | 112 | export function makeAcpRequestResolvedEvent(input: { |
| 113 | readonly stamp: AcpEventStamp; | 113 | readonly stamp: AcpEventStamp; |
| 114 | readonly provider: ProviderKind; | 114 | readonly provider: ProviderDriverKind; |
| 115 | readonly threadId: ThreadId; | 115 | readonly threadId: ThreadId; |
| 116 | readonly turnId: TurnId | undefined; | 116 | readonly turnId: TurnId | undefined; |
| 117 | readonly requestId: RuntimeRequestId; | 117 | readonly requestId: RuntimeRequestId; |
| ... | @@ -134,7 +134,7 @@ export function makeAcpRequestResolvedEvent(input: { | ... | @@ -134,7 +134,7 @@ export function makeAcpRequestResolvedEvent(input: { |
| 134 | 134 | ||
| 135 | export function makeAcpPlanUpdatedEvent(input: { | 135 | export function makeAcpPlanUpdatedEvent(input: { |
| 136 | readonly stamp: AcpEventStamp; | 136 | readonly stamp: AcpEventStamp; |
| 137 | readonly provider: ProviderKind; | 137 | readonly provider: ProviderDriverKind; |
| 138 | readonly threadId: ThreadId; | 138 | readonly threadId: ThreadId; |
| 139 | readonly turnId: TurnId | undefined; | 139 | readonly turnId: TurnId | undefined; |
| 140 | readonly payload: AcpPlanUpdate; | 140 | readonly payload: AcpPlanUpdate; |
| ... | @@ -159,7 +159,7 @@ export function makeAcpPlanUpdatedEvent(input: { | ... | @@ -159,7 +159,7 @@ export function makeAcpPlanUpdatedEvent(input: { |
| 159 | 159 | ||
| 160 | export function makeAcpToolCallEvent(input: { | 160 | export function makeAcpToolCallEvent(input: { |
| 161 | readonly stamp: AcpEventStamp; | 161 | readonly stamp: AcpEventStamp; |
| 162 | readonly provider: ProviderKind; | 162 | readonly provider: ProviderDriverKind; |
| 163 | readonly threadId: ThreadId; | 163 | readonly threadId: ThreadId; |
| 164 | readonly turnId: TurnId | undefined; | 164 | readonly turnId: TurnId | undefined; |
| 165 | readonly toolCall: AcpToolCallState; | 165 | readonly toolCall: AcpToolCallState; |
| ... | @@ -193,7 +193,7 @@ export function makeAcpToolCallEvent(input: { | ... | @@ -193,7 +193,7 @@ export function makeAcpToolCallEvent(input: { |
| 193 | 193 | ||
| 194 | export function makeAcpAssistantItemEvent(input: { | 194 | export function makeAcpAssistantItemEvent(input: { |
| 195 | readonly stamp: AcpEventStamp; | 195 | readonly stamp: AcpEventStamp; |
| 196 | readonly provider: ProviderKind; | 196 | readonly provider: ProviderDriverKind; |
| 197 | readonly threadId: ThreadId; | 197 | readonly threadId: ThreadId; |
| 198 | readonly turnId: TurnId | undefined; | 198 | readonly turnId: TurnId | undefined; |
| 199 | readonly itemId: string; | 199 | readonly itemId: string; |
| ... | @@ -215,7 +215,7 @@ export function makeAcpAssistantItemEvent(input: { | ... | @@ -215,7 +215,7 @@ export function makeAcpAssistantItemEvent(input: { |
| 215 | 215 | ||
| 216 | export function makeAcpContentDeltaEvent(input: { | 216 | export function makeAcpContentDeltaEvent(input: { |
| 217 | readonly stamp: AcpEventStamp; | 217 | readonly stamp: AcpEventStamp; |
| 218 | readonly provider: ProviderKind; | 218 | readonly provider: ProviderDriverKind; |
| 219 | readonly threadId: ThreadId; | 219 | readonly threadId: ThreadId; |
| 220 | readonly turnId: TurnId | undefined; | 220 | readonly turnId: TurnId | undefined; |
| 221 | readonly itemId?: string; | 221 | readonly itemId?: string; |
apps/server/src/provider/acp/AcpNativeLogging.ts+3-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ProviderKind, ThreadId } from "@t3tools/contracts"; | 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; |
| 2 | import { Cause, Effect } from "effect"; | 2 | import { Cause, Effect } from "effect"; |
| 3 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 3 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 4 | 4 | ||
| ... | @@ -7,7 +7,7 @@ import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpS | ... | @@ -7,7 +7,7 @@ import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpS |
| 7 | 7 | ||
| 8 | function writeNativeAcpLog(input: { | 8 | function writeNativeAcpLog(input: { |
| 9 | readonly nativeEventLogger: EventNdjsonLogger | undefined; | 9 | readonly nativeEventLogger: EventNdjsonLogger | undefined; |
| 10 | readonly provider: ProviderKind; | 10 | readonly provider: ProviderDriverKind; |
| 11 | readonly threadId: ThreadId; | 11 | readonly threadId: ThreadId; |
| 12 | readonly kind: "request" | "protocol"; | 12 | readonly kind: "request" | "protocol"; |
| 13 | readonly payload: unknown; | 13 | readonly payload: unknown; |
| ... | @@ -44,7 +44,7 @@ function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { | ... | @@ -44,7 +44,7 @@ function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { |
| 44 | 44 | ||
| 45 | export function makeAcpNativeLoggers(input: { | 45 | export function makeAcpNativeLoggers(input: { |
| 46 | readonly nativeEventLogger: EventNdjsonLogger | undefined; | 46 | readonly nativeEventLogger: EventNdjsonLogger | undefined; |
| 47 | readonly provider: ProviderKind; | 47 | readonly provider: ProviderDriverKind; |
| 48 | readonly threadId: ThreadId; | 48 | readonly threadId: ThreadId; |
| 49 | }): Pick<AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> { | 49 | }): Pick<AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> { |
| 50 | return { | 50 | return { |
apps/server/src/provider/acp/AcpSessionRuntime.ts+1-1| ... | @@ -21,7 +21,7 @@ export interface AcpSpawnInput { | ... | @@ -21,7 +21,7 @@ export interface AcpSpawnInput { |
| 21 | readonly command: string; | 21 | readonly command: string; |
| 22 | readonly args: ReadonlyArray<string>; | 22 | readonly args: ReadonlyArray<string>; |
| 23 | readonly cwd?: string; | 23 | readonly cwd?: string; |
| 24 | readonly env?: Readonly<Record<string, string>>; | 24 | readonly env?: NodeJS.ProcessEnv; |
| 25 | } | 25 | } |
| 26 | 26 | ||
| 27 | export interface AcpSessionRuntimeOptions { | 27 | export interface AcpSessionRuntimeOptions { |
apps/server/src/provider/acp/CursorAcpSupport.ts+4-1| ... | @@ -23,6 +23,7 @@ export interface CursorAcpRuntimeInput extends Omit< | ... | @@ -23,6 +23,7 @@ export interface CursorAcpRuntimeInput extends Omit< |
| 23 | > { | 23 | > { |
| 24 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | 24 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 25 | readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; | 25 | readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; |
| 26 | readonly environment?: NodeJS.ProcessEnv; | ||
| 26 | } | 27 | } |
| 27 | 28 | ||
| 28 | export interface CursorAcpModelSelectionErrorContext { | 29 | export interface CursorAcpModelSelectionErrorContext { |
| ... | @@ -34,6 +35,7 @@ export interface CursorAcpModelSelectionErrorContext { | ... | @@ -34,6 +35,7 @@ export interface CursorAcpModelSelectionErrorContext { |
| 34 | export function buildCursorAcpSpawnInput( | 35 | export function buildCursorAcpSpawnInput( |
| 35 | cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, | 36 | cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, |
| 36 | cwd: string, | 37 | cwd: string, |
| 38 | environment?: NodeJS.ProcessEnv, | ||
| 37 | ): AcpSpawnInput { | 39 | ): AcpSpawnInput { |
| 38 | return { | 40 | return { |
| 39 | command: cursorSettings?.binaryPath || "agent", | 41 | command: cursorSettings?.binaryPath || "agent", |
| ... | @@ -42,6 +44,7 @@ export function buildCursorAcpSpawnInput( | ... | @@ -42,6 +44,7 @@ export function buildCursorAcpSpawnInput( |
| 42 | "acp", | 44 | "acp", |
| 43 | ], | 45 | ], |
| 44 | cwd, | 46 | cwd, |
| 47 | ...(environment ? { env: environment } : {}), | ||
| 45 | }; | 48 | }; |
| 46 | } | 49 | } |
| 47 | 50 | ||
| ... | @@ -52,7 +55,7 @@ export const makeCursorAcpRuntime = ( | ... | @@ -52,7 +55,7 @@ export const makeCursorAcpRuntime = ( |
| 52 | const acpContext = yield* Layer.build( | 55 | const acpContext = yield* Layer.build( |
| 53 | AcpSessionRuntime.layer({ | 56 | AcpSessionRuntime.layer({ |
| 54 | ...input, | 57 | ...input, |
| 55 | spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd), | 58 | spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), |
| 56 | authMethodId: "cursor_login", | 59 | authMethodId: "cursor_login", |
| 57 | clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, | 60 | clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, |
| 58 | }).pipe( | 61 | }).pipe( |
apps/server/src/provider/builtInDrivers.ts created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | /** | ||
| 2 | * BUILT_IN_DRIVERS — the static set of `ProviderDriver`s this build ships | ||
| 3 | * with. | ||
| 4 | * | ||
| 5 | * Every driver that the server knows how to instantiate from settings is | ||
| 6 | * listed here. The `ProviderInstanceRegistry` iterates this array when | ||
| 7 | * resolving `providerInstances` entries; anything not in the array surfaces | ||
| 8 | * as an `"unavailable"` shadow snapshot at runtime (see | ||
| 9 | * `buildUnavailableProviderSnapshot`). | ||
| 10 | * | ||
| 11 | * Adding a new first-party driver means: | ||
| 12 | * 1. implement `ProviderDriver` in a sibling `Drivers/<Name>Driver.ts`, | ||
| 13 | * 2. add it to this array, | ||
| 14 | * 3. ensure the runtime layer satisfies its declared `R`. | ||
| 15 | * | ||
| 16 | * The aggregated `BuiltInDriversEnv` type is the union of every driver's | ||
| 17 | * env requirement — the registry layer's `R` is this type, and the runtime | ||
| 18 | * layer (ChildProcessSpawner, FileSystem, Path, ServerConfig, | ||
| 19 | * OpenCodeRuntime, …) must satisfy it. | ||
| 20 | * | ||
| 21 | * @module provider/builtInDrivers | ||
| 22 | */ | ||
| 23 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; | ||
| 24 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; | ||
| 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | ||
| 26 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | ||
| 27 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Union of infrastructure services required to construct any built-in | ||
| 31 | * driver. The registry layer declares `R = BuiltInDriversEnv`; the runtime | ||
| 32 | * layer must provide every service in this union. | ||
| 33 | */ | ||
| 34 | export type BuiltInDriversEnv = | ||
| 35 | | ClaudeDriverEnv | ||
| 36 | | CodexDriverEnv | ||
| 37 | | CursorDriverEnv | ||
| 38 | | OpenCodeDriverEnv; | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Ordered list of built-in drivers. Order matters only for tie-breaking in | ||
| 42 | * UI presentation — the registry itself is keyed by `driverKind`, so | ||
| 43 | * iteration order has no functional effect on instance lookup. | ||
| 44 | */ | ||
| 45 | export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv>> = [ | ||
| 46 | CodexDriver, | ||
| 47 | ClaudeDriver, | ||
| 48 | CursorDriver, | ||
| 49 | OpenCodeDriver, | ||
| 50 | ]; | ||
apps/server/src/provider/builtInProviderCatalog.ts+9-41| ... | @@ -1,49 +1,17 @@ | ... | @@ -1,49 +1,17 @@ |
| 1 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | 1 | import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; |
| 2 | import type { Stream } from "effect"; | 2 | import type { Stream } from "effect"; |
| 3 | import type { ProviderAdapterError } from "./Errors.ts"; | ||
| 4 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; | ||
| 5 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 3 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
| 6 | 4 | ||
| 7 | export type ProviderSnapshotSource = { | 5 | export type ProviderSnapshotSource = { |
| 8 | readonly provider: ProviderKind; | 6 | /** |
| 7 | * Routing key — uniquely identifies this instance in the aggregated | ||
| 8 | * snapshot list. Two different snapshot sources may share the same | ||
| 9 | * driver kind (multiple instances of the same driver). | ||
| 10 | */ | ||
| 11 | readonly instanceId: ProviderInstanceId; | ||
| 12 | /** Driver implementation kind. */ | ||
| 13 | readonly driverKind: ProviderDriverKind; | ||
| 9 | readonly getSnapshot: ServerProviderShape["getSnapshot"]; | 14 | readonly getSnapshot: ServerProviderShape["getSnapshot"]; |
| 10 | readonly refresh: ServerProviderShape["refresh"]; | 15 | readonly refresh: ServerProviderShape["refresh"]; |
| 11 | readonly streamChanges: Stream.Stream<ServerProvider>; | 16 | readonly streamChanges: Stream.Stream<ServerProvider>; |
| 12 | }; | 17 | }; |
| 13 | |||
| 14 | type BuiltInProviderServiceMap = Record<ProviderKind, ServerProviderShape>; | ||
| 15 | type BuiltInAdapterMap = { | ||
| 16 | readonly codex: ProviderAdapterShape<ProviderAdapterError>; | ||
| 17 | readonly claudeAgent: ProviderAdapterShape<ProviderAdapterError>; | ||
| 18 | readonly opencode: ProviderAdapterShape<ProviderAdapterError>; | ||
| 19 | readonly cursor?: ProviderAdapterShape<ProviderAdapterError>; | ||
| 20 | }; | ||
| 21 | |||
| 22 | export const BUILT_IN_PROVIDER_ORDER = [ | ||
| 23 | "codex", | ||
| 24 | "claudeAgent", | ||
| 25 | "opencode", | ||
| 26 | "cursor", | ||
| 27 | ] as const satisfies ReadonlyArray<ProviderKind>; | ||
| 28 | |||
| 29 | export function createBuiltInProviderSources( | ||
| 30 | services: BuiltInProviderServiceMap, | ||
| 31 | ): ReadonlyArray<ProviderSnapshotSource> { | ||
| 32 | return BUILT_IN_PROVIDER_ORDER.map((provider) => ({ | ||
| 33 | provider, | ||
| 34 | getSnapshot: services[provider].getSnapshot, | ||
| 35 | refresh: services[provider].refresh, | ||
| 36 | streamChanges: services[provider].streamChanges, | ||
| 37 | })); | ||
| 38 | } | ||
| 39 | |||
| 40 | export function createBuiltInAdapterList( | ||
| 41 | adapters: BuiltInAdapterMap, | ||
| 42 | ): ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>> { | ||
| 43 | return [ | ||
| 44 | adapters.codex, | ||
| 45 | adapters.claudeAgent, | ||
| 46 | adapters.opencode, | ||
| 47 | ...(adapters.cursor ? [adapters.cursor] : []), | ||
| 48 | ]; | ||
| 49 | } |
apps/server/src/provider/makeManagedServerProvider.test.ts+5-3| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { describe, it, assert } from "@effect/vitest"; | 1 | import { describe, it, assert } from "@effect/vitest"; |
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | 3 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 4 | import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; | 4 | import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; |
| 5 | 5 | ||
| ... | @@ -21,7 +21,8 @@ interface TestSettings { | ... | @@ -21,7 +21,8 @@ interface TestSettings { |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | const initialSnapshot: ServerProvider = { | 23 | const initialSnapshot: ServerProvider = { |
| 24 | provider: "codex", | 24 | instanceId: ProviderInstanceId.make("codex"), |
| 25 | driver: ProviderDriverKind.make("codex"), | ||
| 25 | enabled: true, | 26 | enabled: true, |
| 26 | installed: true, | 27 | installed: true, |
| 27 | version: null, | 28 | version: null, |
| ... | @@ -35,7 +36,8 @@ const initialSnapshot: ServerProvider = { | ... | @@ -35,7 +36,8 @@ const initialSnapshot: ServerProvider = { |
| 35 | }; | 36 | }; |
| 36 | 37 | ||
| 37 | const refreshedSnapshot: ServerProvider = { | 38 | const refreshedSnapshot: ServerProvider = { |
| 38 | provider: "codex", | 39 | instanceId: ProviderInstanceId.make("codex"), |
| 40 | driver: ProviderDriverKind.make("codex"), | ||
| 39 | enabled: true, | 41 | enabled: true, |
| 40 | installed: true, | 42 | installed: true, |
| 41 | version: "1.0.0", | 43 | version: "1.0.0", |
apps/server/src/provider/opencodeRuntime.ts+6-2| ... | @@ -109,6 +109,7 @@ export interface OpenCodeRuntimeShape { | ... | @@ -109,6 +109,7 @@ export interface OpenCodeRuntimeShape { |
| 109 | */ | 109 | */ |
| 110 | readonly startOpenCodeServerProcess: (input: { | 110 | readonly startOpenCodeServerProcess: (input: { |
| 111 | readonly binaryPath: string; | 111 | readonly binaryPath: string; |
| 112 | readonly environment?: NodeJS.ProcessEnv; | ||
| 112 | readonly port?: number; | 113 | readonly port?: number; |
| 113 | readonly hostname?: string; | 114 | readonly hostname?: string; |
| 114 | readonly timeoutMs?: number; | 115 | readonly timeoutMs?: number; |
| ... | @@ -121,6 +122,7 @@ export interface OpenCodeRuntimeShape { | ... | @@ -121,6 +122,7 @@ export interface OpenCodeRuntimeShape { |
| 121 | readonly connectToOpenCodeServer: (input: { | 122 | readonly connectToOpenCodeServer: (input: { |
| 122 | readonly binaryPath: string; | 123 | readonly binaryPath: string; |
| 123 | readonly serverUrl?: string | null; | 124 | readonly serverUrl?: string | null; |
| 125 | readonly environment?: NodeJS.ProcessEnv; | ||
| 124 | readonly port?: number; | 126 | readonly port?: number; |
| 125 | readonly hostname?: string; | 127 | readonly hostname?: string; |
| 126 | readonly timeoutMs?: number; | 128 | readonly timeoutMs?: number; |
| ... | @@ -128,6 +130,7 @@ export interface OpenCodeRuntimeShape { | ... | @@ -128,6 +130,7 @@ export interface OpenCodeRuntimeShape { |
| 128 | readonly runOpenCodeCommand: (input: { | 130 | readonly runOpenCodeCommand: (input: { |
| 129 | readonly binaryPath: string; | 131 | readonly binaryPath: string; |
| 130 | readonly args: ReadonlyArray<string>; | 132 | readonly args: ReadonlyArray<string>; |
| 133 | readonly environment?: NodeJS.ProcessEnv; | ||
| 131 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; | 134 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; |
| 132 | readonly createOpenCodeSdkClient: (input: { | 135 | readonly createOpenCodeSdkClient: (input: { |
| 133 | readonly baseUrl: string; | 136 | readonly baseUrl: string; |
| ... | @@ -274,7 +277,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -274,7 +277,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 274 | const child = yield* spawner.spawn( | 277 | const child = yield* spawner.spawn( |
| 275 | ChildProcess.make(input.binaryPath, [...input.args], { | 278 | ChildProcess.make(input.binaryPath, [...input.args], { |
| 276 | shell: process.platform === "win32", | 279 | shell: process.platform === "win32", |
| 277 | env: process.env, | 280 | env: input.environment ?? process.env, |
| 278 | }), | 281 | }), |
| 279 | ); | 282 | ); |
| 280 | const [stdout, stderr, code] = yield* Effect.all( | 283 | const [stdout, stderr, code] = yield* Effect.all( |
| ... | @@ -332,7 +335,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -332,7 +335,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 332 | ChildProcess.make(input.binaryPath, args, { | 335 | ChildProcess.make(input.binaryPath, args, { |
| 333 | detached: process.platform !== "win32", | 336 | detached: process.platform !== "win32", |
| 334 | env: { | 337 | env: { |
| 335 | ...process.env, | 338 | ...(input.environment ?? process.env), |
| 336 | OPENCODE_CONFIG_CONTENT: JSON.stringify({}), | 339 | OPENCODE_CONFIG_CONTENT: JSON.stringify({}), |
| 337 | }, | 340 | }, |
| 338 | }), | 341 | }), |
| ... | @@ -472,6 +475,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -472,6 +475,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 472 | 475 | ||
| 473 | return startOpenCodeServerProcess({ | 476 | return startOpenCodeServerProcess({ |
| 474 | binaryPath: input.binaryPath, | 477 | binaryPath: input.binaryPath, |
| 478 | ...(input.environment !== undefined ? { environment: input.environment } : {}), | ||
| 475 | ...(input.port !== undefined ? { port: input.port } : {}), | 479 | ...(input.port !== undefined ? { port: input.port } : {}), |
| 476 | ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), | 480 | ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), |
| 477 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), | 481 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), |
apps/server/src/provider/providerSnapshot.test.ts+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { ModelCapabilities } from "@t3tools/contracts"; | 2 | import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | 3 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 4 | 4 | ||
| 5 | import { providerModelsFromSettings } from "./providerSnapshot.ts"; | 5 | import { providerModelsFromSettings } from "./providerSnapshot.ts"; |
| ... | @@ -27,7 +27,7 @@ describe("providerModelsFromSettings", () => { | ... | @@ -27,7 +27,7 @@ describe("providerModelsFromSettings", () => { |
| 27 | it("applies the provided capabilities to custom models", () => { | 27 | it("applies the provided capabilities to custom models", () => { |
| 28 | const models = providerModelsFromSettings( | 28 | const models = providerModelsFromSettings( |
| 29 | [], | 29 | [], |
| 30 | "opencode", | 30 | ProviderDriverKind.make("opencode"), |
| 31 | ["openai/gpt-5"], | 31 | ["openai/gpt-5"], |
| 32 | OPENCODE_CUSTOM_MODEL_CAPABILITIES, | 32 | OPENCODE_CUSTOM_MODEL_CAPABILITIES, |
| 33 | ); | 33 | ); |
apps/server/src/provider/providerSnapshot.ts+5-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ProviderDriverKind, | ||
| 2 | ModelCapabilities, | 3 | ModelCapabilities, |
| 3 | ServerProvider, | 4 | ServerProvider, |
| 4 | ServerProviderAuth, | 5 | ServerProviderAuth, |
| ... | @@ -36,6 +37,8 @@ export interface ServerProviderPresentation { | ... | @@ -36,6 +37,8 @@ export interface ServerProviderPresentation { |
| 36 | readonly showInteractionModeToggle?: boolean; | 37 | readonly showInteractionModeToggle?: boolean; |
| 37 | } | 38 | } |
| 38 | 39 | ||
| 40 | export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">; | ||
| 41 | |||
| 39 | export function nonEmptyTrimmed(value: string | undefined): string | undefined { | 42 | export function nonEmptyTrimmed(value: string | undefined): string | undefined { |
| 40 | if (!value) return undefined; | 43 | if (!value) return undefined; |
| 41 | const trimmed = value.trim(); | 44 | const trimmed = value.trim(); |
| ... | @@ -110,7 +113,7 @@ export function parseGenericCliVersion(output: string): string | null { | ... | @@ -110,7 +113,7 @@ export function parseGenericCliVersion(output: string): string | null { |
| 110 | 113 | ||
| 111 | export function providerModelsFromSettings( | 114 | export function providerModelsFromSettings( |
| 112 | builtInModels: ReadonlyArray<ServerProviderModel>, | 115 | builtInModels: ReadonlyArray<ServerProviderModel>, |
| 113 | provider: ServerProvider["provider"], | 116 | provider: ProviderDriverKind, |
| 114 | customModels: ReadonlyArray<string>, | 117 | customModels: ReadonlyArray<string>, |
| 115 | customModelCapabilities: ModelCapabilities, | 118 | customModelCapabilities: ModelCapabilities, |
| 116 | ): ReadonlyArray<ServerProviderModel> { | 119 | ): ReadonlyArray<ServerProviderModel> { |
| ... | @@ -179,7 +182,6 @@ export function buildBooleanOptionDescriptor(input: { | ... | @@ -179,7 +182,6 @@ export function buildBooleanOptionDescriptor(input: { |
| 179 | } | 182 | } |
| 180 | 183 | ||
| 181 | export function buildServerProvider(input: { | 184 | export function buildServerProvider(input: { |
| 182 | provider: ServerProvider["provider"]; | ||
| 183 | presentation: ServerProviderPresentation; | 185 | presentation: ServerProviderPresentation; |
| 184 | enabled: boolean; | 186 | enabled: boolean; |
| 185 | checkedAt: string; | 187 | checkedAt: string; |
| ... | @@ -187,9 +189,8 @@ export function buildServerProvider(input: { | ... | @@ -187,9 +189,8 @@ export function buildServerProvider(input: { |
| 187 | slashCommands?: ReadonlyArray<ServerProviderSlashCommand>; | 189 | slashCommands?: ReadonlyArray<ServerProviderSlashCommand>; |
| 188 | skills?: ReadonlyArray<ServerProviderSkill>; | 190 | skills?: ReadonlyArray<ServerProviderSkill>; |
| 189 | probe: ProviderProbeResult; | 191 | probe: ProviderProbeResult; |
| 190 | }): ServerProvider { | 192 | }): ServerProviderDraft { |
| 191 | return { | 193 | return { |
| 192 | provider: input.provider, | ||
| 193 | displayName: input.presentation.displayName, | 194 | displayName: input.presentation.displayName, |
| 194 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), | 195 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), |
| 195 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" | 196 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" |
apps/server/src/provider/providerStatusCache.test.ts+90-16| ... | @@ -1,23 +1,33 @@ | ... | @@ -1,23 +1,33 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | 2 | import { |
| 3 | defaultInstanceIdForDriver, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ServerProvider, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | 8 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 4 | import { assert, it } from "@effect/vitest"; | 9 | import { assert, it } from "@effect/vitest"; |
| 5 | import { Effect, FileSystem } from "effect"; | 10 | import { Effect, FileSystem } from "effect"; |
| 6 | 11 | ||
| 7 | import { | 12 | import { |
| 8 | hydrateCachedProvider, | 13 | hydrateCachedProvider, |
| 14 | isCachedProviderCorrelated, | ||
| 9 | readProviderStatusCache, | 15 | readProviderStatusCache, |
| 10 | resolveProviderStatusCachePath, | 16 | resolveProviderStatusCachePath, |
| 11 | writeProviderStatusCache, | 17 | writeProviderStatusCache, |
| 12 | } from "./providerStatusCache.ts"; | 18 | } from "./providerStatusCache.ts"; |
| 13 | 19 | ||
| 14 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | 20 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 21 | const CODEX_DRIVER = ProviderDriverKind.make("codex"); | ||
| 22 | const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); | ||
| 23 | const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); | ||
| 15 | 24 | ||
| 16 | const makeProvider = ( | 25 | const makeProvider = ( |
| 17 | provider: ServerProvider["provider"], | 26 | provider: ProviderDriverKind, |
| 18 | overrides?: Partial<ServerProvider>, | 27 | overrides?: Partial<ServerProvider>, |
| 19 | ): ServerProvider => ({ | 28 | ): ServerProvider => ({ |
| 20 | provider, | 29 | instanceId: defaultInstanceIdForDriver(provider), |
| 30 | driver: provider, | ||
| 21 | enabled: true, | 31 | enabled: true, |
| 22 | installed: true, | 32 | installed: true, |
| 23 | version: "1.0.0", | 33 | version: "1.0.0", |
| ... | @@ -35,26 +45,26 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -35,26 +45,26 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 35 | Effect.gen(function* () { | 45 | Effect.gen(function* () { |
| 36 | const fs = yield* FileSystem.FileSystem; | 46 | const fs = yield* FileSystem.FileSystem; |
| 37 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); | 47 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); |
| 38 | const codexProvider = makeProvider("codex"); | 48 | const codexProvider = makeProvider(CODEX_DRIVER); |
| 39 | const claudeProvider = makeProvider("claudeAgent", { | 49 | const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, { |
| 40 | status: "warning", | 50 | status: "warning", |
| 41 | auth: { status: "unknown" }, | 51 | auth: { status: "unknown" }, |
| 42 | }); | 52 | }); |
| 43 | const openCodeProvider = makeProvider("opencode", { | 53 | const openCodeProvider = makeProvider(OPENCODE_DRIVER, { |
| 44 | status: "warning", | 54 | status: "warning", |
| 45 | auth: { status: "unknown", type: "opencode" }, | 55 | auth: { status: "unknown", type: "opencode" }, |
| 46 | }); | 56 | }); |
| 47 | const codexPath = resolveProviderStatusCachePath({ | 57 | const codexPath = yield* resolveProviderStatusCachePath({ |
| 48 | cacheDir: tempDir, | 58 | cacheDir: tempDir, |
| 49 | provider: "codex", | 59 | instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), |
| 50 | }); | 60 | }); |
| 51 | const claudePath = resolveProviderStatusCachePath({ | 61 | const claudePath = yield* resolveProviderStatusCachePath({ |
| 52 | cacheDir: tempDir, | 62 | cacheDir: tempDir, |
| 53 | provider: "claudeAgent", | 63 | instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("claudeAgent")), |
| 54 | }); | 64 | }); |
| 55 | const openCodePath = resolveProviderStatusCachePath({ | 65 | const openCodePath = yield* resolveProviderStatusCachePath({ |
| 56 | cacheDir: tempDir, | 66 | cacheDir: tempDir, |
| 57 | provider: "opencode", | 67 | instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("opencode")), |
| 58 | }); | 68 | }); |
| 59 | 69 | ||
| 60 | yield* writeProviderStatusCache({ | 70 | yield* writeProviderStatusCache({ |
| ... | @@ -77,7 +87,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -77,7 +87,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 77 | ); | 87 | ); |
| 78 | 88 | ||
| 79 | it("hydrates cached provider status while preserving current settings-derived models", () => { | 89 | it("hydrates cached provider status while preserving current settings-derived models", () => { |
| 80 | const cachedCodex = makeProvider("codex", { | 90 | const cachedCodex = makeProvider(CODEX_DRIVER, { |
| 81 | checkedAt: "2026-04-10T12:00:00.000Z", | 91 | checkedAt: "2026-04-10T12:00:00.000Z", |
| 82 | models: [ | 92 | models: [ |
| 83 | { | 93 | { |
| ... | @@ -97,7 +107,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -97,7 +107,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 97 | }, | 107 | }, |
| 98 | ], | 108 | ], |
| 99 | }); | 109 | }); |
| 100 | const fallbackCodex = makeProvider("codex", { | 110 | const fallbackCodex = makeProvider(CODEX_DRIVER, { |
| 101 | models: [ | 111 | models: [ |
| 102 | { | 112 | { |
| 103 | slug: "gpt-5.4", | 113 | slug: "gpt-5.4", |
| ... | @@ -138,11 +148,11 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -138,11 +148,11 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 138 | }); | 148 | }); |
| 139 | 149 | ||
| 140 | it("ignores stale cached enabled state when the provider is now disabled", () => { | 150 | it("ignores stale cached enabled state when the provider is now disabled", () => { |
| 141 | const cachedCodex = makeProvider("codex", { | 151 | const cachedCodex = makeProvider(CODEX_DRIVER, { |
| 142 | checkedAt: "2026-04-10T12:00:00.000Z", | 152 | checkedAt: "2026-04-10T12:00:00.000Z", |
| 143 | message: "Cached ready status", | 153 | message: "Cached ready status", |
| 144 | }); | 154 | }); |
| 145 | const disabledFallback = makeProvider("codex", { | 155 | const disabledFallback = makeProvider(CODEX_DRIVER, { |
| 146 | enabled: false, | 156 | enabled: false, |
| 147 | installed: false, | 157 | installed: false, |
| 148 | version: null, | 158 | version: null, |
| ... | @@ -159,4 +169,68 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -159,4 +169,68 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 159 | disabledFallback, | 169 | disabledFallback, |
| 160 | ); | 170 | ); |
| 161 | }); | 171 | }); |
| 172 | |||
| 173 | it("rejects cached snapshots that are not correlated to the fallback instance", () => { | ||
| 174 | const fallbackCodex = makeProvider(CODEX_DRIVER, { | ||
| 175 | models: [ | ||
| 176 | { | ||
| 177 | slug: "gpt-5.4", | ||
| 178 | name: "GPT-5.4", | ||
| 179 | isCustom: false, | ||
| 180 | capabilities: emptyCapabilities, | ||
| 181 | }, | ||
| 182 | ], | ||
| 183 | }); | ||
| 184 | const legacyCachedCodex = { | ||
| 185 | provider: ProviderDriverKind.make("codex"), | ||
| 186 | enabled: true, | ||
| 187 | installed: true, | ||
| 188 | version: "1.0.0", | ||
| 189 | status: "ready", | ||
| 190 | auth: { status: "authenticated" }, | ||
| 191 | checkedAt: "2026-04-10T12:00:00.000Z", | ||
| 192 | models: [ | ||
| 193 | { | ||
| 194 | slug: "cached-legacy-model", | ||
| 195 | name: "Cached Legacy Model", | ||
| 196 | isCustom: false, | ||
| 197 | capabilities: emptyCapabilities, | ||
| 198 | }, | ||
| 199 | ], | ||
| 200 | slashCommands: [], | ||
| 201 | skills: [], | ||
| 202 | } as unknown as ServerProvider; | ||
| 203 | const mismatchedCachedCodex = makeProvider(CODEX_DRIVER, { | ||
| 204 | instanceId: ProviderInstanceId.make("codex_personal"), | ||
| 205 | }); | ||
| 206 | |||
| 207 | assert.strictEqual( | ||
| 208 | isCachedProviderCorrelated({ | ||
| 209 | cachedProvider: legacyCachedCodex, | ||
| 210 | fallbackProvider: fallbackCodex, | ||
| 211 | }), | ||
| 212 | false, | ||
| 213 | ); | ||
| 214 | assert.deepStrictEqual( | ||
| 215 | hydrateCachedProvider({ | ||
| 216 | cachedProvider: legacyCachedCodex, | ||
| 217 | fallbackProvider: fallbackCodex, | ||
| 218 | }), | ||
| 219 | fallbackCodex, | ||
| 220 | ); | ||
| 221 | assert.strictEqual( | ||
| 222 | isCachedProviderCorrelated({ | ||
| 223 | cachedProvider: mismatchedCachedCodex, | ||
| 224 | fallbackProvider: fallbackCodex, | ||
| 225 | }), | ||
| 226 | false, | ||
| 227 | ); | ||
| 228 | assert.deepStrictEqual( | ||
| 229 | hydrateCachedProvider({ | ||
| 230 | cachedProvider: mismatchedCachedCodex, | ||
| 231 | fallbackProvider: fallbackCodex, | ||
| 232 | }), | ||
| 233 | fallbackCodex, | ||
| 234 | ); | ||
| 235 | }); | ||
| 162 | }); | 236 | }); |
apps/server/src/provider/providerStatusCache.ts+61-19| ... | @@ -1,25 +1,17 @@ | ... | @@ -1,25 +1,17 @@ |
| 1 | import * as nodePath from "node:path"; | 1 | import { |
| 2 | import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; | 2 | type ProviderDriverKind, |
| 3 | import { Cause, Effect, FileSystem, Schema } from "effect"; | 3 | type ProviderInstanceId, |
| 4 | type ServerProvider, | ||
| 5 | ServerProvider as ServerProviderSchema, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { Cause, Effect, FileSystem, Path, Schema } from "effect"; | ||
| 4 | 8 | ||
| 5 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | 9 | import { writeFileStringAtomically } from "../atomicWrite.ts"; |
| 6 | 10 | ||
| 7 | export const PROVIDER_CACHE_IDS = [ | ||
| 8 | "codex", | ||
| 9 | "claudeAgent", | ||
| 10 | "opencode", | ||
| 11 | "cursor", | ||
| 12 | ] as const satisfies ReadonlyArray<ServerProvider["provider"]>; | ||
| 13 | |||
| 14 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( | 11 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( |
| 15 | Schema.fromJsonString(ServerProviderSchema), | 12 | Schema.fromJsonString(ServerProviderSchema), |
| 16 | ); | 13 | ); |
| 17 | 14 | ||
| 18 | const providerOrderRank = (provider: ServerProvider["provider"]): number => { | ||
| 19 | const rank = PROVIDER_CACHE_IDS.indexOf(provider); | ||
| 20 | return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; | ||
| 21 | }; | ||
| 22 | |||
| 23 | const mergeProviderModels = ( | 15 | const mergeProviderModels = ( |
| 24 | fallbackModels: ReadonlyArray<ServerProvider["models"][number]>, | 16 | fallbackModels: ReadonlyArray<ServerProvider["models"][number]>, |
| 25 | cachedModels: ReadonlyArray<ServerProvider["models"][number]>, | 17 | cachedModels: ReadonlyArray<ServerProvider["models"][number]>, |
| ... | @@ -32,13 +24,27 @@ export const orderProviderSnapshots = ( | ... | @@ -32,13 +24,27 @@ export const orderProviderSnapshots = ( |
| 32 | providers: ReadonlyArray<ServerProvider>, | 24 | providers: ReadonlyArray<ServerProvider>, |
| 33 | ): ReadonlyArray<ServerProvider> => | 25 | ): ReadonlyArray<ServerProvider> => |
| 34 | [...providers].toSorted( | 26 | [...providers].toSorted( |
| 35 | (left, right) => providerOrderRank(left.provider) - providerOrderRank(right.provider), | 27 | (left, right) => |
| 28 | (left.displayName ?? "").localeCompare(right.displayName ?? "") || | ||
| 29 | left.driver.localeCompare(right.driver) || | ||
| 30 | left.instanceId.localeCompare(right.instanceId), | ||
| 36 | ); | 31 | ); |
| 37 | 32 | ||
| 33 | export const isCachedProviderCorrelated = (input: { | ||
| 34 | readonly cachedProvider: ServerProvider; | ||
| 35 | readonly fallbackProvider: ServerProvider; | ||
| 36 | }): boolean => | ||
| 37 | input.cachedProvider.instanceId === input.fallbackProvider.instanceId && | ||
| 38 | input.cachedProvider.driver === input.fallbackProvider.driver; | ||
| 39 | |||
| 38 | export const hydrateCachedProvider = (input: { | 40 | export const hydrateCachedProvider = (input: { |
| 39 | readonly cachedProvider: ServerProvider; | 41 | readonly cachedProvider: ServerProvider; |
| 40 | readonly fallbackProvider: ServerProvider; | 42 | readonly fallbackProvider: ServerProvider; |
| 41 | }): ServerProvider => { | 43 | }): ServerProvider => { |
| 44 | if (!isCachedProviderCorrelated(input)) { | ||
| 45 | return input.fallbackProvider; | ||
| 46 | } | ||
| 47 | |||
| 42 | if ( | 48 | if ( |
| 43 | !input.fallbackProvider.enabled || | 49 | !input.fallbackProvider.enabled || |
| 44 | input.cachedProvider.enabled !== input.fallbackProvider.enabled | 50 | input.cachedProvider.enabled !== input.fallbackProvider.enabled |
| ... | @@ -64,10 +70,46 @@ export const hydrateCachedProvider = (input: { | ... | @@ -64,10 +70,46 @@ export const hydrateCachedProvider = (input: { |
| 64 | : hydratedProvider; | 70 | : hydratedProvider; |
| 65 | }; | 71 | }; |
| 66 | 72 | ||
| 67 | export const resolveProviderStatusCachePath = (input: { | 73 | /** |
| 74 | * Resolve the on-disk cache path for a provider instance snapshot. | ||
| 75 | * | ||
| 76 | * File naming: `<cacheDir>/<instanceId>.json`. For the default instance of | ||
| 77 | * a built-in kind this equals the legacy `<kind>.json` path (because | ||
| 78 | * `defaultInstanceIdForDriver(kind).toString() === kind`), so existing | ||
| 79 | * cached snapshots remain readable without any rename step. | ||
| 80 | * | ||
| 81 | * Non-default instances (e.g. `codex_personal`) land in their own files and | ||
| 82 | * never collide with other instances. | ||
| 83 | * | ||
| 84 | * Cache contents must still carry matching `instanceId` + `driver` identity | ||
| 85 | * before hydration. The filename alone is not trusted as a routing key. | ||
| 86 | */ | ||
| 87 | export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( | ||
| 88 | function* (input: { | ||
| 89 | readonly cacheDir: string; | ||
| 90 | readonly instanceId: ProviderInstanceId; | ||
| 91 | }): Effect.fn.Return<string, never, Path.Path> { | ||
| 92 | const path = yield* Path.Path; | ||
| 93 | return path.join(input.cacheDir, `${input.instanceId}.json`); | ||
| 94 | }, | ||
| 95 | ); | ||
| 96 | |||
| 97 | /** | ||
| 98 | * Legacy kind-keyed path resolver retained for callers that still think in | ||
| 99 | * terms of `ProviderDriverKind`. Prefer `resolveProviderStatusCachePath` with an | ||
| 100 | * `instanceId`; new code should route through the instance registry. | ||
| 101 | * | ||
| 102 | * @deprecated use `resolveProviderStatusCachePath` with an instance id. | ||
| 103 | */ | ||
| 104 | export const resolveLegacyProviderStatusCachePath = Effect.fn( | ||
| 105 | "resolveLegacyProviderStatusCachePath", | ||
| 106 | )(function* (input: { | ||
| 68 | readonly cacheDir: string; | 107 | readonly cacheDir: string; |
| 69 | readonly provider: ServerProvider["provider"]; | 108 | readonly provider: ProviderDriverKind; |
| 70 | }) => nodePath.join(input.cacheDir, `${input.provider}.json`); | 109 | }): Effect.fn.Return<string, never, Path.Path> { |
| 110 | const path = yield* Path.Path; | ||
| 111 | return path.join(input.cacheDir, `${input.provider}.json`); | ||
| 112 | }); | ||
| 71 | 113 | ||
| 72 | export const readProviderStatusCache = (filePath: string) => | 114 | export const readProviderStatusCache = (filePath: string) => |
| 73 | Effect.gen(function* () { | 115 | Effect.gen(function* () { |
apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | /** | ||
| 2 | * Test helpers for constructing a `ProviderAdapterRegistryShape` mock from a | ||
| 3 | * kind-keyed adapter map. | ||
| 4 | * | ||
| 5 | * Tests historically assembled a `registry` object with only `getByProvider` | ||
| 6 | * + `listProviders` populated. Slice D grew the shape with `getByInstance` | ||
| 7 | * and `listInstances`; this helper fills both in from a single kind-keyed | ||
| 8 | * input so individual fixtures can stay concise. | ||
| 9 | * | ||
| 10 | * Non-default instance ids (e.g. `codex_personal`) are not addressable via | ||
| 11 | * the shim returned here — the legacy test fixtures only ever had | ||
| 12 | * single-instance-per-driver data anyway. | ||
| 13 | * | ||
| 14 | * @module provider/testUtils/providerAdapterRegistryMock | ||
| 15 | */ | ||
| 16 | import { | ||
| 17 | defaultInstanceIdForDriver, | ||
| 18 | ProviderDriverKind, | ||
| 19 | type ProviderInstanceId, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { Effect, PubSub, Record, Result, Stream } from "effect"; | ||
| 22 | |||
| 23 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; | ||
| 24 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | ||
| 25 | import type { ProviderAdapterRegistryShape } from "../Services/ProviderAdapterRegistry.ts"; | ||
| 26 | |||
| 27 | export type KindAdapterMap = Partial< | ||
| 28 | Record<ProviderDriverKind, ProviderAdapterShape<ProviderAdapterError>> | ||
| 29 | >; | ||
| 30 | |||
| 31 | /** | ||
| 32 | * Build a `ProviderAdapterRegistryShape` from a kind-keyed adapter map. | ||
| 33 | * Every adapter present in the map is addressable via both the legacy | ||
| 34 | * `getByProvider(kind)` path and the new `getByInstance(id)` path (where | ||
| 35 | * `id = defaultInstanceIdForDriver(kind)`). | ||
| 36 | */ | ||
| 37 | export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapterRegistryShape => { | ||
| 38 | const byInstanceId = new Map<ProviderInstanceId, ProviderAdapterShape<ProviderAdapterError>>(); | ||
| 39 | for (const [kind, adapter] of Object.entries(adapters)) { | ||
| 40 | if (!adapter) continue; | ||
| 41 | const driverKind = ProviderDriverKind.make(kind); | ||
| 42 | byInstanceId.set(defaultInstanceIdForDriver(driverKind), adapter); | ||
| 43 | } | ||
| 44 | |||
| 45 | const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => { | ||
| 46 | const adapter = byInstanceId.get(instanceId); | ||
| 47 | return adapter | ||
| 48 | ? Effect.succeed(adapter) | ||
| 49 | : Effect.fail( | ||
| 50 | new ProviderUnsupportedError({ | ||
| 51 | provider: ProviderDriverKind.make(instanceId), | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | }; | ||
| 55 | |||
| 56 | return { | ||
| 57 | getByInstance, | ||
| 58 | getInstanceInfo: (instanceId) => { | ||
| 59 | const adapter = byInstanceId.get(instanceId); | ||
| 60 | if (!adapter) { | ||
| 61 | return Effect.fail( | ||
| 62 | new ProviderUnsupportedError({ | ||
| 63 | provider: ProviderDriverKind.make(instanceId), | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | return Effect.succeed({ | ||
| 68 | instanceId, | ||
| 69 | driverKind: ProviderDriverKind.make(adapter.provider), | ||
| 70 | displayName: undefined, | ||
| 71 | enabled: true, | ||
| 72 | continuationIdentity: { | ||
| 73 | driverKind: ProviderDriverKind.make(adapter.provider), | ||
| 74 | continuationKey: `${adapter.provider}:instance:${instanceId}`, | ||
| 75 | }, | ||
| 76 | }); | ||
| 77 | }, | ||
| 78 | listInstances: () => Effect.succeed(Array.from(byInstanceId.keys())), | ||
| 79 | listProviders: () => | ||
| 80 | Effect.succeed( | ||
| 81 | Record.keys( | ||
| 82 | Record.filterMap(adapters, (adapter, kind) => | ||
| 83 | adapter !== undefined ? Result.succeed(kind) : Result.failVoid, | ||
| 84 | ), | ||
| 85 | ), | ||
| 86 | ), | ||
| 87 | // Static test fixtures don't reload; an empty stream is enough to | ||
| 88 | // satisfy the shape. Tests exercising hot-reload build their own | ||
| 89 | // stream via the real `ProviderInstanceRegistry`. | ||
| 90 | streamChanges: Stream.empty, | ||
| 91 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 92 | PubSub.subscribe(pubsub), | ||
| 93 | ), | ||
| 94 | }; | ||
| 95 | }; | ||
apps/server/src/provider/unavailableProviderSnapshot.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | /** | ||
| 2 | * Helpers for synthesizing "unavailable" `ServerProvider` snapshots. | ||
| 3 | * | ||
| 4 | * When `ServerSettings.providerInstances` (or persisted thread/session | ||
| 5 | * state) references a driver this build does not ship — typical after a | ||
| 6 | * downgrade from a fork or a feature-branch test session — the runtime | ||
| 7 | * needs to surface the entry to the UI without crashing. This module | ||
| 8 | * produces shadow snapshots that satisfy `ServerProvider`'s wire shape | ||
| 9 | * while signalling unavailability. | ||
| 10 | * | ||
| 11 | * @module unavailableProviderSnapshot | ||
| 12 | */ | ||
| 13 | import { | ||
| 14 | ProviderDriverKind, | ||
| 15 | type ProviderInstanceId, | ||
| 16 | type ServerProvider, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | |||
| 19 | import { buildServerProvider } from "./providerSnapshot.ts"; | ||
| 20 | |||
| 21 | export interface UnavailableProviderSnapshotInput { | ||
| 22 | readonly driverKind: ProviderDriverKind | string; | ||
| 23 | readonly instanceId: ProviderInstanceId; | ||
| 24 | readonly displayName?: string | undefined; | ||
| 25 | readonly accentColor?: string | undefined; | ||
| 26 | readonly reason: string; | ||
| 27 | /** | ||
| 28 | * Optional override for `checkedAt`. Defaulted to `new Date()` so callers | ||
| 29 | * (notably tests) don't have to pass it. | ||
| 30 | */ | ||
| 31 | readonly checkedAt?: string; | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * Produce a `ServerProvider` snapshot representing a configured instance | ||
| 36 | * whose driver the running build does not implement. The result is safe | ||
| 37 | * to broadcast over the wire and is structured so the web UI can render | ||
| 38 | * a "missing driver" affordance without special-casing. | ||
| 39 | */ | ||
| 40 | export function buildUnavailableProviderSnapshot( | ||
| 41 | input: UnavailableProviderSnapshotInput, | ||
| 42 | ): ServerProvider { | ||
| 43 | const checkedAt = input.checkedAt ?? new Date().toISOString(); | ||
| 44 | const displayName = input.displayName?.trim() || (input.driverKind as string); | ||
| 45 | |||
| 46 | const base = buildServerProvider({ | ||
| 47 | presentation: { displayName }, | ||
| 48 | enabled: false, | ||
| 49 | checkedAt, | ||
| 50 | models: [], | ||
| 51 | skills: [], | ||
| 52 | probe: { | ||
| 53 | installed: false, | ||
| 54 | version: null, | ||
| 55 | status: "error", | ||
| 56 | auth: { status: "unknown" }, | ||
| 57 | message: input.reason, | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | return { | ||
| 62 | ...base, | ||
| 63 | instanceId: input.instanceId, | ||
| 64 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| 65 | driver: | ||
| 66 | typeof input.driverKind === "string" | ||
| 67 | ? ProviderDriverKind.make(input.driverKind) | ||
| 68 | : input.driverKind, | ||
| 69 | availability: "unavailable", | ||
| 70 | unavailableReason: input.reason, | ||
| 71 | }; | ||
| 72 | } | ||
apps/server/src/server.test.ts+8-4| ... | @@ -17,6 +17,8 @@ import { | ... | @@ -17,6 +17,8 @@ import { |
| 17 | type OrchestrationEvent, | 17 | type OrchestrationEvent, |
| 18 | ORCHESTRATION_WS_METHODS, | 18 | ORCHESTRATION_WS_METHODS, |
| 19 | ProjectId, | 19 | ProjectId, |
| 20 | ProviderDriverKind, | ||
| 21 | ProviderInstanceId, | ||
| 20 | ResolvedKeybindingRule, | 22 | ResolvedKeybindingRule, |
| 21 | ThreadId, | 23 | ThreadId, |
| 22 | WS_METHODS, | 24 | WS_METHODS, |
| ... | @@ -110,7 +112,7 @@ const defaultProjectId = ProjectId.make("project-default"); | ... | @@ -110,7 +112,7 @@ const defaultProjectId = ProjectId.make("project-default"); |
| 110 | const defaultThreadId = ThreadId.make("thread-default"); | 112 | const defaultThreadId = ThreadId.make("thread-default"); |
| 111 | const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; | 113 | const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; |
| 112 | const defaultModelSelection = { | 114 | const defaultModelSelection = { |
| 113 | provider: "codex", | 115 | instanceId: ProviderInstanceId.make("codex"), |
| 114 | model: "gpt-5-codex", | 116 | model: "gpt-5-codex", |
| 115 | } as const; | 117 | } as const; |
| 116 | const testEnvironmentDescriptor = { | 118 | const testEnvironmentDescriptor = { |
| ... | @@ -1845,7 +1847,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1845,7 +1847,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1845 | Effect.gen(function* () { | 1847 | Effect.gen(function* () { |
| 1846 | const providers = [ | 1848 | const providers = [ |
| 1847 | { | 1849 | { |
| 1848 | provider: "codex" as const, | 1850 | instanceId: ProviderInstanceId.make("codex"), |
| 1851 | driver: ProviderDriverKind.make("codex"), | ||
| 1849 | enabled: true, | 1852 | enabled: true, |
| 1850 | installed: true, | 1853 | installed: true, |
| 1851 | version: "1.0.0", | 1854 | version: "1.0.0", |
| ... | @@ -1915,7 +1918,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1915,7 +1918,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1915 | Effect.gen(function* () { | 1918 | Effect.gen(function* () { |
| 1916 | const nextProviders = [ | 1919 | const nextProviders = [ |
| 1917 | { | 1920 | { |
| 1918 | provider: "codex" as const, | 1921 | instanceId: ProviderInstanceId.make("codex"), |
| 1922 | driver: ProviderDriverKind.make("codex"), | ||
| 1919 | enabled: true, | 1923 | enabled: true, |
| 1920 | installed: true, | 1924 | installed: true, |
| 1921 | version: "1.0.0", | 1925 | version: "1.0.0", |
| ... | @@ -2164,7 +2168,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2164,7 +2168,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2164 | workspaceRoot: missingWorkspaceRoot, | 2168 | workspaceRoot: missingWorkspaceRoot, |
| 2165 | createWorkspaceRootIfMissing: true, | 2169 | createWorkspaceRootIfMissing: true, |
| 2166 | defaultModelSelection: { | 2170 | defaultModelSelection: { |
| 2167 | provider: "codex", | 2171 | instanceId: ProviderInstanceId.make("codex"), |
| 2168 | model: "gpt-5-codex", | 2172 | model: "gpt-5-codex", |
| 2169 | }, | 2173 | }, |
| 2170 | createdAt: new Date().toISOString(), | 2174 | createdAt: new Date().toISOString(), |
apps/server/src/server.ts+33-43| ... | @@ -16,22 +16,20 @@ import { OpenLive } from "./open.ts"; | ... | @@ -16,22 +16,20 @@ import { OpenLive } from "./open.ts"; |
| 16 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | 16 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; |
| 17 | import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; | 17 | import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; |
| 18 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; | 18 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; |
| 19 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts"; | ||
| 20 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; | 19 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; |
| 21 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; | 20 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; |
| 22 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; | ||
| 23 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; | ||
| 24 | import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts"; | ||
| 25 | import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; | ||
| 26 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; | 21 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 27 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 22 | import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; |
| 23 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | ||
| 28 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; | 24 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| 25 | import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; | ||
| 29 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; | 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; |
| 30 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; | 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; |
| 31 | import { GitCoreLive } from "./git/Layers/GitCore.ts"; | 28 | import { GitCoreLive } from "./git/Layers/GitCore.ts"; |
| 32 | import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; | 29 | import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; |
| 33 | import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; | 30 | import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; |
| 34 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration.ts"; | 31 | import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts"; |
| 32 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | ||
| 35 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 33 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 36 | import { GitManagerLive } from "./git/Layers/GitManager.ts"; | 34 | import { GitManagerLive } from "./git/Layers/GitManager.ts"; |
| 37 | import { KeybindingsLive } from "./keybindings.ts"; | 35 | import { KeybindingsLive } from "./keybindings.ts"; |
| ... | @@ -144,41 +142,15 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( | ... | @@ -144,41 +142,15 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( |
| 144 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | 142 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| 145 | ); | 143 | ); |
| 146 | 144 | ||
| 147 | const ProviderLayerLive = Layer.unwrap( | 145 | // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter |
| 148 | Effect.gen(function* () { | 146 | // by looking up the default `ProviderInstance` per driver in the instance |
| 149 | const { providerEventLogPath } = yield* ServerConfig; | 147 | // registry. Adapter construction itself moved inside each driver's |
| 150 | const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | 148 | // `create()`; `ProviderEventLoggersLive` owns the shared native/canonical |
| 151 | stream: "native", | 149 | // NDJSON writers and is provided at the outer runtime layer so both |
| 152 | }); | 150 | // `ProviderService` and the per-instance drivers read the same logger pair. |
| 153 | const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | 151 | const ProviderLayerLive = ProviderServiceLive.pipe( |
| 154 | stream: "canonical", | 152 | Layer.provide(ProviderAdapterRegistryLive), |
| 155 | }); | 153 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), |
| 156 | const codexAdapterLayer = makeCodexAdapterLive( | ||
| 157 | nativeEventLogger ? { nativeEventLogger } : undefined, | ||
| 158 | ); | ||
| 159 | const claudeAdapterLayer = makeClaudeAdapterLive( | ||
| 160 | nativeEventLogger ? { nativeEventLogger } : undefined, | ||
| 161 | ); | ||
| 162 | const openCodeAdapterLayer = makeOpenCodeAdapterLive( | ||
| 163 | nativeEventLogger ? { nativeEventLogger } : undefined, | ||
| 164 | ); | ||
| 165 | const cursorAdapterLayer = makeCursorAdapterLive( | ||
| 166 | nativeEventLogger ? { nativeEventLogger } : undefined, | ||
| 167 | ); | ||
| 168 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( | ||
| 169 | Layer.provide(codexAdapterLayer), | ||
| 170 | Layer.provide(claudeAdapterLayer), | ||
| 171 | Layer.provide(openCodeAdapterLayer), | ||
| 172 | Layer.provide(cursorAdapterLayer), | ||
| 173 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), | ||
| 174 | ); | ||
| 175 | return makeProviderServiceLive( | ||
| 176 | canonicalEventLogger ? { canonicalEventLogger } : undefined, | ||
| 177 | ).pipe( | ||
| 178 | Layer.provide(adapterRegistryLayer), | ||
| 179 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), | ||
| 180 | ); | ||
| 181 | }), | ||
| 182 | ); | 154 | ); |
| 183 | 155 | ||
| 184 | const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); | 156 | const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); |
| ... | @@ -187,7 +159,7 @@ const GitManagerLayerLive = GitManagerLive.pipe( | ... | @@ -187,7 +159,7 @@ const GitManagerLayerLive = GitManagerLive.pipe( |
| 187 | Layer.provideMerge(ProjectSetupScriptRunnerLive), | 159 | Layer.provideMerge(ProjectSetupScriptRunnerLive), |
| 188 | Layer.provideMerge(GitCoreLive), | 160 | Layer.provideMerge(GitCoreLive), |
| 189 | Layer.provideMerge(GitHubCliLive), | 161 | Layer.provideMerge(GitHubCliLive), |
| 190 | Layer.provideMerge(RoutingTextGenerationLive), | 162 | Layer.provideMerge(TextGenerationLive), |
| 191 | ); | 163 | ); |
| 192 | 164 | ||
| 193 | const GitLayerLive = Layer.empty.pipe( | 165 | const GitLayerLive = Layer.empty.pipe( |
| ... | @@ -233,6 +205,24 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -233,6 +205,24 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 233 | Layer.provideMerge(PersistenceLayerLive), | 205 | Layer.provideMerge(PersistenceLayerLive), |
| 234 | Layer.provideMerge(KeybindingsLive), | 206 | Layer.provideMerge(KeybindingsLive), |
| 235 | Layer.provideMerge(ProviderRegistryLive), | 207 | Layer.provideMerge(ProviderRegistryLive), |
| 208 | // The instance registry is the new routing keystone — text generation, | ||
| 209 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` | ||
| 210 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; | ||
| 211 | // `providerInstances` hydration merges `settings.providers.<kind>` | ||
| 212 | // with explicit `providerInstances` entries on boot. | ||
| 213 | Layer.provideMerge(ProviderInstanceRegistryHydrationLive), | ||
| 214 | // Shared native/canonical NDJSON writers used by both the per-instance | ||
| 215 | // drivers (native stream, written from inside each `<X>Adapter`) and | ||
| 216 | // `ProviderService` (canonical stream, written after event normalization). | ||
| 217 | // Provided once at the runtime level so every consumer sees the same | ||
| 218 | // logger instances. | ||
| 219 | Layer.provideMerge(ProviderEventLoggersLive), | ||
| 220 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old | ||
| 221 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but | ||
| 222 | // the rewritten registry reads snapshots off the instance registry and | ||
| 223 | // no longer transitively provides it. Exposing it at the runtime level | ||
| 224 | // keeps a single Live for all opencode consumers. | ||
| 225 | Layer.provideMerge(OpenCodeRuntimeLive), | ||
| 236 | Layer.provideMerge(ServerSettingsLive), | 226 | Layer.provideMerge(ServerSettingsLive), |
| 237 | Layer.provideMerge(WorkspaceLayerLive), | 227 | Layer.provideMerge(WorkspaceLayerLive), |
| 238 | Layer.provideMerge(ProjectFaviconResolverLive), | 228 | Layer.provideMerge(ProjectFaviconResolverLive), |
apps/server/src/serverRuntimeStartup.test.ts+3-3| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; | 2 | import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { assert, it } from "@effect/vitest"; | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect"; | 4 | import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect"; |
| 5 | 5 | ||
| ... | @@ -21,8 +21,8 @@ import { | ... | @@ -21,8 +21,8 @@ import { |
| 21 | 21 | ||
| 22 | it("uses the canonical Codex default for auto-bootstrapped model selection", () => { | 22 | it("uses the canonical Codex default for auto-bootstrapped model selection", () => { |
| 23 | assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { | 23 | assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { |
| 24 | provider: "codex", | 24 | instanceId: ProviderInstanceId.make("codex"), |
| 25 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 25 | model: DEFAULT_MODEL, |
| 26 | }); | 26 | }); |
| 27 | }); | 27 | }); |
| 28 | 28 |
apps/server/src/serverRuntimeStartup.ts+4-3| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL, |
| 4 | DEFAULT_PROVIDER_INTERACTION_MODE, | 4 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 5 | type ModelSelection, | 5 | type ModelSelection, |
| 6 | ProjectId, | 6 | ProjectId, |
| 7 | ProviderInstanceId, | ||
| 7 | ThreadId, | 8 | ThreadId, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { | 10 | import { |
| ... | @@ -154,8 +155,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( | ... | @@ -154,8 +155,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( |
| 154 | ); | 155 | ); |
| 155 | 156 | ||
| 156 | export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ | 157 | export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ |
| 157 | provider: "codex", | 158 | instanceId: ProviderInstanceId.make("codex"), |
| 158 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 159 | model: DEFAULT_MODEL, |
| 159 | }); | 160 | }); |
| 160 | 161 | ||
| 161 | export const resolveWelcomeBase = Effect.gen(function* () { | 162 | export const resolveWelcomeBase = Effect.gen(function* () { |
apps/server/src/serverSettings.test.ts+222-20| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | 2 | import { |
| 3 | DEFAULT_SERVER_SETTINGS, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ServerSettings, | ||
| 7 | ServerSettingsPatch, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 3 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 4 | import { assert, it } from "@effect/vitest"; | 10 | import { assert, it } from "@effect/vitest"; |
| 5 | import { Effect, FileSystem, Layer, Schema } from "effect"; | 11 | import { Effect, FileSystem, Layer, Schema } from "effect"; |
| ... | @@ -49,14 +55,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -49,14 +55,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 49 | 55 | ||
| 50 | const decoded = decode({ | 56 | const decoded = decode({ |
| 51 | textGenerationModelSelection: { | 57 | textGenerationModelSelection: { |
| 52 | provider: "codex", | 58 | provider: ProviderDriverKind.make("codex"), |
| 53 | model: "gpt-5.4-mini", | 59 | model: "gpt-5.4-mini", |
| 54 | options: { reasoningEffort: "low" }, | 60 | options: { reasoningEffort: "low" }, |
| 55 | }, | 61 | }, |
| 56 | }); | 62 | }); |
| 57 | 63 | ||
| 58 | assert.deepEqual(decoded.textGenerationModelSelection, { | 64 | assert.deepEqual(decoded.textGenerationModelSelection, { |
| 59 | provider: "codex", | 65 | instanceId: ProviderInstanceId.make("codex"), |
| 60 | model: "gpt-5.4-mini", | 66 | model: "gpt-5.4-mini", |
| 61 | options: [{ id: "reasoningEffort", value: "low" }], | 67 | options: [{ id: "reasoningEffort", value: "low" }], |
| 62 | }); | 68 | }); |
| ... | @@ -79,10 +85,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -79,10 +85,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 79 | }, | 85 | }, |
| 80 | }, | 86 | }, |
| 81 | textGenerationModelSelection: { | 87 | textGenerationModelSelection: { |
| 82 | provider: "codex", | 88 | instanceId: ProviderInstanceId.make("codex"), |
| 83 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 89 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 84 | options: createModelSelection( | 90 | options: createModelSelection( |
| 85 | "codex", | 91 | ProviderInstanceId.make("codex"), |
| 86 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 92 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 87 | [ | 93 | [ |
| 88 | { id: "reasoningEffort", value: "high" }, | 94 | { id: "reasoningEffort", value: "high" }, |
| ... | @@ -107,20 +113,26 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -107,20 +113,26 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 107 | enabled: true, | 113 | enabled: true, |
| 108 | binaryPath: "/opt/homebrew/bin/codex", | 114 | binaryPath: "/opt/homebrew/bin/codex", |
| 109 | homePath: "/Users/julius/.codex", | 115 | homePath: "/Users/julius/.codex", |
| 116 | shadowHomePath: "", | ||
| 110 | customModels: [], | 117 | customModels: [], |
| 111 | }); | 118 | }); |
| 112 | assert.deepEqual(next.providers.claudeAgent, { | 119 | assert.deepEqual(next.providers.claudeAgent, { |
| 113 | enabled: true, | 120 | enabled: true, |
| 114 | binaryPath: "/usr/local/bin/claude", | 121 | binaryPath: "/usr/local/bin/claude", |
| 122 | homePath: "", | ||
| 115 | customModels: ["claude-custom"], | 123 | customModels: ["claude-custom"], |
| 116 | launchArgs: "", | 124 | launchArgs: "", |
| 117 | }); | 125 | }); |
| 118 | assert.deepEqual( | 126 | assert.deepEqual( |
| 119 | next.textGenerationModelSelection, | 127 | next.textGenerationModelSelection, |
| 120 | createModelSelection("codex", DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ | 128 | createModelSelection( |
| 121 | { id: "reasoningEffort", value: "high" }, | 129 | ProviderInstanceId.make("codex"), |
| 122 | { id: "fastMode", value: false }, | 130 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 123 | ]), | 131 | [ |
| 132 | { id: "reasoningEffort", value: "high" }, | ||
| 133 | { id: "fastMode", value: false }, | ||
| 134 | ], | ||
| 135 | ), | ||
| 124 | ); | 136 | ); |
| 125 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 137 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 126 | ); | 138 | ); |
| ... | @@ -132,11 +144,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -132,11 +144,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 132 | // Start with Claude text generation selection | 144 | // Start with Claude text generation selection |
| 133 | yield* serverSettings.updateSettings({ | 145 | yield* serverSettings.updateSettings({ |
| 134 | textGenerationModelSelection: { | 146 | textGenerationModelSelection: { |
| 135 | provider: "claudeAgent", | 147 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 136 | model: "claude-sonnet-4-6", | 148 | model: "claude-sonnet-4-6", |
| 137 | options: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | 149 | options: createModelSelection( |
| 138 | { id: "effort", value: "high" }, | 150 | ProviderInstanceId.make("claudeAgent"), |
| 139 | ]).options!, | 151 | "claude-sonnet-4-6", |
| 152 | [{ id: "effort", value: "high" }], | ||
| 153 | ).options!, | ||
| 140 | }, | 154 | }, |
| 141 | }); | 155 | }); |
| 142 | 156 | ||
| ... | @@ -144,9 +158,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -144,9 +158,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 144 | // cause the update to lose the selected model. | 158 | // cause the update to lose the selected model. |
| 145 | const next = yield* serverSettings.updateSettings({ | 159 | const next = yield* serverSettings.updateSettings({ |
| 146 | textGenerationModelSelection: { | 160 | textGenerationModelSelection: { |
| 147 | provider: "codex", | 161 | instanceId: ProviderInstanceId.make("codex"), |
| 148 | model: "gpt-5.4", | 162 | model: "gpt-5.4", |
| 149 | options: createModelSelection("codex", "gpt-5.4", [ | 163 | options: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 150 | { id: "reasoningEffort", value: "high" }, | 164 | { id: "reasoningEffort", value: "high" }, |
| 151 | ]).options!, | 165 | ]).options!, |
| 152 | }, | 166 | }, |
| ... | @@ -154,21 +168,107 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -154,21 +168,107 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 154 | 168 | ||
| 155 | assert.deepEqual( | 169 | assert.deepEqual( |
| 156 | next.textGenerationModelSelection, | 170 | next.textGenerationModelSelection, |
| 157 | createModelSelection("codex", "gpt-5.4", [{ id: "reasoningEffort", value: "high" }]), | 171 | createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 172 | { id: "reasoningEffort", value: "high" }, | ||
| 173 | ]), | ||
| 158 | ); | 174 | ); |
| 159 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 175 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 160 | ); | 176 | ); |
| 161 | 177 | ||
| 178 | it.effect("preserves custom provider instance text generation selections", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const serverSettings = yield* ServerSettingsService; | ||
| 181 | |||
| 182 | const next = yield* serverSettings.updateSettings({ | ||
| 183 | providerInstances: { | ||
| 184 | [ProviderInstanceId.make("claude_openrouter")]: { | ||
| 185 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 186 | enabled: true, | ||
| 187 | config: { customModels: ["openai/gpt-5.5"] }, | ||
| 188 | }, | ||
| 189 | }, | ||
| 190 | textGenerationModelSelection: { | ||
| 191 | instanceId: ProviderInstanceId.make("claude_openrouter"), | ||
| 192 | model: "openai/gpt-5.5", | ||
| 193 | }, | ||
| 194 | }); | ||
| 195 | |||
| 196 | assert.deepEqual(next.textGenerationModelSelection, { | ||
| 197 | instanceId: ProviderInstanceId.make("claude_openrouter"), | ||
| 198 | model: "openai/gpt-5.5", | ||
| 199 | }); | ||
| 200 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 201 | ); | ||
| 202 | |||
| 203 | it.effect( | ||
| 204 | "uses explicit provider instance enabled state over legacy provider enabled state", | ||
| 205 | () => | ||
| 206 | Effect.gen(function* () { | ||
| 207 | const serverSettings = yield* ServerSettingsService; | ||
| 208 | const instanceId = ProviderInstanceId.make("claude_openrouter"); | ||
| 209 | |||
| 210 | const next = yield* serverSettings.updateSettings({ | ||
| 211 | providers: { | ||
| 212 | claudeAgent: { | ||
| 213 | enabled: false, | ||
| 214 | }, | ||
| 215 | }, | ||
| 216 | providerInstances: { | ||
| 217 | [instanceId]: { | ||
| 218 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 219 | enabled: true, | ||
| 220 | config: { customModels: ["openai/gpt-5.5"] }, | ||
| 221 | }, | ||
| 222 | }, | ||
| 223 | textGenerationModelSelection: { | ||
| 224 | instanceId, | ||
| 225 | model: "openai/gpt-5.5", | ||
| 226 | }, | ||
| 227 | }); | ||
| 228 | |||
| 229 | assert.deepEqual(next.textGenerationModelSelection, { | ||
| 230 | instanceId, | ||
| 231 | model: "openai/gpt-5.5", | ||
| 232 | }); | ||
| 233 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 234 | ); | ||
| 235 | |||
| 236 | it.effect("preserves enabled text generation selections for non-built-in drivers", () => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | const serverSettings = yield* ServerSettingsService; | ||
| 239 | const instanceId = ProviderInstanceId.make("openrouter_text"); | ||
| 240 | |||
| 241 | const next = yield* serverSettings.updateSettings({ | ||
| 242 | providerInstances: { | ||
| 243 | [instanceId]: { | ||
| 244 | driver: ProviderDriverKind.make("openrouter"), | ||
| 245 | enabled: true, | ||
| 246 | config: { customModels: ["openai/gpt-5.5"] }, | ||
| 247 | }, | ||
| 248 | }, | ||
| 249 | textGenerationModelSelection: { | ||
| 250 | instanceId, | ||
| 251 | model: "openai/gpt-5.5", | ||
| 252 | }, | ||
| 253 | }); | ||
| 254 | |||
| 255 | assert.deepEqual(next.textGenerationModelSelection, { | ||
| 256 | instanceId, | ||
| 257 | model: "openai/gpt-5.5", | ||
| 258 | }); | ||
| 259 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 260 | ); | ||
| 261 | |||
| 162 | it.effect("drops stale text generation options when resetting model selection", () => | 262 | it.effect("drops stale text generation options when resetting model selection", () => |
| 163 | Effect.gen(function* () { | 263 | Effect.gen(function* () { |
| 164 | const serverSettings = yield* ServerSettingsService; | 264 | const serverSettings = yield* ServerSettingsService; |
| 165 | 265 | ||
| 166 | yield* serverSettings.updateSettings({ | 266 | yield* serverSettings.updateSettings({ |
| 167 | textGenerationModelSelection: { | 267 | textGenerationModelSelection: { |
| 168 | provider: "codex", | 268 | instanceId: ProviderInstanceId.make("codex"), |
| 169 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 269 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 170 | options: createModelSelection( | 270 | options: createModelSelection( |
| 171 | "codex", | 271 | ProviderInstanceId.make("codex"), |
| 172 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 272 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 173 | [ | 273 | [ |
| 174 | { id: "reasoningEffort", value: "high" }, | 274 | { id: "reasoningEffort", value: "high" }, |
| ... | @@ -180,18 +280,55 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -180,18 +280,55 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 180 | 280 | ||
| 181 | const next = yield* serverSettings.updateSettings({ | 281 | const next = yield* serverSettings.updateSettings({ |
| 182 | textGenerationModelSelection: { | 282 | textGenerationModelSelection: { |
| 183 | provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, | 283 | instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, |
| 184 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 284 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 185 | }, | 285 | }, |
| 186 | }); | 286 | }); |
| 187 | 287 | ||
| 188 | assert.deepEqual(next.textGenerationModelSelection, { | 288 | assert.deepEqual(next.textGenerationModelSelection, { |
| 189 | provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, | 289 | instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, |
| 190 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 290 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 191 | }); | 291 | }); |
| 192 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 292 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 193 | ); | 293 | ); |
| 194 | 294 | ||
| 295 | it.effect("replaces provider instance maps when clearing optional fields", () => | ||
| 296 | Effect.gen(function* () { | ||
| 297 | const serverSettings = yield* ServerSettingsService; | ||
| 298 | const codexId = ProviderInstanceId.make("codex"); | ||
| 299 | |||
| 300 | yield* serverSettings.updateSettings({ | ||
| 301 | providerInstances: { | ||
| 302 | [codexId]: { | ||
| 303 | driver: ProviderDriverKind.make("codex"), | ||
| 304 | displayName: "Codex Work", | ||
| 305 | accentColor: "#7c3aed", | ||
| 306 | enabled: true, | ||
| 307 | config: { homePath: "~/.codex" }, | ||
| 308 | }, | ||
| 309 | }, | ||
| 310 | }); | ||
| 311 | |||
| 312 | const next = yield* serverSettings.updateSettings({ | ||
| 313 | providerInstances: { | ||
| 314 | [codexId]: { | ||
| 315 | driver: ProviderDriverKind.make("codex"), | ||
| 316 | displayName: "Codex Work", | ||
| 317 | enabled: true, | ||
| 318 | config: { homePath: "~/.codex" }, | ||
| 319 | }, | ||
| 320 | }, | ||
| 321 | }); | ||
| 322 | |||
| 323 | assert.deepEqual(next.providerInstances[codexId], { | ||
| 324 | driver: ProviderDriverKind.make("codex"), | ||
| 325 | displayName: "Codex Work", | ||
| 326 | enabled: true, | ||
| 327 | config: { homePath: "~/.codex" }, | ||
| 328 | }); | ||
| 329 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 330 | ); | ||
| 331 | |||
| 195 | it.effect("trims provider path settings when updates are applied", () => | 332 | it.effect("trims provider path settings when updates are applied", () => |
| 196 | Effect.gen(function* () { | 333 | Effect.gen(function* () { |
| 197 | const serverSettings = yield* ServerSettingsService; | 334 | const serverSettings = yield* ServerSettingsService; |
| ... | @@ -217,11 +354,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -217,11 +354,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 217 | enabled: true, | 354 | enabled: true, |
| 218 | binaryPath: "/opt/homebrew/bin/codex", | 355 | binaryPath: "/opt/homebrew/bin/codex", |
| 219 | homePath: "", | 356 | homePath: "", |
| 357 | shadowHomePath: "", | ||
| 220 | customModels: [], | 358 | customModels: [], |
| 221 | }); | 359 | }); |
| 222 | assert.deepEqual(next.providers.claudeAgent, { | 360 | assert.deepEqual(next.providers.claudeAgent, { |
| 223 | enabled: true, | 361 | enabled: true, |
| 224 | binaryPath: "/opt/homebrew/bin/claude", | 362 | binaryPath: "/opt/homebrew/bin/claude", |
| 363 | homePath: "", | ||
| 225 | customModels: [], | 364 | customModels: [], |
| 226 | launchArgs: "", | 365 | launchArgs: "", |
| 227 | }); | 366 | }); |
| ... | @@ -318,4 +457,67 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -318,4 +457,67 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 318 | }); | 457 | }); |
| 319 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 458 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 320 | ); | 459 | ); |
| 460 | |||
| 461 | it.effect("stores sensitive provider instance environment values outside settings.json", () => | ||
| 462 | Effect.gen(function* () { | ||
| 463 | const serverSettings = yield* ServerSettingsService; | ||
| 464 | const serverConfig = yield* ServerConfig; | ||
| 465 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 466 | const instanceId = ProviderInstanceId.make("codex_personal"); | ||
| 467 | |||
| 468 | const next = yield* serverSettings.updateSettings({ | ||
| 469 | providerInstances: { | ||
| 470 | [instanceId]: { | ||
| 471 | driver: ProviderDriverKind.make("codex"), | ||
| 472 | environment: [ | ||
| 473 | { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true }, | ||
| 474 | { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, | ||
| 475 | ], | ||
| 476 | config: {}, | ||
| 477 | }, | ||
| 478 | }, | ||
| 479 | }); | ||
| 480 | |||
| 481 | assert.deepEqual(next.providerInstances[instanceId]?.environment, [ | ||
| 482 | { | ||
| 483 | name: "OPENROUTER_API_KEY", | ||
| 484 | value: "sk-or-secret", | ||
| 485 | sensitive: true, | ||
| 486 | valueRedacted: true, | ||
| 487 | }, | ||
| 488 | { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, | ||
| 489 | ]); | ||
| 490 | |||
| 491 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | ||
| 492 | assert.notInclude(raw, "sk-or-secret"); | ||
| 493 | assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [ | ||
| 494 | { | ||
| 495 | name: "OPENROUTER_API_KEY", | ||
| 496 | value: "", | ||
| 497 | sensitive: true, | ||
| 498 | valueRedacted: true, | ||
| 499 | }, | ||
| 500 | { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, | ||
| 501 | ]); | ||
| 502 | |||
| 503 | const roundTripped = yield* serverSettings.updateSettings({ | ||
| 504 | providerInstances: { | ||
| 505 | [instanceId]: { | ||
| 506 | driver: ProviderDriverKind.make("codex"), | ||
| 507 | displayName: "Codex Personal", | ||
| 508 | environment: [ | ||
| 509 | { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true }, | ||
| 510 | { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, | ||
| 511 | ], | ||
| 512 | config: {}, | ||
| 513 | }, | ||
| 514 | }, | ||
| 515 | }); | ||
| 516 | |||
| 517 | assert.equal( | ||
| 518 | roundTripped.providerInstances[instanceId]?.environment?.[0]?.value, | ||
| 519 | "sk-or-secret", | ||
| 520 | ); | ||
| 521 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 522 | ); | ||
| 321 | }); | 523 | }); |
apps/server/src/serverSettings.ts+232-14| ... | @@ -11,10 +11,15 @@ | ... | @@ -11,10 +11,15 @@ |
| 11 | * @module ServerSettings | 11 | * @module ServerSettings |
| 12 | */ | 12 | */ |
| 13 | import { | 13 | import { |
| 14 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | ||
| 14 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 15 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 15 | DEFAULT_SERVER_SETTINGS, | 16 | DEFAULT_SERVER_SETTINGS, |
| 17 | isProviderDriverKind, | ||
| 16 | type ModelSelection, | 18 | type ModelSelection, |
| 17 | type ProviderKind, | 19 | type ProviderInstanceConfig, |
| 20 | type ProviderInstanceEnvironmentVariable, | ||
| 21 | ProviderDriverKind, | ||
| 22 | ProviderInstanceId, | ||
| 18 | ServerSettings, | 23 | ServerSettings, |
| 19 | ServerSettingsError, | 24 | ServerSettingsError, |
| 20 | type ServerSettingsPatch, | 25 | type ServerSettingsPatch, |
| ... | @@ -44,6 +49,47 @@ import { ServerConfig } from "./config.ts"; | ... | @@ -44,6 +49,47 @@ import { ServerConfig } from "./config.ts"; |
| 44 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; | 49 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 45 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 50 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 46 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 51 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 52 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | ||
| 53 | import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; | ||
| 54 | |||
| 55 | const textEncoder = new TextEncoder(); | ||
| 56 | const textDecoder = new TextDecoder(); | ||
| 57 | |||
| 58 | function providerEnvironmentSecretName(input: { | ||
| 59 | readonly instanceId: string; | ||
| 60 | readonly name: string; | ||
| 61 | }): string { | ||
| 62 | return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; | ||
| 63 | } | ||
| 64 | |||
| 65 | function redactProviderEnvironmentVariable( | ||
| 66 | variable: ProviderInstanceEnvironmentVariable, | ||
| 67 | ): ProviderInstanceEnvironmentVariable { | ||
| 68 | if (!variable.sensitive) { | ||
| 69 | const { valueRedacted: _omit, ...rest } = variable; | ||
| 70 | return rest; | ||
| 71 | } | ||
| 72 | return { | ||
| 73 | ...variable, | ||
| 74 | value: "", | ||
| 75 | ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), | ||
| 76 | }; | ||
| 77 | } | ||
| 78 | |||
| 79 | export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { | ||
| 80 | const providerInstances = Object.fromEntries( | ||
| 81 | Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ | ||
| 82 | instanceId, | ||
| 83 | instance.environment | ||
| 84 | ? { | ||
| 85 | ...instance, | ||
| 86 | environment: instance.environment.map(redactProviderEnvironmentVariable), | ||
| 87 | } | ||
| 88 | : instance, | ||
| 89 | ]), | ||
| 90 | ); | ||
| 91 | return { ...settings, providerInstances }; | ||
| 92 | } | ||
| 47 | 93 | ||
| 48 | export interface ServerSettingsShape { | 94 | export interface ServerSettingsShape { |
| 49 | /** Start the settings runtime and attach file watching. */ | 95 | /** Start the settings runtime and attach file watching. */ |
| ... | @@ -106,7 +152,13 @@ export class ServerSettingsService extends Context.Service< | ... | @@ -106,7 +152,13 @@ export class ServerSettingsService extends Context.Service< |
| 106 | 152 | ||
| 107 | const ServerSettingsJson = fromLenientJson(ServerSettings); | 153 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 108 | 154 | ||
| 109 | const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"]; | 155 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; |
| 156 | |||
| 157 | const getLegacyProviderSettings = ( | ||
| 158 | settings: ServerSettings, | ||
| 159 | provider: ProviderDriverKind, | ||
| 160 | ): LegacyProviderSettings | undefined => | ||
| 161 | (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider]; | ||
| 110 | 162 | ||
| 111 | /** | 163 | /** |
| 112 | * Ensure the `textGenerationModelSelection` points to an enabled provider. | 164 | * Ensure the `textGenerationModelSelection` points to an enabled provider. |
| ... | @@ -116,22 +168,36 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "openco | ... | @@ -116,22 +168,36 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "openco |
| 116 | */ | 168 | */ |
| 117 | function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { | 169 | function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 118 | const selection = settings.textGenerationModelSelection; | 170 | const selection = settings.textGenerationModelSelection; |
| 119 | if (settings.providers[selection.provider].enabled) { | 171 | const instanceConfig = settings.providerInstances[selection.instanceId]; |
| 172 | if (instanceConfig !== undefined) { | ||
| 173 | return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); | ||
| 174 | } | ||
| 175 | |||
| 176 | if ( | ||
| 177 | isProviderDriverKind(selection.instanceId) && | ||
| 178 | getLegacyProviderSettings(settings, selection.instanceId)?.enabled | ||
| 179 | ) { | ||
| 120 | return settings; | 180 | return settings; |
| 121 | } | 181 | } |
| 122 | 182 | ||
| 123 | const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); | 183 | return fallbackTextGenerationProvider(settings); |
| 184 | } | ||
| 185 | |||
| 186 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { | ||
| 187 | const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); | ||
| 188 | const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; | ||
| 124 | if (!fallback) { | 189 | if (!fallback) { |
| 125 | // No providers enabled — return as-is; callers will report the error. | ||
| 126 | return settings; | 190 | return settings; |
| 127 | } | 191 | } |
| 128 | 192 | ||
| 129 | return { | 193 | return { |
| 130 | ...settings, | 194 | ...settings, |
| 131 | textGenerationModelSelection: { | 195 | textGenerationModelSelection: { |
| 132 | provider: fallback, | 196 | instanceId: ProviderInstanceId.make(fallback), |
| 133 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], | 197 | model: |
| 134 | } as ModelSelection, | 198 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? |
| 199 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | ||
| 200 | } satisfies ModelSelection, | ||
| 135 | }; | 201 | }; |
| 136 | } | 202 | } |
| 137 | 203 | ||
| ... | @@ -176,6 +242,7 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -176,6 +242,7 @@ const makeServerSettings = Effect.gen(function* () { |
| 176 | const { settingsPath } = yield* ServerConfig; | 242 | const { settingsPath } = yield* ServerConfig; |
| 177 | const fs = yield* FileSystem.FileSystem; | 243 | const fs = yield* FileSystem.FileSystem; |
| 178 | const pathService = yield* Path.Path; | 244 | const pathService = yield* Path.Path; |
| 245 | const secretStore = yield* ServerSecretStore; | ||
| 179 | const writeSemaphore = yield* Semaphore.make(1); | 246 | const writeSemaphore = yield* Semaphore.make(1); |
| 180 | const cacheKey = "settings" as const; | 247 | const cacheKey = "settings" as const; |
| 181 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); | 248 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); |
| ... | @@ -233,6 +300,138 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -233,6 +300,138 @@ const makeServerSettings = Effect.gen(function* () { |
| 233 | 300 | ||
| 234 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); | 301 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); |
| 235 | 302 | ||
| 303 | const toSettingsError = (detail: string, cause: unknown) => | ||
| 304 | new ServerSettingsError({ | ||
| 305 | settingsPath, | ||
| 306 | detail, | ||
| 307 | cause, | ||
| 308 | }); | ||
| 309 | |||
| 310 | const materializeProviderEnvironmentSecrets = ( | ||
| 311 | settings: ServerSettings, | ||
| 312 | ): Effect.Effect<ServerSettings, ServerSettingsError> => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | const providerInstances: Record<string, ProviderInstanceConfig> = { | ||
| 315 | ...settings.providerInstances, | ||
| 316 | }; | ||
| 317 | for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { | ||
| 318 | if (!instance.environment) continue; | ||
| 319 | const environment: ProviderInstanceEnvironmentVariable[] = []; | ||
| 320 | for (const variable of instance.environment) { | ||
| 321 | if (!variable.sensitive || !variable.valueRedacted) { | ||
| 322 | environment.push(variable); | ||
| 323 | continue; | ||
| 324 | } | ||
| 325 | const secret = yield* secretStore | ||
| 326 | .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) | ||
| 327 | .pipe( | ||
| 328 | Effect.mapError((cause) => | ||
| 329 | toSettingsError( | ||
| 330 | `failed to read sensitive environment variable ${variable.name}`, | ||
| 331 | cause, | ||
| 332 | ), | ||
| 333 | ), | ||
| 334 | ); | ||
| 335 | environment.push({ | ||
| 336 | ...variable, | ||
| 337 | value: secret ? textDecoder.decode(secret) : "", | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | providerInstances[instanceId] = { | ||
| 341 | ...instance, | ||
| 342 | environment, | ||
| 343 | } satisfies ProviderInstanceConfig; | ||
| 344 | } | ||
| 345 | return { | ||
| 346 | ...settings, | ||
| 347 | providerInstances: providerInstances as ServerSettings["providerInstances"], | ||
| 348 | }; | ||
| 349 | }); | ||
| 350 | |||
| 351 | const persistProviderEnvironmentSecrets = ( | ||
| 352 | current: ServerSettings, | ||
| 353 | next: ServerSettings, | ||
| 354 | ): Effect.Effect<ServerSettings, ServerSettingsError> => | ||
| 355 | Effect.gen(function* () { | ||
| 356 | const providerInstances: Record<string, ProviderInstanceConfig> = { | ||
| 357 | ...next.providerInstances, | ||
| 358 | }; | ||
| 359 | |||
| 360 | const nextSecretKeys = new Set<string>(); | ||
| 361 | for (const [instanceId, instance] of Object.entries(next.providerInstances)) { | ||
| 362 | if (!instance.environment) continue; | ||
| 363 | const environment: ProviderInstanceEnvironmentVariable[] = []; | ||
| 364 | for (const variable of instance.environment) { | ||
| 365 | const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); | ||
| 366 | if (!variable.sensitive) { | ||
| 367 | yield* secretStore | ||
| 368 | .remove(secretName) | ||
| 369 | .pipe( | ||
| 370 | Effect.mapError((cause) => | ||
| 371 | toSettingsError(`failed to remove environment secret ${variable.name}`, cause), | ||
| 372 | ), | ||
| 373 | ); | ||
| 374 | environment.push(redactProviderEnvironmentVariable(variable)); | ||
| 375 | continue; | ||
| 376 | } | ||
| 377 | |||
| 378 | nextSecretKeys.add(secretName); | ||
| 379 | if (!variable.valueRedacted) { | ||
| 380 | if (variable.value.length > 0) { | ||
| 381 | yield* secretStore | ||
| 382 | .set(secretName, textEncoder.encode(variable.value)) | ||
| 383 | .pipe( | ||
| 384 | Effect.mapError((cause) => | ||
| 385 | toSettingsError(`failed to persist environment secret ${variable.name}`, cause), | ||
| 386 | ), | ||
| 387 | ); | ||
| 388 | environment.push({ ...variable, value: "", valueRedacted: true }); | ||
| 389 | } else { | ||
| 390 | yield* secretStore | ||
| 391 | .remove(secretName) | ||
| 392 | .pipe( | ||
| 393 | Effect.mapError((cause) => | ||
| 394 | toSettingsError(`failed to remove environment secret ${variable.name}`, cause), | ||
| 395 | ), | ||
| 396 | ); | ||
| 397 | const { valueRedacted: _omit, ...rest } = variable; | ||
| 398 | environment.push(rest); | ||
| 399 | } | ||
| 400 | continue; | ||
| 401 | } | ||
| 402 | |||
| 403 | environment.push(redactProviderEnvironmentVariable(variable)); | ||
| 404 | } | ||
| 405 | providerInstances[instanceId] = { | ||
| 406 | ...instance, | ||
| 407 | environment, | ||
| 408 | } satisfies ProviderInstanceConfig; | ||
| 409 | } | ||
| 410 | |||
| 411 | for (const [instanceId, instance] of Object.entries(current.providerInstances)) { | ||
| 412 | for (const variable of instance.environment ?? []) { | ||
| 413 | if (!variable.sensitive) continue; | ||
| 414 | const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); | ||
| 415 | if (nextSecretKeys.has(secretName)) continue; | ||
| 416 | yield* secretStore | ||
| 417 | .remove(secretName) | ||
| 418 | .pipe( | ||
| 419 | Effect.mapError((cause) => | ||
| 420 | toSettingsError( | ||
| 421 | `failed to remove stale environment secret ${variable.name}`, | ||
| 422 | cause, | ||
| 423 | ), | ||
| 424 | ), | ||
| 425 | ); | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | return { | ||
| 430 | ...next, | ||
| 431 | providerInstances: providerInstances as ServerSettings["providerInstances"], | ||
| 432 | }; | ||
| 433 | }); | ||
| 434 | |||
| 236 | const writeSettingsAtomically = (settings: ServerSettings) => { | 435 | const writeSettingsAtomically = (settings: ServerSettings) => { |
| 237 | const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; | 436 | const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; |
| 238 | 437 | ||
| ... | @@ -324,14 +523,19 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -324,14 +523,19 @@ const makeServerSettings = Effect.gen(function* () { |
| 324 | return { | 523 | return { |
| 325 | start, | 524 | start, |
| 326 | ready: Deferred.await(startedDeferred), | 525 | ready: Deferred.await(startedDeferred), |
| 327 | getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), | 526 | getSettings: getSettingsFromCache.pipe( |
| 527 | Effect.flatMap(materializeProviderEnvironmentSecrets), | ||
| 528 | Effect.map(resolveTextGenerationProvider), | ||
| 529 | ), | ||
| 328 | updateSettings: (patch) => | 530 | updateSettings: (patch) => |
| 329 | writeSemaphore.withPermits(1)( | 531 | writeSemaphore.withPermits(1)( |
| 330 | Effect.gen(function* () { | 532 | Effect.gen(function* () { |
| 331 | const current = yield* getSettingsFromCache; | 533 | const current = yield* getSettingsFromCache; |
| 332 | const next = yield* Schema.decodeEffect(ServerSettings)( | 534 | const nextPersisted = yield* persistProviderEnvironmentSecrets( |
| 535 | current, | ||
| 333 | applyServerSettingsPatch(current, patch), | 536 | applyServerSettingsPatch(current, patch), |
| 334 | ).pipe( | 537 | ); |
| 538 | const next = yield* Schema.decodeEffect(ServerSettings)(nextPersisted).pipe( | ||
| 335 | Effect.mapError( | 539 | Effect.mapError( |
| 336 | (cause) => | 540 | (cause) => |
| 337 | new ServerSettingsError({ | 541 | new ServerSettingsError({ |
| ... | @@ -344,13 +548,27 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -344,13 +548,27 @@ const makeServerSettings = Effect.gen(function* () { |
| 344 | yield* writeSettingsAtomically(next); | 548 | yield* writeSettingsAtomically(next); |
| 345 | yield* Cache.set(settingsCache, cacheKey, next); | 549 | yield* Cache.set(settingsCache, cacheKey, next); |
| 346 | yield* emitChange(next); | 550 | yield* emitChange(next); |
| 347 | return resolveTextGenerationProvider(next); | 551 | const materialized = yield* materializeProviderEnvironmentSecrets(next); |
| 552 | return resolveTextGenerationProvider(materialized); | ||
| 348 | }), | 553 | }), |
| 349 | ), | 554 | ), |
| 350 | get streamChanges() { | 555 | get streamChanges() { |
| 351 | return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); | 556 | return Stream.fromPubSub(changesPubSub).pipe( |
| 557 | Stream.mapEffect((settings) => | ||
| 558 | materializeProviderEnvironmentSecrets(settings).pipe( | ||
| 559 | Effect.catch((error: ServerSettingsError) => | ||
| 560 | Effect.logWarning("failed to materialize provider environment secrets", { | ||
| 561 | detail: error.detail, | ||
| 562 | }).pipe(Effect.as(settings)), | ||
| 563 | ), | ||
| 564 | ), | ||
| 565 | ), | ||
| 566 | Stream.map(resolveTextGenerationProvider), | ||
| 567 | ); | ||
| 352 | }, | 568 | }, |
| 353 | } satisfies ServerSettingsShape; | 569 | } satisfies ServerSettingsShape; |
| 354 | }); | 570 | }); |
| 355 | 571 | ||
| 356 | export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); | 572 | export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( |
| 573 | Layer.provide(ServerSecretStoreLive), | ||
| 574 | ); |
apps/server/src/ws.ts+25-17| ... | @@ -45,7 +45,7 @@ import { | ... | @@ -45,7 +45,7 @@ import { |
| 45 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; | 45 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; |
| 46 | import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; | 46 | import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 47 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; | 47 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 48 | import { ServerSettingsService } from "./serverSettings.ts"; | 48 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 49 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 49 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 50 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 50 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 51 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 51 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| ... | @@ -512,7 +512,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -512,7 +512,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 512 | const loadServerConfig = Effect.gen(function* () { | 512 | const loadServerConfig = Effect.gen(function* () { |
| 513 | const keybindingsConfig = yield* keybindings.loadConfigState; | 513 | const keybindingsConfig = yield* keybindings.loadConfigState; |
| 514 | const providers = yield* providerRegistry.getProviders; | 514 | const providers = yield* providerRegistry.getProviders; |
| 515 | const settings = yield* serverSettings.getSettings; | 515 | const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); |
| 516 | const environment = yield* serverEnvironment.getDescriptor; | 516 | const environment = yield* serverEnvironment.getDescriptor; |
| 517 | const auth = yield* serverAuth.getDescriptor(); | 517 | const auth = yield* serverAuth.getDescriptor(); |
| 518 | 518 | ||
| ... | @@ -749,10 +749,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -749,10 +749,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 749 | observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { | 749 | observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { |
| 750 | "rpc.aggregate": "server", | 750 | "rpc.aggregate": "server", |
| 751 | }), | 751 | }), |
| 752 | [WS_METHODS.serverRefreshProviders]: (_input) => | 752 | [WS_METHODS.serverRefreshProviders]: (input) => |
| 753 | observeRpcEffect( | 753 | observeRpcEffect( |
| 754 | WS_METHODS.serverRefreshProviders, | 754 | WS_METHODS.serverRefreshProviders, |
| 755 | providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), | 755 | (input.instanceId !== undefined |
| 756 | ? providerRegistry.refreshInstance(input.instanceId) | ||
| 757 | : providerRegistry.refresh() | ||
| 758 | ).pipe(Effect.map((providers) => ({ providers }))), | ||
| 756 | { "rpc.aggregate": "server" }, | 759 | { "rpc.aggregate": "server" }, |
| 757 | ), | 760 | ), |
| 758 | [WS_METHODS.serverUpsertKeybinding]: (rule) => | 761 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| ... | @@ -765,13 +768,21 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -765,13 +768,21 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 765 | { "rpc.aggregate": "server" }, | 768 | { "rpc.aggregate": "server" }, |
| 766 | ), | 769 | ), |
| 767 | [WS_METHODS.serverGetSettings]: (_input) => | 770 | [WS_METHODS.serverGetSettings]: (_input) => |
| 768 | observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { | 771 | observeRpcEffect( |
| 769 | "rpc.aggregate": "server", | 772 | WS_METHODS.serverGetSettings, |
| 770 | }), | 773 | serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), |
| 774 | { | ||
| 775 | "rpc.aggregate": "server", | ||
| 776 | }, | ||
| 777 | ), | ||
| 771 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => | 778 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => |
| 772 | observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { | 779 | observeRpcEffect( |
| 773 | "rpc.aggregate": "server", | 780 | WS_METHODS.serverUpdateSettings, |
| 774 | }), | 781 | serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), |
| 782 | { | ||
| 783 | "rpc.aggregate": "server", | ||
| 784 | }, | ||
| 785 | ), | ||
| 775 | [WS_METHODS.projectsSearchEntries]: (input) => | 786 | [WS_METHODS.projectsSearchEntries]: (input) => |
| 776 | observeRpcEffect( | 787 | observeRpcEffect( |
| 777 | WS_METHODS.projectsSearchEntries, | 788 | WS_METHODS.projectsSearchEntries, |
| ... | @@ -976,6 +987,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -976,6 +987,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 976 | Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), | 987 | Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), |
| 977 | ); | 988 | ); |
| 978 | const settingsUpdates = serverSettings.streamChanges.pipe( | 989 | const settingsUpdates = serverSettings.streamChanges.pipe( |
| 990 | Stream.map((settings) => redactServerSettingsForClient(settings)), | ||
| 979 | Stream.map((settings) => ({ | 991 | Stream.map((settings) => ({ |
| 980 | version: 1 as const, | 992 | version: 1 as const, |
| 981 | type: "settingsUpdated" as const, | 993 | type: "settingsUpdated" as const, |
| ... | @@ -983,13 +995,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -983,13 +995,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 983 | })), | 995 | })), |
| 984 | ); | 996 | ); |
| 985 | 997 | ||
| 986 | yield* Effect.all( | 998 | yield* providerRegistry |
| 987 | [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")], | 999 | .refresh() |
| 988 | { | 1000 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); |
| 989 | concurrency: "unbounded", | ||
| 990 | discard: true, | ||
| 991 | }, | ||
| 992 | ).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); | ||
| 993 | 1001 | ||
| 994 | const liveUpdates = Stream.merge( | 1002 | const liveUpdates = Stream.merge( |
| 995 | keybindingsUpdates, | 1003 | keybindingsUpdates, |
apps/web/src/components/ChatView.browser.tsx+184-37| ... | @@ -9,6 +9,8 @@ import { | ... | @@ -9,6 +9,8 @@ import { |
| 9 | type MessageId, | 9 | type MessageId, |
| 10 | type OrchestrationReadModel, | 10 | type OrchestrationReadModel, |
| 11 | type ProjectId, | 11 | type ProjectId, |
| 12 | ProviderDriverKind, | ||
| 13 | ProviderInstanceId, | ||
| 12 | type ServerConfig, | 14 | type ServerConfig, |
| 13 | type ServerLifecycleWelcomePayload, | 15 | type ServerLifecycleWelcomePayload, |
| 14 | type ThreadId, | 16 | type ThreadId, |
| ... | @@ -167,7 +169,8 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -167,7 +169,8 @@ function createBaseServerConfig(): ServerConfig { |
| 167 | issues: [], | 169 | issues: [], |
| 168 | providers: [ | 170 | providers: [ |
| 169 | { | 171 | { |
| 170 | provider: "codex", | 172 | driver: ProviderDriverKind.make("codex"), |
| 173 | instanceId: ProviderInstanceId.make("codex"), | ||
| 171 | enabled: true, | 174 | enabled: true, |
| 172 | installed: true, | 175 | installed: true, |
| 173 | version: "0.116.0", | 176 | version: "0.116.0", |
| ... | @@ -323,7 +326,7 @@ function createSnapshotForTargetUser(options: { | ... | @@ -323,7 +326,7 @@ function createSnapshotForTargetUser(options: { |
| 323 | title: "Project", | 326 | title: "Project", |
| 324 | workspaceRoot: "/repo/project", | 327 | workspaceRoot: "/repo/project", |
| 325 | defaultModelSelection: { | 328 | defaultModelSelection: { |
| 326 | provider: "codex", | 329 | instanceId: ProviderInstanceId.make("codex"), |
| 327 | model: "gpt-5", | 330 | model: "gpt-5", |
| 328 | }, | 331 | }, |
| 329 | scripts: [], | 332 | scripts: [], |
| ... | @@ -338,7 +341,7 @@ function createSnapshotForTargetUser(options: { | ... | @@ -338,7 +341,7 @@ function createSnapshotForTargetUser(options: { |
| 338 | projectId: PROJECT_ID, | 341 | projectId: PROJECT_ID, |
| 339 | title: THREAD_TITLE, | 342 | title: THREAD_TITLE, |
| 340 | modelSelection: { | 343 | modelSelection: { |
| 341 | provider: "codex", | 344 | instanceId: ProviderInstanceId.make("codex"), |
| 342 | model: "gpt-5", | 345 | model: "gpt-5", |
| 343 | }, | 346 | }, |
| 344 | interactionMode: "default", | 347 | interactionMode: "default", |
| ... | @@ -403,7 +406,7 @@ function addThreadToSnapshot( | ... | @@ -403,7 +406,7 @@ function addThreadToSnapshot( |
| 403 | projectId: PROJECT_ID, | 406 | projectId: PROJECT_ID, |
| 404 | title: "New thread", | 407 | title: "New thread", |
| 405 | modelSelection: { | 408 | modelSelection: { |
| 406 | provider: "codex", | 409 | instanceId: ProviderInstanceId.make("codex"), |
| 407 | model: "gpt-5", | 410 | model: "gpt-5", |
| 408 | }, | 411 | }, |
| 409 | interactionMode: "default", | 412 | interactionMode: "default", |
| ... | @@ -740,7 +743,7 @@ function createSnapshotWithSecondaryProject(options?: { | ... | @@ -740,7 +743,7 @@ function createSnapshotWithSecondaryProject(options?: { |
| 740 | id: "thread-secondary-project" as ThreadId, | 743 | id: "thread-secondary-project" as ThreadId, |
| 741 | projectId: SECOND_PROJECT_ID, | 744 | projectId: SECOND_PROJECT_ID, |
| 742 | title: "Release checklist", | 745 | title: "Release checklist", |
| 743 | modelSelection: { provider: "codex", model: "gpt-5" }, | 746 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 744 | interactionMode: "default", | 747 | interactionMode: "default", |
| 745 | runtimeMode: "full-access", | 748 | runtimeMode: "full-access", |
| 746 | branch: "release/docs-portal", | 749 | branch: "release/docs-portal", |
| ... | @@ -772,7 +775,7 @@ function createSnapshotWithSecondaryProject(options?: { | ... | @@ -772,7 +775,7 @@ function createSnapshotWithSecondaryProject(options?: { |
| 772 | id: ARCHIVED_SECONDARY_THREAD_ID, | 775 | id: ARCHIVED_SECONDARY_THREAD_ID, |
| 773 | projectId: SECOND_PROJECT_ID, | 776 | projectId: SECOND_PROJECT_ID, |
| 774 | title: "Archived Docs Notes", | 777 | title: "Archived Docs Notes", |
| 775 | modelSelection: { provider: "codex", model: "gpt-5" }, | 778 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 776 | interactionMode: "default", | 779 | interactionMode: "default", |
| 777 | runtimeMode: "full-access", | 780 | runtimeMode: "full-access", |
| 778 | branch: "release/docs-archive", | 781 | branch: "release/docs-archive", |
| ... | @@ -807,7 +810,7 @@ function createSnapshotWithSecondaryProject(options?: { | ... | @@ -807,7 +810,7 @@ function createSnapshotWithSecondaryProject(options?: { |
| 807 | id: SECOND_PROJECT_ID, | 810 | id: SECOND_PROJECT_ID, |
| 808 | title: "Docs Portal", | 811 | title: "Docs Portal", |
| 809 | workspaceRoot: "/repo/clients/docs-portal", | 812 | workspaceRoot: "/repo/clients/docs-portal", |
| 810 | defaultModelSelection: { provider: "codex", model: "gpt-5" }, | 813 | defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 811 | scripts: [], | 814 | scripts: [], |
| 812 | createdAt: NOW_ISO, | 815 | createdAt: NOW_ISO, |
| 813 | updatedAt: NOW_ISO, | 816 | updatedAt: NOW_ISO, |
| ... | @@ -884,7 +887,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { | ... | @@ -884,7 +887,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { |
| 884 | } | 887 | } |
| 885 | 888 | ||
| 886 | function createSnapshotWithPlanFollowUpPrompt(options?: { | 889 | function createSnapshotWithPlanFollowUpPrompt(options?: { |
| 887 | modelSelection?: { provider: "codex"; model: string }; | 890 | modelSelection?: { instanceId: ProviderInstanceId; model: string }; |
| 888 | planMarkdown?: string; | 891 | planMarkdown?: string; |
| 889 | }): OrchestrationReadModel { | 892 | }): OrchestrationReadModel { |
| 890 | const snapshot = createSnapshotForTargetUser({ | 893 | const snapshot = createSnapshotForTargetUser({ |
| ... | @@ -892,7 +895,7 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { | ... | @@ -892,7 +895,7 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { |
| 892 | targetText: "plan follow-up thread", | 895 | targetText: "plan follow-up thread", |
| 893 | }); | 896 | }); |
| 894 | const modelSelection = options?.modelSelection ?? { | 897 | const modelSelection = options?.modelSelection ?? { |
| 895 | provider: "codex" as const, | 898 | instanceId: ProviderInstanceId.make("codex"), |
| 896 | model: "gpt-5", | 899 | model: "gpt-5", |
| 897 | }; | 900 | }; |
| 898 | const planMarkdown = | 901 | const planMarkdown = |
| ... | @@ -2438,6 +2441,126 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2438,6 +2441,126 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2438 | } | 2441 | } |
| 2439 | }); | 2442 | }); |
| 2440 | 2443 | ||
| 2444 | it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { | ||
| 2445 | setDraftThreadWithoutWorktree(); | ||
| 2446 | const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); | ||
| 2447 | const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); | ||
| 2448 | useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); | ||
| 2449 | |||
| 2450 | const mounted = await mountChatView({ | ||
| 2451 | viewport: DEFAULT_VIEWPORT, | ||
| 2452 | snapshot: createDraftOnlySnapshot(), | ||
| 2453 | configureFixture: (nextFixture) => { | ||
| 2454 | nextFixture.serverConfig = { | ||
| 2455 | ...nextFixture.serverConfig, | ||
| 2456 | providers: [ | ||
| 2457 | ...nextFixture.serverConfig.providers, | ||
| 2458 | { | ||
| 2459 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 2460 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 2461 | enabled: true, | ||
| 2462 | installed: true, | ||
| 2463 | version: "2.1.117", | ||
| 2464 | status: "ready", | ||
| 2465 | auth: { status: "authenticated" }, | ||
| 2466 | checkedAt: NOW_ISO, | ||
| 2467 | models: [ | ||
| 2468 | { | ||
| 2469 | slug: "claude-opus-4-7", | ||
| 2470 | name: "Claude Opus 4.7", | ||
| 2471 | isCustom: false, | ||
| 2472 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 2473 | }, | ||
| 2474 | ], | ||
| 2475 | slashCommands: [], | ||
| 2476 | skills: [], | ||
| 2477 | }, | ||
| 2478 | { | ||
| 2479 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 2480 | instanceId: openRouterInstanceId, | ||
| 2481 | displayName: "Claude OpenRouter", | ||
| 2482 | enabled: true, | ||
| 2483 | installed: true, | ||
| 2484 | version: "2.1.117", | ||
| 2485 | status: "ready", | ||
| 2486 | auth: { status: "authenticated" }, | ||
| 2487 | checkedAt: NOW_ISO, | ||
| 2488 | models: [ | ||
| 2489 | { | ||
| 2490 | slug: "claude-opus-4-7", | ||
| 2491 | name: "Claude Opus 4.7", | ||
| 2492 | isCustom: false, | ||
| 2493 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 2494 | }, | ||
| 2495 | ], | ||
| 2496 | slashCommands: [], | ||
| 2497 | skills: [], | ||
| 2498 | }, | ||
| 2499 | ], | ||
| 2500 | settings: { | ||
| 2501 | ...nextFixture.serverConfig.settings, | ||
| 2502 | providerInstances: { | ||
| 2503 | ...nextFixture.serverConfig.settings.providerInstances, | ||
| 2504 | [openRouterInstanceId]: { | ||
| 2505 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 2506 | displayName: "Claude OpenRouter", | ||
| 2507 | config: { customModels: ["openai/gpt-5.5"] }, | ||
| 2508 | }, | ||
| 2509 | }, | ||
| 2510 | }, | ||
| 2511 | }; | ||
| 2512 | }, | ||
| 2513 | resolveRpc: (body) => { | ||
| 2514 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 2515 | return { | ||
| 2516 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 2517 | }; | ||
| 2518 | } | ||
| 2519 | return undefined; | ||
| 2520 | }, | ||
| 2521 | }); | ||
| 2522 | |||
| 2523 | try { | ||
| 2524 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); | ||
| 2525 | await waitForLayout(); | ||
| 2526 | |||
| 2527 | const sendButton = await waitForSendButton(); | ||
| 2528 | expect(sendButton.disabled).toBe(false); | ||
| 2529 | sendButton.click(); | ||
| 2530 | |||
| 2531 | await vi.waitFor( | ||
| 2532 | () => { | ||
| 2533 | const turnStartRequest = wsRequests.find( | ||
| 2534 | (request) => | ||
| 2535 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 2536 | request.type === "thread.turn.start", | ||
| 2537 | ) as | ||
| 2538 | | { | ||
| 2539 | modelSelection?: { instanceId?: string; model?: string }; | ||
| 2540 | bootstrap?: { | ||
| 2541 | createThread?: { | ||
| 2542 | modelSelection?: { instanceId?: string; model?: string }; | ||
| 2543 | }; | ||
| 2544 | }; | ||
| 2545 | } | ||
| 2546 | | undefined; | ||
| 2547 | |||
| 2548 | expect(turnStartRequest?.modelSelection).toMatchObject({ | ||
| 2549 | instanceId: openRouterInstanceId, | ||
| 2550 | model: "openai/gpt-5.5", | ||
| 2551 | }); | ||
| 2552 | expect(turnStartRequest?.bootstrap?.createThread?.modelSelection).toMatchObject({ | ||
| 2553 | instanceId: openRouterInstanceId, | ||
| 2554 | model: "openai/gpt-5.5", | ||
| 2555 | }); | ||
| 2556 | }, | ||
| 2557 | { timeout: 8_000, interval: 16 }, | ||
| 2558 | ); | ||
| 2559 | } finally { | ||
| 2560 | await mounted.cleanup(); | ||
| 2561 | } | ||
| 2562 | }); | ||
| 2563 | |||
| 2441 | it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { | 2564 | it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { |
| 2442 | const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); | 2565 | const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); |
| 2443 | const mounted = await mountChatView({ | 2566 | const mounted = await mountChatView({ |
| ... | @@ -3778,12 +3901,16 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3778,12 +3901,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3778 | it("snapshots sticky codex settings into a new draft thread", async () => { | 3901 | it("snapshots sticky codex settings into a new draft thread", async () => { |
| 3779 | useComposerDraftStore.setState({ | 3902 | useComposerDraftStore.setState({ |
| 3780 | stickyModelSelectionByProvider: { | 3903 | stickyModelSelectionByProvider: { |
| 3781 | codex: createModelSelection("codex", "gpt-5.3-codex", [ | 3904 | [ProviderInstanceId.make("codex")]: createModelSelection( |
| 3782 | { id: "reasoningEffort", value: "medium" }, | 3905 | ProviderInstanceId.make("codex"), |
| 3783 | { id: "fastMode", value: true }, | 3906 | "gpt-5.3-codex", |
| 3784 | ]), | 3907 | [ |
| 3908 | { id: "reasoningEffort", value: "medium" }, | ||
| 3909 | { id: "fastMode", value: true }, | ||
| 3910 | ], | ||
| 3911 | ), | ||
| 3785 | }, | 3912 | }, |
| 3786 | stickyActiveProvider: "codex", | 3913 | stickyActiveProvider: ProviderInstanceId.make("codex"), |
| 3787 | }); | 3914 | }); |
| 3788 | 3915 | ||
| 3789 | const mounted = await mountChatView({ | 3916 | const mounted = await mountChatView({ |
| ... | @@ -3814,7 +3941,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3814,7 +3941,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3814 | expect(composerDraftFor(newDraftId)).toMatchObject({ | 3941 | expect(composerDraftFor(newDraftId)).toMatchObject({ |
| 3815 | modelSelectionByProvider: { | 3942 | modelSelectionByProvider: { |
| 3816 | codex: { | 3943 | codex: { |
| 3817 | provider: "codex", | 3944 | instanceId: ProviderInstanceId.make("codex"), |
| 3818 | model: "gpt-5.3-codex", | 3945 | model: "gpt-5.3-codex", |
| 3819 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), | 3946 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), |
| 3820 | }, | 3947 | }, |
| ... | @@ -3829,12 +3956,16 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3829,12 +3956,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3829 | it("hydrates the provider alongside a sticky claude model", async () => { | 3956 | it("hydrates the provider alongside a sticky claude model", async () => { |
| 3830 | useComposerDraftStore.setState({ | 3957 | useComposerDraftStore.setState({ |
| 3831 | stickyModelSelectionByProvider: { | 3958 | stickyModelSelectionByProvider: { |
| 3832 | claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 3959 | [ProviderInstanceId.make("claudeAgent")]: createModelSelection( |
| 3833 | { id: "effort", value: "max" }, | 3960 | ProviderInstanceId.make("claudeAgent"), |
| 3834 | { id: "fastMode", value: true }, | 3961 | "claude-opus-4-6", |
| 3835 | ]), | 3962 | [ |
| 3963 | { id: "effort", value: "max" }, | ||
| 3964 | { id: "fastMode", value: true }, | ||
| 3965 | ], | ||
| 3966 | ), | ||
| 3836 | }, | 3967 | }, |
| 3837 | stickyActiveProvider: "claudeAgent", | 3968 | stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), |
| 3838 | }); | 3969 | }); |
| 3839 | 3970 | ||
| 3840 | const mounted = await mountChatView({ | 3971 | const mounted = await mountChatView({ |
| ... | @@ -3860,10 +3991,14 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3860,10 +3991,14 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3860 | 3991 | ||
| 3861 | expect(composerDraftFor(newDraftId)).toMatchObject({ | 3992 | expect(composerDraftFor(newDraftId)).toMatchObject({ |
| 3862 | modelSelectionByProvider: { | 3993 | modelSelectionByProvider: { |
| 3863 | claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 3994 | claudeAgent: createModelSelection( |
| 3864 | { id: "effort", value: "max" }, | 3995 | ProviderInstanceId.make("claudeAgent"), |
| 3865 | { id: "fastMode", value: true }, | 3996 | "claude-opus-4-6", |
| 3866 | ]), | 3997 | [ |
| 3998 | { id: "effort", value: "max" }, | ||
| 3999 | { id: "fastMode", value: true }, | ||
| 4000 | ], | ||
| 4001 | ), | ||
| 3867 | }, | 4002 | }, |
| 3868 | activeProvider: "claudeAgent", | 4003 | activeProvider: "claudeAgent", |
| 3869 | }); | 4004 | }); |
| ... | @@ -3903,12 +4038,16 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3903,12 +4038,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3903 | it("prefers draft state over sticky composer settings and defaults", async () => { | 4038 | it("prefers draft state over sticky composer settings and defaults", async () => { |
| 3904 | useComposerDraftStore.setState({ | 4039 | useComposerDraftStore.setState({ |
| 3905 | stickyModelSelectionByProvider: { | 4040 | stickyModelSelectionByProvider: { |
| 3906 | codex: createModelSelection("codex", "gpt-5.3-codex", [ | 4041 | [ProviderInstanceId.make("codex")]: createModelSelection( |
| 3907 | { id: "reasoningEffort", value: "medium" }, | 4042 | ProviderInstanceId.make("codex"), |
| 3908 | { id: "fastMode", value: true }, | 4043 | "gpt-5.3-codex", |
| 3909 | ]), | 4044 | [ |
| 4045 | { id: "reasoningEffort", value: "medium" }, | ||
| 4046 | { id: "fastMode", value: true }, | ||
| 4047 | ], | ||
| 4048 | ), | ||
| 3910 | }, | 4049 | }, |
| 3911 | stickyActiveProvider: "codex", | 4050 | stickyActiveProvider: ProviderInstanceId.make("codex"), |
| 3912 | }); | 4051 | }); |
| 3913 | 4052 | ||
| 3914 | const mounted = await mountChatView({ | 4053 | const mounted = await mountChatView({ |
| ... | @@ -3938,7 +4077,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3938,7 +4077,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3938 | expect(composerDraftFor(draftId)).toMatchObject({ | 4077 | expect(composerDraftFor(draftId)).toMatchObject({ |
| 3939 | modelSelectionByProvider: { | 4078 | modelSelectionByProvider: { |
| 3940 | codex: { | 4079 | codex: { |
| 3941 | provider: "codex", | 4080 | instanceId: ProviderInstanceId.make("codex"), |
| 3942 | model: "gpt-5.3-codex", | 4081 | model: "gpt-5.3-codex", |
| 3943 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), | 4082 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), |
| 3944 | }, | 4083 | }, |
| ... | @@ -3948,7 +4087,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3948,7 +4087,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3948 | 4087 | ||
| 3949 | useComposerDraftStore.getState().setModelSelection( | 4088 | useComposerDraftStore.getState().setModelSelection( |
| 3950 | draftId, | 4089 | draftId, |
| 3951 | createModelSelection("codex", "gpt-5.4", [ | 4090 | createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 3952 | { id: "reasoningEffort", value: "low" }, | 4091 | { id: "reasoningEffort", value: "low" }, |
| 3953 | { id: "fastMode", value: true }, | 4092 | { id: "fastMode", value: true }, |
| 3954 | ]), | 4093 | ]), |
| ... | @@ -3963,7 +4102,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3963,7 +4102,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3963 | ); | 4102 | ); |
| 3964 | expect(composerDraftFor(draftId)).toMatchObject({ | 4103 | expect(composerDraftFor(draftId)).toMatchObject({ |
| 3965 | modelSelectionByProvider: { | 4104 | modelSelectionByProvider: { |
| 3966 | codex: createModelSelection("codex", "gpt-5.4", [ | 4105 | codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 3967 | { id: "reasoningEffort", value: "low" }, | 4106 | { id: "reasoningEffort", value: "low" }, |
| 3968 | { id: "fastMode", value: true }, | 4107 | { id: "fastMode", value: true }, |
| 3969 | ]), | 4108 | ]), |
| ... | @@ -5435,7 +5574,10 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5435,7 +5574,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5435 | const mounted = await mountChatView({ | 5574 | const mounted = await mountChatView({ |
| 5436 | viewport: WIDE_FOOTER_VIEWPORT, | 5575 | viewport: WIDE_FOOTER_VIEWPORT, |
| 5437 | snapshot: createSnapshotWithPlanFollowUpPrompt({ | 5576 | snapshot: createSnapshotWithPlanFollowUpPrompt({ |
| 5438 | modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, | 5577 | modelSelection: { |
| 5578 | instanceId: ProviderInstanceId.make("codex"), | ||
| 5579 | model: "gpt-5.3-codex-spark", | ||
| 5580 | }, | ||
| 5439 | planMarkdown: | 5581 | planMarkdown: |
| 5440 | "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", | 5582 | "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", |
| 5441 | }), | 5583 | }), |
| ... | @@ -5465,7 +5607,10 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5465,7 +5607,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5465 | const mounted = await mountChatView({ | 5607 | const mounted = await mountChatView({ |
| 5466 | viewport: WIDE_FOOTER_VIEWPORT, | 5608 | viewport: WIDE_FOOTER_VIEWPORT, |
| 5467 | snapshot: createSnapshotWithPlanFollowUpPrompt({ | 5609 | snapshot: createSnapshotWithPlanFollowUpPrompt({ |
| 5468 | modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, | 5610 | modelSelection: { |
| 5611 | instanceId: ProviderInstanceId.make("codex"), | ||
| 5612 | model: "gpt-5.3-codex-spark", | ||
| 5613 | }, | ||
| 5469 | planMarkdown: | 5614 | planMarkdown: |
| 5470 | "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", | 5615 | "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", |
| 5471 | }), | 5616 | }), |
| ... | @@ -5589,14 +5734,17 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5589,14 +5734,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5589 | projects: snapshot.projects.map((project) => | 5734 | projects: snapshot.projects.map((project) => |
| 5590 | project.id === PROJECT_ID | 5735 | project.id === PROJECT_ID |
| 5591 | ? Object.assign({}, project, { | 5736 | ? Object.assign({}, project, { |
| 5592 | defaultModelSelection: { provider: "codex", model: "gpt-5.4" }, | 5737 | defaultModelSelection: { |
| 5738 | instanceId: ProviderInstanceId.make("codex"), | ||
| 5739 | model: "gpt-5.4", | ||
| 5740 | }, | ||
| 5593 | }) | 5741 | }) |
| 5594 | : project, | 5742 | : project, |
| 5595 | ), | 5743 | ), |
| 5596 | threads: snapshot.threads.map((thread) => | 5744 | threads: snapshot.threads.map((thread) => |
| 5597 | thread.id === THREAD_ID | 5745 | thread.id === THREAD_ID |
| 5598 | ? Object.assign({}, thread, { | 5746 | ? Object.assign({}, thread, { |
| 5599 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 5747 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 5600 | }) | 5748 | }) |
| 5601 | : thread, | 5749 | : thread, |
| 5602 | ), | 5750 | ), |
| ... | @@ -5647,7 +5795,6 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5647,7 +5795,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5647 | providers: [ | 5795 | providers: [ |
| 5648 | { | 5796 | { |
| 5649 | ...nextFixture.serverConfig.providers[0]!, | 5797 | ...nextFixture.serverConfig.providers[0]!, |
| 5650 | provider: "codex", | ||
| 5651 | models: [ | 5798 | models: [ |
| 5652 | { | 5799 | { |
| 5653 | slug: "gpt-5.1-codex-max", | 5800 | slug: "gpt-5.1-codex-max", |
apps/web/src/components/ChatView.logic.test.ts+17-10| ... | @@ -1,5 +1,12 @@ | ... | @@ -1,5 +1,12 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | 2 | import { |
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderDriverKind, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | TurnId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | 10 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | import { type EnvironmentState, useStore } from "../store"; | 11 | import { type EnvironmentState, useStore } from "../store"; |
| 5 | import { type Thread } from "../types"; | 12 | import { type Thread } from "../types"; |
| ... | @@ -220,7 +227,7 @@ const makeThread = (input?: { | ... | @@ -220,7 +227,7 @@ const makeThread = (input?: { |
| 220 | codexThreadId: null, | 227 | codexThreadId: null, |
| 221 | projectId: ProjectId.make("project-1"), | 228 | projectId: ProjectId.make("project-1"), |
| 222 | title: "Thread", | 229 | title: "Thread", |
| 223 | modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, | 230 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 224 | runtimeMode: "full-access" as const, | 231 | runtimeMode: "full-access" as const, |
| 225 | interactionMode: "default" as const, | 232 | interactionMode: "default" as const, |
| 226 | session: null, | 233 | session: null, |
| ... | @@ -253,7 +260,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) | ... | @@ -253,7 +260,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) |
| 253 | name: "Project", | 260 | name: "Project", |
| 254 | cwd: "/tmp/project", | 261 | cwd: "/tmp/project", |
| 255 | defaultModelSelection: { | 262 | defaultModelSelection: { |
| 256 | provider: "codex", | 263 | instanceId: ProviderInstanceId.make("codex"), |
| 257 | model: "gpt-5.4", | 264 | model: "gpt-5.4", |
| 258 | }, | 265 | }, |
| 259 | createdAt: "2026-03-29T00:00:00.000Z", | 266 | createdAt: "2026-03-29T00:00:00.000Z", |
| ... | @@ -452,7 +459,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -452,7 +459,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 452 | }; | 459 | }; |
| 453 | 460 | ||
| 454 | const previousSession = { | 461 | const previousSession = { |
| 455 | provider: "codex" as const, | 462 | provider: ProviderDriverKind.make("codex"), |
| 456 | status: "ready" as const, | 463 | status: "ready" as const, |
| 457 | createdAt: "2026-03-29T00:00:00.000Z", | 464 | createdAt: "2026-03-29T00:00:00.000Z", |
| 458 | updatedAt: "2026-03-29T00:00:10.000Z", | 465 | updatedAt: "2026-03-29T00:00:10.000Z", |
| ... | @@ -466,7 +473,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -466,7 +473,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 466 | codexThreadId: null, | 473 | codexThreadId: null, |
| 467 | projectId, | 474 | projectId, |
| 468 | title: "Thread", | 475 | title: "Thread", |
| 469 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 476 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 470 | runtimeMode: "full-access", | 477 | runtimeMode: "full-access", |
| 471 | interactionMode: "default", | 478 | interactionMode: "default", |
| 472 | session: previousSession, | 479 | session: previousSession, |
| ... | @@ -503,7 +510,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -503,7 +510,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 503 | codexThreadId: null, | 510 | codexThreadId: null, |
| 504 | projectId, | 511 | projectId, |
| 505 | title: "Thread", | 512 | title: "Thread", |
| 506 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 513 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 507 | runtimeMode: "full-access", | 514 | runtimeMode: "full-access", |
| 508 | interactionMode: "default", | 515 | interactionMode: "default", |
| 509 | session: previousSession, | 516 | session: previousSession, |
| ... | @@ -549,7 +556,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -549,7 +556,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 549 | codexThreadId: null, | 556 | codexThreadId: null, |
| 550 | projectId, | 557 | projectId, |
| 551 | title: "Thread", | 558 | title: "Thread", |
| 552 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 559 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 553 | runtimeMode: "full-access", | 560 | runtimeMode: "full-access", |
| 554 | interactionMode: "default", | 561 | interactionMode: "default", |
| 555 | session: previousSession, | 562 | session: previousSession, |
| ... | @@ -592,7 +599,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -592,7 +599,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 592 | codexThreadId: null, | 599 | codexThreadId: null, |
| 593 | projectId, | 600 | projectId, |
| 594 | title: "Thread", | 601 | title: "Thread", |
| 595 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 602 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 596 | runtimeMode: "full-access", | 603 | runtimeMode: "full-access", |
| 597 | interactionMode: "default", | 604 | interactionMode: "default", |
| 598 | session: previousSession, | 605 | session: previousSession, |
| ... | @@ -635,7 +642,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -635,7 +642,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 635 | codexThreadId: null, | 642 | codexThreadId: null, |
| 636 | projectId, | 643 | projectId, |
| 637 | title: "Thread", | 644 | title: "Thread", |
| 638 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 645 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 639 | runtimeMode: "full-access", | 646 | runtimeMode: "full-access", |
| 640 | interactionMode: "default", | 647 | interactionMode: "default", |
| 641 | session: previousSession, | 648 | session: previousSession, |
| ... | @@ -685,7 +692,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -685,7 +692,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 685 | codexThreadId: null, | 692 | codexThreadId: null, |
| 686 | projectId, | 693 | projectId, |
| 687 | title: "Thread", | 694 | title: "Thread", |
| 688 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 695 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 689 | runtimeMode: "full-access", | 696 | runtimeMode: "full-access", |
| 690 | interactionMode: "default", | 697 | interactionMode: "default", |
| 691 | session: previousSession, | 698 | session: previousSession, |
apps/web/src/components/ChatView.logic.ts+30-5| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | isProviderDriverKind, | ||
| 3 | ProjectId, | 4 | ProjectId, |
| 4 | type ModelSelection, | 5 | type ModelSelection, |
| 5 | type ProviderKind, | 6 | type ProviderDriverKind, |
| 6 | type ScopedThreadRef, | 7 | type ScopedThreadRef, |
| 7 | type ThreadId, | 8 | type ThreadId, |
| 8 | type TurnId, | 9 | type TurnId, |
| ... | @@ -226,15 +227,39 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { | ... | @@ -226,15 +227,39 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 226 | ); | 227 | ); |
| 227 | } | 228 | } |
| 228 | 229 | ||
| 230 | // `threadProvider` is the open branded driver kind carried by the session. | ||
| 231 | // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe | ||
| 232 | // rollback / fork behavior — the routing layer is the right place to surface | ||
| 233 | // "driver not installed" errors, not the lock state. | ||
| 234 | // | ||
| 235 | // `selectedProvider` takes the same open-string shape because the composer | ||
| 236 | // now tracks the picker selection as a `ProviderInstanceId` (e.g. | ||
| 237 | // `codex_personal`). Custom instance ids that don't directly match a | ||
| 238 | // registered driver resolve to `null` here, which matches the existing | ||
| 239 | // "unknown driver -> unlocked" semantics. Callers that want the lock to track | ||
| 240 | // a custom instance's underlying driver kind should resolve the instance id | ||
| 241 | // upstream and pass the correlated kind. | ||
| 229 | export function deriveLockedProvider(input: { | 242 | export function deriveLockedProvider(input: { |
| 230 | thread: Thread | null | undefined; | 243 | thread: Thread | null | undefined; |
| 231 | selectedProvider: ProviderKind | null; | 244 | selectedProvider: string | null; |
| 232 | threadProvider: ProviderKind | null; | 245 | threadProvider: string | null; |
| 233 | }): ProviderKind | null { | 246 | }): ProviderDriverKind | null { |
| 234 | if (!threadHasStarted(input.thread)) { | 247 | if (!threadHasStarted(input.thread)) { |
| 235 | return null; | 248 | return null; |
| 236 | } | 249 | } |
| 237 | return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null; | 250 | const sessionProvider = input.thread?.session?.provider ?? null; |
| 251 | if (sessionProvider) { | ||
| 252 | return sessionProvider; | ||
| 253 | } | ||
| 254 | const narrowedThreadProvider = | ||
| 255 | input.threadProvider && isProviderDriverKind(input.threadProvider) | ||
| 256 | ? input.threadProvider | ||
| 257 | : null; | ||
| 258 | const narrowedSelectedProvider = | ||
| 259 | input.selectedProvider && isProviderDriverKind(input.selectedProvider) | ||
| 260 | ? input.selectedProvider | ||
| 261 | : null; | ||
| 262 | return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; | ||
| 238 | } | 263 | } |
| 239 | 264 | ||
| 240 | export async function waitForStartedServerThread( | 265 | export async function waitForStartedServerThread( |
apps/web/src/components/ChatView.tsx+65-24| ... | @@ -1,13 +1,14 @@ | ... | @@ -1,13 +1,14 @@ |
| 1 | import { | 1 | import { |
| 2 | type ApprovalRequestId, | 2 | type ApprovalRequestId, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL, |
| 4 | defaultInstanceIdForDriver, | ||
| 4 | type EnvironmentId, | 5 | type EnvironmentId, |
| 5 | type MessageId, | 6 | type MessageId, |
| 6 | type ModelSelection, | 7 | type ModelSelection, |
| 7 | type ProjectScript, | 8 | type ProjectScript, |
| 8 | type ProviderKind, | ||
| 9 | type ProjectId, | 9 | type ProjectId, |
| 10 | type ProviderApprovalDecision, | 10 | type ProviderApprovalDecision, |
| 11 | ProviderInstanceId, | ||
| 11 | type ServerProvider, | 12 | type ServerProvider, |
| 12 | type ResolvedKeybindingsConfig, | 13 | type ResolvedKeybindingsConfig, |
| 13 | type ScopedThreadRef, | 14 | type ScopedThreadRef, |
| ... | @@ -16,6 +17,7 @@ import { | ... | @@ -16,6 +17,7 @@ import { |
| 16 | type KeybindingCommand, | 17 | type KeybindingCommand, |
| 17 | OrchestrationThreadActivity, | 18 | OrchestrationThreadActivity, |
| 18 | ProviderInteractionMode, | 19 | ProviderInteractionMode, |
| 20 | ProviderDriverKind, | ||
| 19 | RuntimeMode, | 21 | RuntimeMode, |
| 20 | TerminalOpenInput, | 22 | TerminalOpenInput, |
| 21 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| ... | @@ -115,7 +117,7 @@ import { | ... | @@ -115,7 +117,7 @@ import { |
| 115 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 117 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 116 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 118 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 117 | import { useSettings } from "../hooks/useSettings"; | 119 | import { useSettings } from "../hooks/useSettings"; |
| 118 | import { resolveAppModelSelection } from "../modelSelection"; | 120 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 119 | import { isTerminalFocused } from "../lib/terminalFocus"; | 121 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 120 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; | 122 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; |
| 121 | import { | 123 | import { |
| ... | @@ -302,7 +304,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog | ... | @@ -302,7 +304,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog |
| 302 | } | 304 | } |
| 303 | 305 | ||
| 304 | function formatOutgoingPrompt(params: { | 306 | function formatOutgoingPrompt(params: { |
| 305 | provider: ProviderKind; | 307 | provider: ProviderDriverKind; |
| 306 | model: string | null; | 308 | model: string | null; |
| 307 | models: ReadonlyArray<ServerProvider["models"][number]>; | 309 | models: ReadonlyArray<ServerProvider["models"][number]>; |
| 308 | effort: string | null; | 310 | effort: string | null; |
| ... | @@ -777,8 +779,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -777,8 +779,8 @@ export default function ChatView(props: ChatViewProps) { |
| 777 | threadId, | 779 | threadId, |
| 778 | draftThread, | 780 | draftThread, |
| 779 | fallbackDraftProject?.defaultModelSelection ?? { | 781 | fallbackDraftProject?.defaultModelSelection ?? { |
| 780 | provider: "codex", | 782 | instanceId: ProviderInstanceId.make("codex"), |
| 781 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 783 | model: DEFAULT_MODEL, |
| 782 | }, | 784 | }, |
| 783 | localDraftError, | 785 | localDraftError, |
| 784 | ) | 786 | ) |
| ... | @@ -1031,7 +1033,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1031,7 +1033,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1031 | 1033 | ||
| 1032 | const selectedProviderByThreadId = composerActiveProvider ?? null; | 1034 | const selectedProviderByThreadId = composerActiveProvider ?? null; |
| 1033 | const threadProvider = | 1035 | const threadProvider = |
| 1034 | activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; | 1036 | activeThread?.modelSelection.instanceId ?? |
| 1037 | activeProject?.defaultModelSelection?.instanceId ?? | ||
| 1038 | null; | ||
| 1035 | const lockedProvider = deriveLockedProvider({ | 1039 | const lockedProvider = deriveLockedProvider({ |
| 1036 | thread: activeThread, | 1040 | thread: activeThread, |
| 1037 | selectedProvider: selectedProviderByThreadId, | 1041 | selectedProvider: selectedProviderByThreadId, |
| ... | @@ -1051,9 +1055,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1051,9 +1055,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1051 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; | 1055 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
| 1052 | const unlockedSelectedProvider = resolveSelectableProvider( | 1056 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 1053 | providerStatuses, | 1057 | providerStatuses, |
| 1054 | selectedProviderByThreadId ?? threadProvider ?? "codex", | 1058 | selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), |
| 1055 | ); | 1059 | ); |
| 1056 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; | 1060 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 1057 | const phase = derivePhase(activeThread?.session ?? null); | 1061 | const phase = derivePhase(activeThread?.session ?? null); |
| 1058 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; | 1062 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 1059 | const workLogEntries = useMemo( | 1063 | const workLogEntries = useMemo( |
| ... | @@ -1425,10 +1429,24 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1425,10 +1429,24 @@ export default function ChatView(props: ChatViewProps) { |
| 1425 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); | 1429 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); |
| 1426 | const keybindings = useServerKeybindings(); | 1430 | const keybindings = useServerKeybindings(); |
| 1427 | const availableEditors = useServerAvailableEditors(); | 1431 | const availableEditors = useServerAvailableEditors(); |
| 1428 | const activeProviderStatus = useMemo( | 1432 | // Prefer an instance-id match so a custom Codex instance (e.g. |
| 1429 | () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, | 1433 | // `codex_personal`) surfaces its own status/message in the banner rather |
| 1430 | [selectedProvider, providerStatuses], | 1434 | // than the default Codex's. Falls back to first-match-by-kind when no |
| 1431 | ); | 1435 | // saved instance id is available or the instance no longer exists. |
| 1436 | const activeProviderInstanceId = | ||
| 1437 | activeThread?.session?.providerInstanceId ?? | ||
| 1438 | activeThread?.modelSelection.instanceId ?? | ||
| 1439 | activeProject?.defaultModelSelection?.instanceId ?? | ||
| 1440 | null; | ||
| 1441 | const activeProviderStatus = useMemo(() => { | ||
| 1442 | if (activeProviderInstanceId) { | ||
| 1443 | return ( | ||
| 1444 | providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null | ||
| 1445 | ); | ||
| 1446 | } | ||
| 1447 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); | ||
| 1448 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; | ||
| 1449 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); | ||
| 1432 | const activeProjectCwd = activeProject?.cwd ?? null; | 1450 | const activeProjectCwd = activeProject?.cwd ?? null; |
| 1433 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; | 1451 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 1434 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; | 1452 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| ... | @@ -1945,7 +1963,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1945,7 +1963,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1945 | if ( | 1963 | if ( |
| 1946 | input.modelSelection !== undefined && | 1964 | input.modelSelection !== undefined && |
| 1947 | (input.modelSelection.model !== serverThread.modelSelection.model || | 1965 | (input.modelSelection.model !== serverThread.modelSelection.model || |
| 1948 | input.modelSelection.provider !== serverThread.modelSelection.provider || | 1966 | input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || |
| 1949 | JSON.stringify(input.modelSelection.options ?? null) !== | 1967 | JSON.stringify(input.modelSelection.options ?? null) !== |
| 1950 | JSON.stringify(serverThread.modelSelection.options ?? null)) | 1968 | JSON.stringify(serverThread.modelSelection.options ?? null)) |
| 1951 | ) { | 1969 | ) { |
| ... | @@ -2559,10 +2577,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2559,10 +2577,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2559 | } | 2577 | } |
| 2560 | const title = truncate(titleSeed); | 2578 | const title = truncate(titleSeed); |
| 2561 | const threadCreateModelSelection = createModelSelection( | 2579 | const threadCreateModelSelection = createModelSelection( |
| 2562 | ctxSelectedProvider, | 2580 | ctxSelectedModelSelection.instanceId, |
| 2563 | ctxSelectedModel || | 2581 | ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, |
| 2564 | activeProject.defaultModelSelection?.model || | ||
| 2565 | DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 2566 | ctxSelectedModelSelection.options, | 2582 | ctxSelectedModelSelection.options, |
| 2567 | ); | 2583 | ); |
| 2568 | 2584 | ||
| ... | @@ -3128,21 +3144,46 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3128,21 +3144,46 @@ export default function ChatView(props: ChatViewProps) { |
| 3128 | ]); | 3144 | ]); |
| 3129 | 3145 | ||
| 3130 | const onProviderModelSelect = useCallback( | 3146 | const onProviderModelSelect = useCallback( |
| 3131 | (provider: ProviderKind, model: string) => { | 3147 | (instanceId: ProviderInstanceId, model: string) => { |
| 3132 | if (!activeThread) return; | 3148 | if (!activeThread) return; |
| 3133 | if (lockedProvider !== null && provider !== lockedProvider) { | 3149 | // Look up the configured instance so model normalization and custom |
| 3150 | // model lookup stay scoped to that exact instance. Unknown instance ids | ||
| 3151 | // are rejected by returning early; the server remains authoritative too. | ||
| 3152 | const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); | ||
| 3153 | const resolvedDriverKind = entry?.driver ?? null; | ||
| 3154 | if ( | ||
| 3155 | lockedProvider !== null && | ||
| 3156 | resolvedDriverKind !== null && | ||
| 3157 | resolvedDriverKind !== lockedProvider | ||
| 3158 | ) { | ||
| 3134 | scheduleComposerFocus(); | 3159 | scheduleComposerFocus(); |
| 3135 | return; | 3160 | return; |
| 3136 | } | 3161 | } |
| 3137 | const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); | 3162 | if (lockedProvider !== null && activeThread.session?.providerInstanceId) { |
| 3138 | const resolvedModel = resolveAppModelSelection( | 3163 | const currentEntry = providerStatuses.find( |
| 3139 | resolvedProvider, | 3164 | (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, |
| 3165 | ); | ||
| 3166 | if ( | ||
| 3167 | currentEntry?.continuation?.groupKey && | ||
| 3168 | entry?.continuation?.groupKey && | ||
| 3169 | currentEntry.continuation.groupKey !== entry.continuation.groupKey | ||
| 3170 | ) { | ||
| 3171 | scheduleComposerFocus(); | ||
| 3172 | return; | ||
| 3173 | } | ||
| 3174 | } | ||
| 3175 | const resolvedModel = resolveAppModelSelectionForInstance( | ||
| 3176 | instanceId, | ||
| 3140 | settings, | 3177 | settings, |
| 3141 | providerStatuses, | 3178 | providerStatuses, |
| 3142 | model, | 3179 | model, |
| 3143 | ); | 3180 | ); |
| 3181 | if (!resolvedModel) { | ||
| 3182 | scheduleComposerFocus(); | ||
| 3183 | return; | ||
| 3184 | } | ||
| 3144 | const nextModelSelection: ModelSelection = { | 3185 | const nextModelSelection: ModelSelection = { |
| 3145 | provider: resolvedProvider, | 3186 | instanceId, |
| 3146 | model: resolvedModel, | 3187 | model: resolvedModel, |
| 3147 | }; | 3188 | }; |
| 3148 | setComposerDraftModelSelection( | 3189 | setComposerDraftModelSelection( |
apps/web/src/components/CommandPalette.logic.test.ts+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 3 | import type { Thread } from "../types"; | 3 | import type { Thread } from "../types"; |
| 4 | import { | 4 | import { |
| 5 | buildThreadActionItems, | 5 | buildThreadActionItems, |
| ... | @@ -17,7 +17,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -17,7 +17,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 17 | codexThreadId: null, | 17 | codexThreadId: null, |
| 18 | projectId: PROJECT_ID, | 18 | projectId: PROJECT_ID, |
| 19 | title: "Thread", | 19 | title: "Thread", |
| 20 | modelSelection: { provider: "codex", model: "gpt-5" }, | 20 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 21 | runtimeMode: "full-access", | 21 | runtimeMode: "full-access", |
| 22 | interactionMode: "default", | 22 | interactionMode: "default", |
| 23 | session: null, | 23 | session: null, |
apps/web/src/components/CommandPalette.tsx+4-3| ... | @@ -2,10 +2,11 @@ | ... | @@ -2,10 +2,11 @@ |
| 2 | 2 | ||
| 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 4 | import { | 4 | import { |
| 5 | DEFAULT_MODEL_BY_PROVIDER, | 5 | DEFAULT_MODEL, |
| 6 | type EnvironmentId, | 6 | type EnvironmentId, |
| 7 | type FilesystemBrowseResult, | 7 | type FilesystemBrowseResult, |
| 8 | type ProjectId, | 8 | type ProjectId, |
| 9 | ProviderInstanceId, | ||
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | 11 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 11 | import { useNavigate, useParams } from "@tanstack/react-router"; | 12 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| ... | @@ -786,8 +787,8 @@ function OpenCommandPaletteDialog() { | ... | @@ -786,8 +787,8 @@ function OpenCommandPaletteDialog() { |
| 786 | workspaceRoot: cwd, | 787 | workspaceRoot: cwd, |
| 787 | createWorkspaceRootIfMissing: true, | 788 | createWorkspaceRootIfMissing: true, |
| 788 | defaultModelSelection: { | 789 | defaultModelSelection: { |
| 789 | provider: "codex", | 790 | instanceId: ProviderInstanceId.make("codex"), |
| 790 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 791 | model: DEFAULT_MODEL, |
| 791 | }, | 792 | }, |
| 792 | createdAt: new Date().toISOString(), | 793 | createdAt: new Date().toISOString(), |
| 793 | }); | 794 | }); |
apps/web/src/components/Icons.tsx+23| ... | @@ -588,3 +588,26 @@ export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( | ... | @@ -588,3 +588,26 @@ export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( |
| 588 | <path d="M205.3 31.4c14 14.8 20 35.2 22.5 63.6 6.6 0 12.8 1.5 17 7.2l7.8 10.6c2.2 3 3.4 6.6 3.4 10.4v28.7a12 12 0 0 1-4.8 9.5C215.9 187.2 172.3 208 128 208c-49 0-98.2-28.3-123.2-46.6a12 12 0 0 1-4.8-9.5v-28.7c0-3.8 1.2-7.4 3.4-10.5l7.8-10.5c4.2-5.7 10.4-7.2 17-7.2 2.5-28.4 8.4-48.8 22.5-63.6C77.3 3.2 112.6 0 127.6 0h.4c14.7 0 50.4 2.9 77.3 31.4ZM128 78.7c-3 0-6.5.2-10.3.6a27.1 27.1 0 0 1-6 12.1 45 45 0 0 1-32 13c-6.8 0-13.9-1.5-19.7-5.2-5.5 1.9-10.8 4.5-11.2 11-.5 12.2-.6 24.5-.6 36.8 0 6.1 0 12.3-.2 18.5 0 3.6 2.2 6.9 5.5 8.4C79.9 185.9 105 192 128 192s48-6 74.5-18.1a9.4 9.4 0 0 0 5.5-8.4c.3-18.4 0-37-.8-55.3-.4-6.6-5.7-9.1-11.2-11-5.8 3.7-13 5.1-19.7 5.1a45 45 0 0 1-32-12.9 27.1 27.1 0 0 1-6-12.1c-3.4-.4-6.9-.5-10.3-.6Zm-27 44c5.8 0 10.5 4.6 10.5 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.6-10.4 10.4-10.4Zm53.4 0c5.8 0 10.4 4.6 10.4 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.7-10.4 10.4-10.4Zm-73-94.4c-11.2 1.1-20.6 4.8-25.4 10-10.4 11.3-8.2 40.1-2.2 46.2A31.2 31.2 0 0 0 75 91.7c6.8 0 19.6-1.5 30.1-12.2 4.7-4.5 7.5-15.7 7.2-27-.3-9.1-2.9-16.7-6.7-19.9-4.2-3.6-13.6-5.2-24.2-4.3Zm69 4.3c-3.8 3.2-6.4 10.8-6.7 19.9-.3 11.3 2.5 22.5 7.2 27a41.7 41.7 0 0 0 30 12.2c8.9 0 17-2.9 21.3-7.2 6-6.1 8.2-34.9-2.2-46.3-4.8-5-14.2-8.8-25.4-9.9-10.6-1-20 .7-24.2 4.3ZM128 56c-2.6 0-5.6.2-9 .5.4 1.7.5 3.7.7 5.7 0 1.5 0 3-.2 4.5 3.2-.3 6-.3 8.5-.3 2.6 0 5.3 0 8.5.3-.2-1.6-.2-3-.2-4.5.2-2 .3-4 .7-5.7-3.4-.3-6.4-.5-9-.5Z" /> | 588 | <path d="M205.3 31.4c14 14.8 20 35.2 22.5 63.6 6.6 0 12.8 1.5 17 7.2l7.8 10.6c2.2 3 3.4 6.6 3.4 10.4v28.7a12 12 0 0 1-4.8 9.5C215.9 187.2 172.3 208 128 208c-49 0-98.2-28.3-123.2-46.6a12 12 0 0 1-4.8-9.5v-28.7c0-3.8 1.2-7.4 3.4-10.5l7.8-10.5c4.2-5.7 10.4-7.2 17-7.2 2.5-28.4 8.4-48.8 22.5-63.6C77.3 3.2 112.6 0 127.6 0h.4c14.7 0 50.4 2.9 77.3 31.4ZM128 78.7c-3 0-6.5.2-10.3.6a27.1 27.1 0 0 1-6 12.1 45 45 0 0 1-32 13c-6.8 0-13.9-1.5-19.7-5.2-5.5 1.9-10.8 4.5-11.2 11-.5 12.2-.6 24.5-.6 36.8 0 6.1 0 12.3-.2 18.5 0 3.6 2.2 6.9 5.5 8.4C79.9 185.9 105 192 128 192s48-6 74.5-18.1a9.4 9.4 0 0 0 5.5-8.4c.3-18.4 0-37-.8-55.3-.4-6.6-5.7-9.1-11.2-11-5.8 3.7-13 5.1-19.7 5.1a45 45 0 0 1-32-12.9 27.1 27.1 0 0 1-6-12.1c-3.4-.4-6.9-.5-10.3-.6Zm-27 44c5.8 0 10.5 4.6 10.5 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.6-10.4 10.4-10.4Zm53.4 0c5.8 0 10.4 4.6 10.4 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.7-10.4 10.4-10.4Zm-73-94.4c-11.2 1.1-20.6 4.8-25.4 10-10.4 11.3-8.2 40.1-2.2 46.2A31.2 31.2 0 0 0 75 91.7c6.8 0 19.6-1.5 30.1-12.2 4.7-4.5 7.5-15.7 7.2-27-.3-9.1-2.9-16.7-6.7-19.9-4.2-3.6-13.6-5.2-24.2-4.3Zm69 4.3c-3.8 3.2-6.4 10.8-6.7 19.9-.3 11.3 2.5 22.5 7.2 27a41.7 41.7 0 0 0 30 12.2c8.9 0 17-2.9 21.3-7.2 6-6.1 8.2-34.9-2.2-46.3-4.8-5-14.2-8.8-25.4-9.9-10.6-1-20 .7-24.2 4.3ZM128 56c-2.6 0-5.6.2-9 .5.4 1.7.5 3.7.7 5.7 0 1.5 0 3-.2 4.5 3.2-.3 6-.3 8.5-.3 2.6 0 5.3 0 8.5.3-.2-1.6-.2-3-.2-4.5.2-2 .3-4 .7-5.7-3.4-.3-6.4-.5-9-.5Z" /> |
| 589 | </svg> | 589 | </svg> |
| 590 | ); | 590 | ); |
| 591 | |||
| 592 | export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( | ||
| 593 | <svg | ||
| 594 | {...props} | ||
| 595 | viewBox="0 0 576 220" | ||
| 596 | fill="none" | ||
| 597 | className={cn("fill-black dark:fill-white", className)} | ||
| 598 | > | ||
| 599 | <path d="M568.003 115.821L517.278 27.9661C507.183 10.4816 489.084 0.0227367 468.894 0.0227367C448.727 0.0227367 430.674 10.4361 420.556 27.8752L343.251 161.749H242.755C236.23 161.749 230.386 158.384 227.135 152.745C223.861 147.106 223.861 140.376 227.135 134.715L277.861 46.8603C281.112 41.2216 286.955 37.8338 293.481 37.8338C300.006 37.8338 305.827 41.1988 309.101 46.8603L312.125 52.0897C313.353 54.2042 315.604 55.5002 318.036 55.5002C320.469 55.5002 322.743 54.1815 323.948 52.067L337.385 28.5118C338.795 26.0335 338.522 22.9413 336.703 20.7586C325.699 7.57131 309.874 0 293.322 0C292.662 0 292.003 0 291.321 0.0454733C272.04 0.75031 254.76 11.1864 245.074 27.9434L200.215 105.657L155.81 29.1484C145.465 11.2092 126.594 0.0227367 106.608 0.0227367C105.949 0.0227367 105.289 0.0227367 104.607 0.06821C85.3265 0.773047 68.0467 11.2092 58.3608 27.9661L7.65806 115.821C-6.25678 139.899 -0.868187 168.82 21.05 187.759C29.8945 195.422 41.6039 199.628 54.0181 199.628H148.648C151.081 199.628 153.332 198.332 154.56 196.217L168.52 172.026C169.748 169.911 169.748 167.319 168.52 165.205C167.292 163.09 165.041 161.794 162.608 161.794H56.0417C49.5163 161.794 43.6729 158.429 40.4216 152.79C37.1475 147.152 37.1475 140.422 40.4216 134.76L91.1471 46.9057C94.3985 41.2671 100.242 37.8793 106.767 37.8793C113.293 37.8793 119.113 41.2443 122.387 46.9057L194.826 172.526C195.031 172.89 195.258 173.208 195.531 173.526C198.76 178.665 202.83 183.485 207.786 187.782C216.631 195.444 228.34 199.651 240.754 199.651H321.424L315.581 209.769C314.353 211.883 314.353 214.475 315.581 216.59C316.809 218.704 319.059 220 321.492 220H349.436C351.868 220 354.119 218.704 355.347 216.59L364.396 200.901L367.17 196.468C367.17 196.468 367.261 196.331 367.284 196.263L453.274 46.9285C456.525 41.2898 462.369 37.902 468.894 37.902C475.42 37.902 481.263 41.2671 484.514 46.9285L535.24 134.783C538.491 140.422 538.514 147.174 535.24 152.813C531.988 158.452 526.145 161.84 519.62 161.84H418.669C416.236 161.84 413.985 163.136 412.757 165.25L398.774 189.442C397.546 191.556 397.546 194.148 398.774 196.263C400.002 198.377 402.253 199.673 404.686 199.673H518.21C539.81 199.673 559.295 188.237 569.026 169.843C578.053 152.79 577.666 132.623 567.981 115.843L568.003 115.821Z" /> | ||
| 600 | </svg> | ||
| 601 | ); | ||
| 602 | |||
| 603 | export const PiAgentIcon: Icon = ({ className, ...props }) => ( | ||
| 604 | <svg {...props} viewBox="0 0 800 800" className={cn("fill-none", className)}> | ||
| 605 | <rect width="800" height="800" rx="160" fill="#000" /> | ||
| 606 | <path | ||
| 607 | fill="#fff" | ||
| 608 | fillRule="evenodd" | ||
| 609 | d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z" | ||
| 610 | /> | ||
| 611 | <path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z" /> | ||
| 612 | </svg> | ||
| 613 | ); |
apps/web/src/components/KeybindingsToast.browser.tsx+24-6| ... | @@ -7,6 +7,8 @@ import { | ... | @@ -7,6 +7,8 @@ import { |
| 7 | type MessageId, | 7 | type MessageId, |
| 8 | type OrchestrationReadModel, | 8 | type OrchestrationReadModel, |
| 9 | type ProjectId, | 9 | type ProjectId, |
| 10 | ProviderDriverKind, | ||
| 11 | ProviderInstanceId, | ||
| 10 | type ServerConfig, | 12 | type ServerConfig, |
| 11 | type ServerLifecycleWelcomePayload, | 13 | type ServerLifecycleWelcomePayload, |
| 12 | type ThreadId, | 14 | type ThreadId, |
| ... | @@ -72,7 +74,8 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -72,7 +74,8 @@ function createBaseServerConfig(): ServerConfig { |
| 72 | issues: [], | 74 | issues: [], |
| 73 | providers: [ | 75 | providers: [ |
| 74 | { | 76 | { |
| 75 | provider: "codex", | 77 | driver: ProviderDriverKind.make("codex"), |
| 78 | instanceId: ProviderInstanceId.make("codex"), | ||
| 76 | enabled: true, | 79 | enabled: true, |
| 77 | installed: true, | 80 | installed: true, |
| 78 | version: "0.116.0", | 81 | version: "0.116.0", |
| ... | @@ -95,10 +98,25 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -95,10 +98,25 @@ function createBaseServerConfig(): ServerConfig { |
| 95 | ...DEFAULT_SERVER_SETTINGS, | 98 | ...DEFAULT_SERVER_SETTINGS, |
| 96 | enableAssistantStreaming: false, | 99 | enableAssistantStreaming: false, |
| 97 | defaultThreadEnvMode: "local" as const, | 100 | defaultThreadEnvMode: "local" as const, |
| 98 | textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, | 101 | textGenerationModelSelection: { |
| 102 | instanceId: ProviderInstanceId.make("codex"), | ||
| 103 | model: "gpt-5.4-mini", | ||
| 104 | }, | ||
| 99 | providers: { | 105 | providers: { |
| 100 | codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, | 106 | codex: { |
| 101 | claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, | 107 | enabled: true, |
| 108 | binaryPath: "", | ||
| 109 | homePath: "", | ||
| 110 | shadowHomePath: "", | ||
| 111 | customModels: [], | ||
| 112 | }, | ||
| 113 | claudeAgent: { | ||
| 114 | enabled: true, | ||
| 115 | binaryPath: "", | ||
| 116 | homePath: "", | ||
| 117 | customModels: [], | ||
| 118 | launchArgs: "", | ||
| 119 | }, | ||
| 102 | cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, | 120 | cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, |
| 103 | opencode: { | 121 | opencode: { |
| 104 | enabled: true, | 122 | enabled: true, |
| ... | @@ -121,7 +139,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { | ... | @@ -121,7 +139,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 121 | title: "Project", | 139 | title: "Project", |
| 122 | workspaceRoot: "/repo/project", | 140 | workspaceRoot: "/repo/project", |
| 123 | defaultModelSelection: { | 141 | defaultModelSelection: { |
| 124 | provider: "codex", | 142 | instanceId: ProviderInstanceId.make("codex"), |
| 125 | model: "gpt-5", | 143 | model: "gpt-5", |
| 126 | }, | 144 | }, |
| 127 | scripts: [], | 145 | scripts: [], |
| ... | @@ -136,7 +154,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { | ... | @@ -136,7 +154,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 136 | projectId: PROJECT_ID, | 154 | projectId: PROJECT_ID, |
| 137 | title: "Test thread", | 155 | title: "Test thread", |
| 138 | modelSelection: { | 156 | modelSelection: { |
| 139 | provider: "codex", | 157 | instanceId: ProviderInstanceId.make("codex"), |
| 140 | model: "gpt-5", | 158 | model: "gpt-5", |
| 141 | }, | 159 | }, |
| 142 | interactionMode: "default", | 160 | interactionMode: "default", |
apps/web/src/components/Sidebar.logic.test.ts+11-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | createThreadJumpHintVisibilityController, | 5 | createThreadJumpHintVisibilityController, |
| ... | @@ -20,7 +21,13 @@ import { | ... | @@ -20,7 +21,13 @@ import { |
| 20 | sortProjectsForSidebar, | 21 | sortProjectsForSidebar, |
| 21 | THREAD_JUMP_HINT_SHOW_DELAY_MS, | 22 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 22 | } from "./Sidebar.logic"; | 23 | } from "./Sidebar.logic"; |
| 23 | import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; | 24 | import { |
| 25 | EnvironmentId, | ||
| 26 | OrchestrationLatestTurn, | ||
| 27 | ProjectId, | ||
| 28 | ProviderInstanceId, | ||
| 29 | ThreadId, | ||
| 30 | } from "@t3tools/contracts"; | ||
| 24 | import { | 31 | import { |
| 25 | DEFAULT_INTERACTION_MODE, | 32 | DEFAULT_INTERACTION_MODE, |
| 26 | DEFAULT_RUNTIME_MODE, | 33 | DEFAULT_RUNTIME_MODE, |
| ... | @@ -474,7 +481,7 @@ describe("resolveThreadStatusPill", () => { | ... | @@ -474,7 +481,7 @@ describe("resolveThreadStatusPill", () => { |
| 474 | latestTurn: null, | 481 | latestTurn: null, |
| 475 | lastVisitedAt: undefined, | 482 | lastVisitedAt: undefined, |
| 476 | session: { | 483 | session: { |
| 477 | provider: "codex" as const, | 484 | provider: ProviderDriverKind.make("codex"), |
| 478 | status: "running" as const, | 485 | status: "running" as const, |
| 479 | createdAt: "2026-03-09T10:00:00.000Z", | 486 | createdAt: "2026-03-09T10:00:00.000Z", |
| 480 | updatedAt: "2026-03-09T10:00:00.000Z", | 487 | updatedAt: "2026-03-09T10:00:00.000Z", |
| ... | @@ -698,7 +705,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { | ... | @@ -698,7 +705,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { |
| 698 | name: "Project", | 705 | name: "Project", |
| 699 | cwd: "/tmp/project", | 706 | cwd: "/tmp/project", |
| 700 | defaultModelSelection: { | 707 | defaultModelSelection: { |
| 701 | provider: "codex", | 708 | instanceId: ProviderInstanceId.make("codex"), |
| 702 | model: "gpt-5.4", | 709 | model: "gpt-5.4", |
| 703 | ...defaultModelSelection, | 710 | ...defaultModelSelection, |
| 704 | }, | 711 | }, |
| ... | @@ -717,7 +724,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -717,7 +724,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 717 | projectId: ProjectId.make("project-1"), | 724 | projectId: ProjectId.make("project-1"), |
| 718 | title: "Thread", | 725 | title: "Thread", |
| 719 | modelSelection: { | 726 | modelSelection: { |
| 720 | provider: "codex", | 727 | instanceId: ProviderInstanceId.make("codex"), |
| 721 | model: "gpt-5.4", | 728 | model: "gpt-5.4", |
| 722 | ...overrides?.modelSelection, | 729 | ...overrides?.modelSelection, |
| 723 | }, | 730 | }, |
apps/web/src/components/chat/ChatComposer.tsx+149-38| ... | @@ -5,7 +5,6 @@ import type { | ... | @@ -5,7 +5,6 @@ import type { |
| 5 | ProjectEntry, | 5 | ProjectEntry, |
| 6 | ProviderApprovalDecision, | 6 | ProviderApprovalDecision, |
| 7 | ProviderInteractionMode, | 7 | ProviderInteractionMode, |
| 8 | ProviderKind, | ||
| 9 | ResolvedKeybindingsConfig, | 8 | ResolvedKeybindingsConfig, |
| 10 | RuntimeMode, | 9 | RuntimeMode, |
| 11 | ScopedThreadRef, | 10 | ScopedThreadRef, |
| ... | @@ -14,6 +13,8 @@ import type { | ... | @@ -14,6 +13,8 @@ import type { |
| 14 | TurnId, | 13 | TurnId, |
| 15 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 16 | import { | 15 | import { |
| 16 | ProviderDriverKind, | ||
| 17 | ProviderInstanceId, | ||
| 17 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 18 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 18 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 19 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 19 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| ... | @@ -95,11 +96,14 @@ import { | ... | @@ -95,11 +96,14 @@ import { |
| 95 | XIcon, | 96 | XIcon, |
| 96 | } from "lucide-react"; | 97 | } from "lucide-react"; |
| 97 | import { proposedPlanTitle } from "../../proposedPlan"; | 98 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 99 | import { getProviderInteractionModeToggle } from "../../providerModels"; | ||
| 98 | import { | 100 | import { |
| 99 | getProviderInteractionModeToggle, | 101 | deriveProviderInstanceEntries, |
| 100 | getProviderModels, | 102 | resolveProviderDriverKindForInstanceSelection, |
| 101 | resolveSelectableProvider, | 103 | sortProviderInstanceEntries, |
| 102 | } from "../../providerModels"; | 104 | type ProviderInstanceEntry, |
| 105 | } from "../../providerInstances"; | ||
| 106 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; | ||
| 103 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | 107 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 104 | import type { SessionPhase, Thread } from "../../types"; | 108 | import type { SessionPhase, Thread } from "../../types"; |
| 105 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; | 109 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| ... | @@ -346,7 +350,7 @@ export interface ChatComposerHandle { | ... | @@ -346,7 +350,7 @@ export interface ChatComposerHandle { |
| 346 | selectedPromptEffort: string | null; | 350 | selectedPromptEffort: string | null; |
| 347 | selectedModelOptionsForDispatch: unknown; | 351 | selectedModelOptionsForDispatch: unknown; |
| 348 | selectedModelSelection: ModelSelection; | 352 | selectedModelSelection: ModelSelection; |
| 349 | selectedProvider: ProviderKind; | 353 | selectedProvider: ProviderDriverKind; |
| 350 | selectedModel: string; | 354 | selectedModel: string; |
| 351 | selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; | 355 | selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; |
| 352 | }; | 356 | }; |
| ... | @@ -406,7 +410,7 @@ export interface ChatComposerProps { | ... | @@ -406,7 +410,7 @@ export interface ChatComposerProps { |
| 406 | interactionMode: ProviderInteractionMode; | 410 | interactionMode: ProviderInteractionMode; |
| 407 | 411 | ||
| 408 | // Provider / model | 412 | // Provider / model |
| 409 | lockedProvider: ProviderKind | null; | 413 | lockedProvider: ProviderDriverKind | null; |
| 410 | providerStatuses: ServerProvider[]; | 414 | providerStatuses: ServerProvider[]; |
| 411 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; | 415 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; |
| 412 | activeThreadModelSelection: ModelSelection | null | undefined; | 416 | activeThreadModelSelection: ModelSelection | null | undefined; |
| ... | @@ -449,7 +453,7 @@ export interface ChatComposerProps { | ... | @@ -449,7 +453,7 @@ export interface ChatComposerProps { |
| 449 | cursorAdjacentToMention: boolean, | 453 | cursorAdjacentToMention: boolean, |
| 450 | ) => void; | 454 | ) => void; |
| 451 | 455 | ||
| 452 | onProviderModelSelect: (provider: ProviderKind, model: string) => void; | 456 | onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; |
| 453 | toggleInteractionMode: () => void; | 457 | toggleInteractionMode: () => void; |
| 454 | handleRuntimeModeChange: (mode: RuntimeMode) => void; | 458 | handleRuntimeModeChange: (mode: RuntimeMode) => void; |
| 455 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; | 459 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; |
| ... | @@ -566,29 +570,134 @@ export const ChatComposer = memo( | ... | @@ -566,29 +570,134 @@ export const ChatComposer = memo( |
| 566 | // ------------------------------------------------------------------ | 570 | // ------------------------------------------------------------------ |
| 567 | // Model state | 571 | // Model state |
| 568 | // ------------------------------------------------------------------ | 572 | // ------------------------------------------------------------------ |
| 573 | // Instance-aware projection of the wire provider list. One entry per | ||
| 574 | // configured instance (default built-in + any custom `providerInstances.*`), | ||
| 575 | // sorted default-first per driver kind for a stable picker order. | ||
| 576 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( | ||
| 577 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), | ||
| 578 | [providerStatuses], | ||
| 579 | ); | ||
| 569 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | 580 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 570 | const threadProvider = | 581 | const threadProvider = |
| 571 | activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null; | 582 | activeThread?.session?.providerInstanceId ?? |
| 583 | activeThreadModelSelection?.instanceId ?? | ||
| 584 | activeProjectDefaultModelSelection?.instanceId ?? | ||
| 585 | null; | ||
| 586 | const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; | ||
| 587 | |||
| 588 | const unlockedSelectedProvider = | ||
| 589 | resolveProviderDriverKindForInstanceSelection( | ||
| 590 | providerInstanceEntries, | ||
| 591 | providerStatuses, | ||
| 592 | explicitSelectedInstanceId, | ||
| 593 | ) ?? ProviderDriverKind.make("codex"); | ||
| 594 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; | ||
| 595 | const lockedContinuationGroupKey = useMemo((): string | null => { | ||
| 596 | if (!lockedProvider || !activeThread) return null; | ||
| 597 | const lockedInstanceId = | ||
| 598 | activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; | ||
| 599 | if (!lockedInstanceId) return null; | ||
| 600 | return ( | ||
| 601 | providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) | ||
| 602 | ?.continuationGroupKey ?? null | ||
| 603 | ); | ||
| 604 | }, [ | ||
| 605 | activeThread, | ||
| 606 | activeThreadModelSelection?.instanceId, | ||
| 607 | lockedProvider, | ||
| 608 | providerInstanceEntries, | ||
| 609 | ]); | ||
| 572 | 610 | ||
| 573 | const unlockedSelectedProvider = resolveSelectableProvider( | 611 | // Resolve which configured instance the composer is currently targeting. |
| 574 | providerStatuses, | 612 | // Priority: |
| 575 | selectedProviderByThreadId ?? threadProvider ?? "codex", | 613 | // 1. The composer draft's `activeProvider` — the user's unsaved pick |
| 576 | ); | 614 | // from the model picker (must win, otherwise the UI appears to |
| 577 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; | 615 | // ignore picker selections). |
| 616 | // 2. Thread's persisted instance id (server-side saved selection). | ||
| 617 | // 3. Project default's instance id. | ||
| 618 | // 4. First enabled entry matching the current driver kind. | ||
| 619 | // 5. First enabled entry overall / default instance for the kind. | ||
| 620 | // | ||
| 621 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { | ||
| 622 | const candidates: Array<string | null | undefined> = [ | ||
| 623 | composerDraft.activeProvider, | ||
| 624 | activeThread?.session?.providerInstanceId, | ||
| 625 | activeThreadModelSelection?.instanceId, | ||
| 626 | activeProjectDefaultModelSelection?.instanceId, | ||
| 627 | ]; | ||
| 628 | for (const candidate of candidates) { | ||
| 629 | if (!candidate) continue; | ||
| 630 | const match = providerInstanceEntries.find( | ||
| 631 | (entry) => entry.instanceId === candidate && entry.enabled, | ||
| 632 | ); | ||
| 633 | if (match) { | ||
| 634 | // When locked to a specific driver kind, ignore persisted instance | ||
| 635 | // ids from a different kind or continuation group. | ||
| 636 | if (lockedProvider && match.driverKind !== lockedProvider) continue; | ||
| 637 | if ( | ||
| 638 | lockedContinuationGroupKey && | ||
| 639 | match.continuationGroupKey !== lockedContinuationGroupKey | ||
| 640 | ) { | ||
| 641 | continue; | ||
| 642 | } | ||
| 643 | return match.instanceId; | ||
| 644 | } | ||
| 645 | } | ||
| 646 | if (explicitSelectedInstanceId) { | ||
| 647 | return ProviderInstanceId.make(explicitSelectedInstanceId); | ||
| 648 | } | ||
| 649 | const byKind = providerInstanceEntries.find( | ||
| 650 | (entry) => | ||
| 651 | entry.enabled && | ||
| 652 | entry.driverKind === selectedProvider && | ||
| 653 | (!lockedContinuationGroupKey || | ||
| 654 | entry.continuationGroupKey === lockedContinuationGroupKey), | ||
| 655 | ); | ||
| 656 | if (byKind) return byKind.instanceId; | ||
| 657 | const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); | ||
| 658 | return ( | ||
| 659 | anyEnabled?.instanceId ?? | ||
| 660 | providerInstanceEntries[0]?.instanceId ?? | ||
| 661 | activeThreadModelSelection?.instanceId ?? | ||
| 662 | activeProjectDefaultModelSelection?.instanceId ?? | ||
| 663 | ProviderInstanceId.make("codex") | ||
| 664 | ); | ||
| 665 | }, [ | ||
| 666 | activeProjectDefaultModelSelection?.instanceId, | ||
| 667 | activeThread?.session?.providerInstanceId, | ||
| 668 | activeThreadModelSelection?.instanceId, | ||
| 669 | composerDraft.activeProvider, | ||
| 670 | explicitSelectedInstanceId, | ||
| 671 | lockedContinuationGroupKey, | ||
| 672 | lockedProvider, | ||
| 673 | providerInstanceEntries, | ||
| 674 | selectedProvider, | ||
| 675 | ]); | ||
| 578 | 676 | ||
| 579 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | 677 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 580 | threadRef: composerDraftTarget, | 678 | threadRef: composerDraftTarget, |
| 581 | providers: providerStatuses, | 679 | providers: providerStatuses, |
| 582 | selectedProvider, | 680 | selectedProvider, |
| 681 | selectedInstanceId, | ||
| 583 | threadModelSelection: activeThreadModelSelection, | 682 | threadModelSelection: activeThreadModelSelection, |
| 584 | projectModelSelection: activeProjectDefaultModelSelection, | 683 | projectModelSelection: activeProjectDefaultModelSelection, |
| 585 | settings, | 684 | settings, |
| 586 | }); | 685 | }); |
| 587 | 686 | ||
| 588 | const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); | 687 | // Resolve the active instance's snapshot by `instanceId` so a custom |
| 688 | // instance gets its own slash commands, skills, and model list — not | ||
| 689 | // the first snapshot for the same driver kind. | ||
| 690 | const selectedProviderEntry = useMemo( | ||
| 691 | () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), | ||
| 692 | [providerInstanceEntries, selectedInstanceId], | ||
| 693 | ); | ||
| 589 | const selectedProviderStatus = useMemo( | 694 | const selectedProviderStatus = useMemo( |
| 590 | () => providerStatuses.find((provider) => provider.provider === selectedProvider), | 695 | () => selectedProviderEntry?.snapshot ?? null, |
| 591 | [providerStatuses, selectedProvider], | 696 | [selectedProviderEntry], |
| 697 | ); | ||
| 698 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( | ||
| 699 | () => selectedProviderEntry?.models ?? [], | ||
| 700 | [selectedProviderEntry], | ||
| 592 | ); | 701 | ); |
| 593 | 702 | ||
| 594 | const composerProviderState = useMemo( | 703 | const composerProviderState = useMemo( |
| ... | @@ -615,29 +724,30 @@ export const ChatComposer = memo( | ... | @@ -615,29 +724,30 @@ export const ChatComposer = memo( |
| 615 | [providerStatuses, selectedProvider], | 724 | [providerStatuses, selectedProvider], |
| 616 | ); | 725 | ); |
| 617 | const selectedModelSelection = useMemo<ModelSelection>( | 726 | const selectedModelSelection = useMemo<ModelSelection>( |
| 618 | () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), | 727 | () => |
| 619 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], | 728 | createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), |
| 729 | [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], | ||
| 620 | ); | 730 | ); |
| 621 | const selectedModelForPicker = selectedModel; | 731 | const selectedModelForPicker = selectedModel; |
| 622 | const modelOptionsByProvider = useMemo< | 732 | // Instance-keyed option list so the picker can show each configured |
| 623 | Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>> | 733 | // instance (built-in + custom) as a first-class sidebar entry. The |
| 624 | >( | 734 | // options are server-reported models plus that exact instance's |
| 625 | () => ({ | 735 | // configured custom models; selected slugs are not injected into lists. |
| 626 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], | 736 | const modelOptionsByInstance = useMemo< |
| 627 | claudeAgent: | 737 | ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>> |
| 628 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], | 738 | >(() => { |
| 629 | opencode: | 739 | const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); |
| 630 | providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], | 740 | for (const entry of providerInstanceEntries) { |
| 631 | cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], | 741 | out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); |
| 632 | }), | 742 | } |
| 633 | [providerStatuses], | 743 | return out; |
| 634 | ); | 744 | }, [providerInstanceEntries, settings]); |
| 635 | const selectedModelForPickerWithCustomFallback = useMemo(() => { | 745 | const selectedModelForPickerWithCustomFallback = useMemo(() => { |
| 636 | const currentOptions = modelOptionsByProvider[selectedProvider]; | 746 | const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; |
| 637 | return currentOptions.some((option) => option.slug === selectedModelForPicker) | 747 | return currentOptions.some((option) => option.slug === selectedModelForPicker) |
| 638 | ? selectedModelForPicker | 748 | ? selectedModelForPicker |
| 639 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | 749 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); |
| 640 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); | 750 | }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]); |
| 641 | 751 | ||
| 642 | // ------------------------------------------------------------------ | 752 | // ------------------------------------------------------------------ |
| 643 | // Context window | 753 | // Context window |
| ... | @@ -1889,12 +1999,13 @@ export const ChatComposer = memo( | ... | @@ -1889,12 +1999,13 @@ export const ChatComposer = memo( |
| 1889 | <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | 1999 | <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> |
| 1890 | <ProviderModelPicker | 2000 | <ProviderModelPicker |
| 1891 | compact={isComposerFooterCompact} | 2001 | compact={isComposerFooterCompact} |
| 1892 | provider={selectedProvider} | 2002 | activeInstanceId={selectedInstanceId} |
| 1893 | model={selectedModelForPickerWithCustomFallback} | 2003 | model={selectedModelForPickerWithCustomFallback} |
| 1894 | lockedProvider={lockedProvider} | 2004 | lockedProvider={lockedProvider} |
| 1895 | providers={providerStatuses} | 2005 | lockedContinuationGroupKey={lockedContinuationGroupKey} |
| 2006 | instanceEntries={providerInstanceEntries} | ||
| 1896 | keybindings={keybindings} | 2007 | keybindings={keybindings} |
| 1897 | modelOptionsByProvider={modelOptionsByProvider} | 2008 | modelOptionsByInstance={modelOptionsByInstance} |
| 1898 | terminalOpen={terminalOpen} | 2009 | terminalOpen={terminalOpen} |
| 1899 | open={isComposerModelPickerOpen} | 2010 | open={isComposerModelPickerOpen} |
| 1900 | {...(composerProviderState.modelPickerIconClassName | 2011 | {...(composerProviderState.modelPickerIconClassName |
| ... | @@ -1906,7 +2017,7 @@ export const ChatComposer = memo( | ... | @@ -1906,7 +2017,7 @@ export const ChatComposer = memo( |
| 1906 | onOpenChange={(open) => { | 2017 | onOpenChange={(open) => { |
| 1907 | setIsComposerModelPickerOpen(open); | 2018 | setIsComposerModelPickerOpen(open); |
| 1908 | }} | 2019 | }} |
| 1909 | onProviderModelChange={onProviderModelSelect} | 2020 | onInstanceModelChange={onProviderModelSelect} |
| 1910 | /> | 2021 | /> |
| 1911 | 2022 | ||
| 1912 | {isComposerFooterCompact ? ( | 2023 | {isComposerFooterCompact ? ( |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+36-16| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_MODEL, | ||
| 2 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | ModelSelection, | 5 | ModelSelection, |
| 6 | ProviderInstanceId, | ||
| 7 | ProviderDriverKind, | ||
| 5 | ThreadId, | 8 | ThreadId, |
| 6 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 7 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 10 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| ... | @@ -50,8 +53,10 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str | ... | @@ -50,8 +53,10 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 50 | const threadId = ThreadId.make("thread-compact-menu"); | 53 | const threadId = ThreadId.make("thread-compact-menu"); |
| 51 | const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | 54 | const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); |
| 52 | const threadKey = scopedThreadKey(threadRef); | 55 | const threadKey = scopedThreadKey(threadRef); |
| 53 | const provider = props?.modelSelection?.provider ?? "claudeAgent"; | 56 | const provider = ProviderDriverKind.make("claudeAgent"); |
| 54 | const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; | 57 | const instanceId = ProviderInstanceId.make(props?.modelSelection?.instanceId ?? provider); |
| 58 | const model = | ||
| 59 | props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; | ||
| 55 | 60 | ||
| 56 | useComposerDraftStore.setState({ | 61 | useComposerDraftStore.setState({ |
| 57 | draftsByThreadKey: { | 62 | draftsByThreadKey: { |
| ... | @@ -62,9 +67,9 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str | ... | @@ -62,9 +67,9 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 62 | persistedAttachments: [], | 67 | persistedAttachments: [], |
| 63 | terminalContexts: [], | 68 | terminalContexts: [], |
| 64 | modelSelectionByProvider: { | 69 | modelSelectionByProvider: { |
| 65 | [provider]: createModelSelection(provider, model, props?.modelSelection?.options), | 70 | [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), |
| 66 | }, | 71 | }, |
| 67 | activeProvider: provider, | 72 | activeProvider: instanceId, |
| 68 | runtimeMode: null, | 73 | runtimeMode: null, |
| 69 | interactionMode: null, | 74 | interactionMode: null, |
| 70 | }, | 75 | }, |
| ... | @@ -178,7 +183,10 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -178,7 +183,10 @@ describe("CompactComposerControlsMenu", () => { |
| 178 | 183 | ||
| 179 | it("shows fast mode controls for Opus", async () => { | 184 | it("shows fast mode controls for Opus", async () => { |
| 180 | await using _ = await mountMenu({ | 185 | await using _ = await mountMenu({ |
| 181 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6"), | 186 | modelSelection: createModelSelection( |
| 187 | ProviderInstanceId.make("claudeAgent"), | ||
| 188 | "claude-opus-4-6", | ||
| 189 | ), | ||
| 182 | }); | 190 | }); |
| 183 | 191 | ||
| 184 | await page.getByLabelText("More composer controls").click(); | 192 | await page.getByLabelText("More composer controls").click(); |
| ... | @@ -193,7 +201,10 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -193,7 +201,10 @@ describe("CompactComposerControlsMenu", () => { |
| 193 | 201 | ||
| 194 | it("hides fast mode controls for non-Opus Claude models", async () => { | 202 | it("hides fast mode controls for non-Opus Claude models", async () => { |
| 195 | await using _ = await mountMenu({ | 203 | await using _ = await mountMenu({ |
| 196 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), | 204 | modelSelection: createModelSelection( |
| 205 | ProviderInstanceId.make("claudeAgent"), | ||
| 206 | "claude-sonnet-4-6", | ||
| 207 | ), | ||
| 197 | }); | 208 | }); |
| 198 | 209 | ||
| 199 | await page.getByLabelText("More composer controls").click(); | 210 | await page.getByLabelText("More composer controls").click(); |
| ... | @@ -205,7 +216,10 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -205,7 +216,10 @@ describe("CompactComposerControlsMenu", () => { |
| 205 | 216 | ||
| 206 | it("shows only the provided effort options", async () => { | 217 | it("shows only the provided effort options", async () => { |
| 207 | await using _ = await mountMenu({ | 218 | await using _ = await mountMenu({ |
| 208 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), | 219 | modelSelection: createModelSelection( |
| 220 | ProviderInstanceId.make("claudeAgent"), | ||
| 221 | "claude-sonnet-4-6", | ||
| 222 | ), | ||
| 209 | }); | 223 | }); |
| 210 | 224 | ||
| 211 | await page.getByLabelText("More composer controls").click(); | 225 | await page.getByLabelText("More composer controls").click(); |
| ... | @@ -222,9 +236,11 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -222,9 +236,11 @@ describe("CompactComposerControlsMenu", () => { |
| 222 | 236 | ||
| 223 | it("shows a Claude thinking on/off section for Haiku", async () => { | 237 | it("shows a Claude thinking on/off section for Haiku", async () => { |
| 224 | await using _ = await mountMenu({ | 238 | await using _ = await mountMenu({ |
| 225 | modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ | 239 | modelSelection: createModelSelection( |
| 226 | { id: "thinking", value: true }, | 240 | ProviderInstanceId.make("claudeAgent"), |
| 227 | ]), | 241 | "claude-haiku-4-5", |
| 242 | [{ id: "thinking", value: true }], | ||
| 243 | ), | ||
| 228 | }); | 244 | }); |
| 229 | 245 | ||
| 230 | await page.getByLabelText("More composer controls").click(); | 246 | await page.getByLabelText("More composer controls").click(); |
| ... | @@ -239,9 +255,11 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -239,9 +255,11 @@ describe("CompactComposerControlsMenu", () => { |
| 239 | 255 | ||
| 240 | it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { | 256 | it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { |
| 241 | await using _ = await mountMenu({ | 257 | await using _ = await mountMenu({ |
| 242 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 258 | modelSelection: createModelSelection( |
| 243 | { id: "effort", value: "high" }, | 259 | ProviderInstanceId.make("claudeAgent"), |
| 244 | ]), | 260 | "claude-opus-4-6", |
| 261 | [{ id: "effort", value: "high" }], | ||
| 262 | ), | ||
| 245 | prompt: "Ultrathink:\nInvestigate this", | 263 | prompt: "Ultrathink:\nInvestigate this", |
| 246 | }); | 264 | }); |
| 247 | 265 | ||
| ... | @@ -256,9 +274,11 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -256,9 +274,11 @@ describe("CompactComposerControlsMenu", () => { |
| 256 | 274 | ||
| 257 | it("warns when ultrathink appears in prompt body text", async () => { | 275 | it("warns when ultrathink appears in prompt body text", async () => { |
| 258 | await using _ = await mountMenu({ | 276 | await using _ = await mountMenu({ |
| 259 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | 277 | modelSelection: createModelSelection( |
| 260 | { id: "effort", value: "high" }, | 278 | ProviderInstanceId.make("claudeAgent"), |
| 261 | ]), | 279 | "claude-opus-4-6", |
| 280 | [{ id: "effort", value: "high" }], | ||
| 281 | ), | ||
| 262 | prompt: "Ultrathink:\nplease ultrathink about this problem", | 282 | prompt: "Ultrathink:\nplease ultrathink about this problem", |
| 263 | }); | 283 | }); |
| 264 | 284 |
apps/web/src/components/chat/ComposerCommandMenu.tsx+3-3| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProjectEntry, | 2 | type ProjectEntry, |
| 3 | type ProviderKind, | 3 | type ProviderDriverKind, |
| 4 | type ServerProviderSkill, | 4 | type ServerProviderSkill, |
| 5 | type ServerProviderSlashCommand, | 5 | type ServerProviderSlashCommand, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| ... | @@ -39,7 +39,7 @@ export type ComposerCommandItem = | ... | @@ -39,7 +39,7 @@ export type ComposerCommandItem = |
| 39 | | { | 39 | | { |
| 40 | id: string; | 40 | id: string; |
| 41 | type: "provider-slash-command"; | 41 | type: "provider-slash-command"; |
| 42 | provider: ProviderKind; | 42 | provider: ProviderDriverKind; |
| 43 | command: ServerProviderSlashCommand; | 43 | command: ServerProviderSlashCommand; |
| 44 | label: string; | 44 | label: string; |
| 45 | description: string; | 45 | description: string; |
| ... | @@ -47,7 +47,7 @@ export type ComposerCommandItem = | ... | @@ -47,7 +47,7 @@ export type ComposerCommandItem = |
| 47 | | { | 47 | | { |
| 48 | id: string; | 48 | id: string; |
| 49 | type: "skill"; | 49 | type: "skill"; |
| 50 | provider: ProviderKind; | 50 | provider: ProviderDriverKind; |
| 51 | skill: ServerProviderSkill; | 51 | skill: ServerProviderSkill; |
| 52 | label: string; | 52 | label: string; |
| 53 | description: string; | 53 | description: string; |
apps/web/src/components/chat/ModelListRow.tsx+26-7| ... | @@ -1,9 +1,8 @@ | ... | @@ -1,9 +1,8 @@ |
| 1 | import { type ProviderKind } from "@t3tools/contracts"; | 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { StarIcon } from "lucide-react"; | 3 | import { StarIcon } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | getDisplayModelName, | 5 | getDisplayModelName, |
| 6 | getProviderLabel, | ||
| 7 | getTriggerDisplayModelLabel, | 6 | getTriggerDisplayModelLabel, |
| 8 | type ModelEsque, | 7 | type ModelEsque, |
| 9 | PROVIDER_ICON_BY_PROVIDER, | 8 | PROVIDER_ICON_BY_PROVIDER, |
| ... | @@ -16,7 +15,17 @@ import { cn } from "~/lib/utils"; | ... | @@ -16,7 +15,17 @@ import { cn } from "~/lib/utils"; |
| 16 | export const ModelListRow = memo(function ModelListRow(props: { | 15 | export const ModelListRow = memo(function ModelListRow(props: { |
| 17 | index: number; | 16 | index: number; |
| 18 | model: ModelEsque; | 17 | model: ModelEsque; |
| 19 | provider: ProviderKind; | 18 | /** Instance the model belongs to — the routing key used in combobox values. */ |
| 19 | instanceId: ProviderInstanceId; | ||
| 20 | /** Driver kind of the instance — used for the provider icon glyph. */ | ||
| 21 | driverKind: ProviderDriverKind; | ||
| 22 | /** | ||
| 23 | * Display name to show in the secondary line (provider footer). Usually | ||
| 24 | * the instance's configured `displayName` so custom instances like | ||
| 25 | * "Codex Personal" render with their user-authored label. | ||
| 26 | */ | ||
| 27 | providerDisplayName: string; | ||
| 28 | providerAccentColor?: string | undefined; | ||
| 20 | isFavorite: boolean; | 29 | isFavorite: boolean; |
| 21 | showProvider: boolean; | 30 | showProvider: boolean; |
| 22 | preferShortName?: boolean; | 31 | preferShortName?: boolean; |
| ... | @@ -25,13 +34,16 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -25,13 +34,16 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 25 | jumpLabel?: string | null; | 34 | jumpLabel?: string | null; |
| 26 | onToggleFavorite: () => void; | 35 | onToggleFavorite: () => void; |
| 27 | }) { | 36 | }) { |
| 28 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; | 37 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; |
| 38 | const providerLabel = props.model.subProvider | ||
| 39 | ? `${props.providerDisplayName} · ${props.model.subProvider}` | ||
| 40 | : props.providerDisplayName; | ||
| 29 | 41 | ||
| 30 | return ( | 42 | return ( |
| 31 | <ComboboxItem | 43 | <ComboboxItem |
| 32 | hideIndicator | 44 | hideIndicator |
| 33 | index={props.index} | 45 | index={props.index} |
| 34 | value={`${props.provider}:${props.model.slug}`} | 46 | value={`${props.instanceId}:${props.model.slug}`} |
| 35 | contentClassName="flex w-full items-start gap-2" | 47 | contentClassName="flex w-full items-start gap-2" |
| 36 | className={cn( | 48 | className={cn( |
| 37 | "w-full cursor-pointer rounded px-3 py-2 transition-colors group", | 49 | "w-full cursor-pointer rounded px-3 py-2 transition-colors group", |
| ... | @@ -92,9 +104,16 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -92,9 +104,16 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 92 | </div> | 104 | </div> |
| 93 | {props.showProvider && ( | 105 | {props.showProvider && ( |
| 94 | <div className="flex items-center gap-1 mt-0.5"> | 106 | <div className="flex items-center gap-1 mt-0.5"> |
| 95 | <ProviderIcon className="size-3 shrink-0" /> | 107 | {ProviderIcon ? <ProviderIcon className="size-3 shrink-0" /> : null} |
| 108 | {props.providerAccentColor ? ( | ||
| 109 | <span | ||
| 110 | className="size-1.5 shrink-0 rounded-full" | ||
| 111 | style={{ backgroundColor: props.providerAccentColor }} | ||
| 112 | aria-hidden | ||
| 113 | /> | ||
| 114 | ) : null} | ||
| 96 | <span className="text-xs font-normal leading-snug text-muted-foreground/70 truncate"> | 115 | <span className="text-xs font-normal leading-snug text-muted-foreground/70 truncate"> |
| 97 | {getProviderLabel(props.provider, props.model)} | 116 | {providerLabel} |
| 98 | </span> | 117 | </span> |
| 99 | </div> | 118 | </div> |
| 100 | )} | 119 | )} |
apps/web/src/components/chat/ModelPickerContent.tsx+249-119| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderKind, | 2 | type ProviderInstanceId, |
| 3 | PROVIDER_DISPLAY_NAMES, | 3 | type ProviderDriverKind, |
| 4 | type ResolvedKeybindingsConfig, | 4 | type ResolvedKeybindingsConfig, |
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 7 | import { resolveSelectableModel } from "@t3tools/shared/model"; | 6 | import { resolveSelectableModel } from "@t3tools/shared/model"; |
| 8 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; | 7 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| ... | @@ -22,40 +21,89 @@ import { | ... | @@ -22,40 +21,89 @@ import { |
| 22 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 21 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 23 | import { cn } from "~/lib/utils"; | 22 | import { cn } from "~/lib/utils"; |
| 24 | import { TooltipProvider } from "../ui/tooltip"; | 23 | import { TooltipProvider } from "../ui/tooltip"; |
| 24 | import type { ProviderInstanceEntry } from "../../providerInstances"; | ||
| 25 | import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; | ||
| 25 | 26 | ||
| 26 | type ModelPickerItem = { | 27 | type ModelPickerItem = { |
| 27 | slug: string; | 28 | slug: string; |
| 28 | name: string; | 29 | name: string; |
| 29 | shortName?: string; | 30 | shortName?: string; |
| 30 | subProvider?: string; | 31 | subProvider?: string; |
| 31 | provider: ProviderKind; | 32 | instanceId: ProviderInstanceId; |
| 33 | driverKind: ProviderDriverKind; | ||
| 34 | instanceDisplayName: string; | ||
| 35 | instanceAccentColor?: string | undefined; | ||
| 36 | continuationGroupKey?: string | undefined; | ||
| 32 | }; | 37 | }; |
| 33 | 38 | ||
| 34 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); | 39 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 35 | 40 | ||
| 41 | // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs | ||
| 42 | // can contain colons (e.g. some vendor model ids), so we only split on the | ||
| 43 | // first colon — anything after that is the slug. | ||
| 44 | function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; slug: string } { | ||
| 45 | const colonIndex = key.indexOf(":"); | ||
| 46 | if (colonIndex === -1) { | ||
| 47 | return { instanceId: key as ProviderInstanceId, slug: "" }; | ||
| 48 | } | ||
| 49 | return { | ||
| 50 | instanceId: key.slice(0, colonIndex) as ProviderInstanceId, | ||
| 51 | slug: key.slice(colonIndex + 1), | ||
| 52 | }; | ||
| 53 | } | ||
| 54 | |||
| 36 | export const ModelPickerContent = memo(function ModelPickerContent(props: { | 55 | export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 37 | provider: ProviderKind; | 56 | /** The instance currently selected in the composer (combobox "value"). */ |
| 57 | activeInstanceId: ProviderInstanceId; | ||
| 38 | model: string; | 58 | model: string; |
| 39 | lockedProvider: ProviderKind | null; | 59 | /** |
| 40 | providers?: ReadonlyArray<ServerProvider>; | 60 | * When set, the picker is locked to the given driver kind — typically |
| 61 | * because the user is editing a previously-sent message and can't change | ||
| 62 | * which driver served the turn. Multiple instances of the same kind | ||
| 63 | * remain selectable (e.g. locked to `codex` still lets the user switch | ||
| 64 | * between the default Codex and a custom Codex Personal). | ||
| 65 | */ | ||
| 66 | lockedProvider: ProviderDriverKind | null; | ||
| 67 | lockedContinuationGroupKey?: string | null; | ||
| 68 | /** | ||
| 69 | * All configured provider instances in display order. Used to render | ||
| 70 | * the sidebar (one button per instance) and to resolve display names | ||
| 71 | * for the locked-mode header. | ||
| 72 | */ | ||
| 73 | instanceEntries: ReadonlyArray<ProviderInstanceEntry>; | ||
| 41 | keybindings?: ResolvedKeybindingsConfig; | 74 | keybindings?: ResolvedKeybindingsConfig; |
| 42 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<ModelEsque>>; | 75 | /** |
| 76 | * Model options per instance. Keyed by `ProviderInstanceId` so the | ||
| 77 | * default Codex instance and any custom Codex instances each have their | ||
| 78 | * own list (custom instances typically start with the same built-in | ||
| 79 | * model set but are free to diverge via customModels). | ||
| 80 | */ | ||
| 81 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>; | ||
| 43 | terminalOpen: boolean; | 82 | terminalOpen: boolean; |
| 44 | onRequestClose?: () => void; | 83 | onRequestClose?: () => void; |
| 45 | onProviderModelChange: (provider: ProviderKind, model: string) => void; | 84 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 46 | }) { | 85 | }) { |
| 47 | const { keybindings: providedKeybindings, modelOptionsByProvider, onProviderModelChange } = props; | 86 | const { |
| 87 | keybindings: providedKeybindings, | ||
| 88 | modelOptionsByInstance, | ||
| 89 | instanceEntries, | ||
| 90 | onInstanceModelChange, | ||
| 91 | } = props; | ||
| 48 | const [searchQuery, setSearchQuery] = useState(""); | 92 | const [searchQuery, setSearchQuery] = useState(""); |
| 49 | const searchInputRef = useRef<HTMLInputElement>(null); | 93 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 50 | const listRegionRef = useRef<HTMLDivElement>(null); | 94 | const listRegionRef = useRef<HTMLDivElement>(null); |
| 51 | const highlightedModelKeyRef = useRef<string | null>(null); | 95 | const highlightedModelKeyRef = useRef<string | null>(null); |
| 52 | const favorites = useSettings((s) => s.favorites ?? []); | 96 | const favorites = useSettings((s) => s.favorites ?? []); |
| 53 | const [selectedProvider, setSelectedProvider] = useState<ProviderKind | "favorites">(() => { | 97 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">( |
| 54 | if (props.lockedProvider !== null) { | 98 | () => { |
| 55 | return props.lockedProvider; | 99 | if (props.lockedProvider !== null) { |
| 56 | } | 100 | // When locked, prime the sidebar to the currently-active instance |
| 57 | return favorites.length > 0 ? "favorites" : props.provider; | 101 | // so jumping into the picker keeps the focused instance visible. |
| 58 | }); | 102 | return props.activeInstanceId; |
| 103 | } | ||
| 104 | return favorites.length > 0 ? "favorites" : props.activeInstanceId; | ||
| 105 | }, | ||
| 106 | ); | ||
| 59 | const keybindings = useMemo<ResolvedKeybindingsConfig>( | 107 | const keybindings = useMemo<ResolvedKeybindingsConfig>( |
| 60 | () => providedKeybindings ?? [], | 108 | () => providedKeybindings ?? [], |
| 61 | [providedKeybindings], | 109 | [providedKeybindings], |
| ... | @@ -66,9 +114,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -66,9 +114,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 66 | searchInputRef.current?.focus({ preventScroll: true }); | 114 | searchInputRef.current?.focus({ preventScroll: true }); |
| 67 | }, []); | 115 | }, []); |
| 68 | 116 | ||
| 69 | const handleSelectProvider = useCallback( | 117 | const handleSelectInstance = useCallback( |
| 70 | (provider: ProviderKind | "favorites") => { | 118 | (instanceId: ProviderInstanceId | "favorites") => { |
| 71 | setSelectedProvider(provider); | 119 | setSelectedInstanceId(instanceId); |
| 72 | window.requestAnimationFrame(() => { | 120 | window.requestAnimationFrame(() => { |
| 73 | focusSearchInput(); | 121 | focusSearchInput(); |
| 74 | }); | 122 | }); |
| ... | @@ -90,44 +138,97 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -90,44 +138,97 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 90 | }; | 138 | }; |
| 91 | }, [focusSearchInput]); | 139 | }, [focusSearchInput]); |
| 92 | 140 | ||
| 93 | // Create a Set for efficient lookup | 141 | // Create a Set for efficient lookup. Favorites are keyed by |
| 142 | // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind | ||
| 143 | // to ProviderInstanceId so pre-migration favorites keyed by driver slugs | ||
| 144 | // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals | ||
| 145 | // the driver slug. | ||
| 94 | const favoritesSet = useMemo(() => { | 146 | const favoritesSet = useMemo(() => { |
| 95 | return new Set(favorites.map((fav) => `${fav.provider}:${fav.model}`)); | 147 | return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); |
| 96 | }, [favorites]); | ||
| 97 | const favoriteOrder = useMemo(() => { | ||
| 98 | return new Map( | ||
| 99 | favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), | ||
| 100 | ); | ||
| 101 | }, [favorites]); | 148 | }, [favorites]); |
| 102 | 149 | ||
| 103 | const readyProviderSet = useMemo(() => { | 150 | /** |
| 104 | if (!props.providers || props.providers.length === 0) { | 151 | * Lookup table keyed by `instanceId`. Used for display name + driver |
| 105 | return null; | 152 | * kind enrichment and for `ready`/enabled filtering before flattening |
| 153 | * models into the search list. | ||
| 154 | */ | ||
| 155 | const entryByInstanceId = useMemo( | ||
| 156 | () => new Map(instanceEntries.map((entry) => [entry.instanceId, entry])), | ||
| 157 | [instanceEntries], | ||
| 158 | ); | ||
| 159 | const matchesLockedProvider = useCallback( | ||
| 160 | (entry: Pick<ProviderInstanceEntry, "driverKind" | "continuationGroupKey">): boolean => { | ||
| 161 | if (props.lockedProvider === null) return true; | ||
| 162 | if (entry.driverKind !== props.lockedProvider) return false; | ||
| 163 | if (!props.lockedContinuationGroupKey) return true; | ||
| 164 | return entry.continuationGroupKey === props.lockedContinuationGroupKey; | ||
| 165 | }, | ||
| 166 | [props.lockedContinuationGroupKey, props.lockedProvider], | ||
| 167 | ); | ||
| 168 | |||
| 169 | const readyInstanceSet = useMemo(() => { | ||
| 170 | const ready = new Set<ProviderInstanceId>(); | ||
| 171 | for (const entry of instanceEntries) { | ||
| 172 | if (entry.status === "ready") { | ||
| 173 | ready.add(entry.instanceId); | ||
| 174 | } | ||
| 106 | } | 175 | } |
| 107 | return new Set( | 176 | return ready; |
| 108 | props.providers | 177 | }, [instanceEntries]); |
| 109 | .filter((provider) => provider.status === "ready") | 178 | |
| 110 | .map((provider) => provider.provider), | 179 | // Flatten models into a searchable array. One pass over the |
| 111 | ); | 180 | // instance-keyed map; each model carries its instance id + driver kind |
| 112 | }, [props.providers]); | 181 | // so the list row can render the right icon and display name without |
| 113 | 182 | // another lookup. | |
| 114 | // Flatten models into a searchable array | ||
| 115 | const flatModels = useMemo(() => { | 183 | const flatModels = useMemo(() => { |
| 116 | return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { | 184 | const out: ModelPickerItem[] = []; |
| 117 | if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { | 185 | for (const [instanceId, models] of modelOptionsByInstance) { |
| 118 | return []; | 186 | const entry = entryByInstanceId.get(instanceId); |
| 187 | if (!entry) { | ||
| 188 | // Instance disappeared between renders (configuration change). Skip | ||
| 189 | // its models — stale options shouldn't appear in the picker. | ||
| 190 | continue; | ||
| 119 | } | 191 | } |
| 120 | return models.map((m) => ({ | 192 | if (!readyInstanceSet.has(instanceId)) { |
| 121 | slug: m.slug, | 193 | continue; |
| 122 | name: m.name, | 194 | } |
| 123 | ...(m.shortName ? { shortName: m.shortName } : {}), | 195 | for (const model of models) { |
| 124 | ...(m.subProvider ? { subProvider: m.subProvider } : {}), | 196 | out.push({ |
| 125 | provider: providerKind as ProviderKind, | 197 | slug: model.slug, |
| 126 | })) satisfies Array<ModelPickerItem>; | 198 | name: model.name, |
| 127 | }); | 199 | ...(model.shortName ? { shortName: model.shortName } : {}), |
| 128 | }, [props.modelOptionsByProvider, readyProviderSet]); | 200 | ...(model.subProvider ? { subProvider: model.subProvider } : {}), |
| 201 | instanceId, | ||
| 202 | driverKind: entry.driverKind, | ||
| 203 | instanceDisplayName: entry.displayName, | ||
| 204 | ...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}), | ||
| 205 | ...(entry.continuationGroupKey | ||
| 206 | ? { continuationGroupKey: entry.continuationGroupKey } | ||
| 207 | : {}), | ||
| 208 | }); | ||
| 209 | } | ||
| 210 | } | ||
| 211 | return out; | ||
| 212 | }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); | ||
| 213 | |||
| 214 | const isLocked = props.lockedProvider !== null; | ||
| 215 | const isSearching = searchQuery.trim().length > 0; | ||
| 216 | const lockedInstanceEntries = useMemo( | ||
| 217 | () => | ||
| 218 | props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], | ||
| 219 | [instanceEntries, matchesLockedProvider, props.lockedProvider], | ||
| 220 | ); | ||
| 221 | const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; | ||
| 222 | const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); | ||
| 223 | const sidebarInstanceEntries = showLockedInstanceSidebar | ||
| 224 | ? lockedInstanceEntries | ||
| 225 | : instanceEntries; | ||
| 226 | const instanceOrder = useMemo( | ||
| 227 | () => instanceEntries.map((entry) => entry.instanceId), | ||
| 228 | [instanceEntries], | ||
| 229 | ); | ||
| 129 | 230 | ||
| 130 | // Filter models based on search query and selected provider | 231 | // Filter models based on search query and selected instance |
| 131 | const filteredModels = useMemo(() => { | 232 | const filteredModels = useMemo(() => { |
| 132 | let result = flatModels; | 233 | let result = flatModels; |
| 133 | 234 | ||
| ... | @@ -138,13 +239,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -138,13 +239,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 138 | model, | 239 | model, |
| 139 | score: scoreModelPickerSearch( | 240 | score: scoreModelPickerSearch( |
| 140 | { | 241 | { |
| 141 | ...model, | 242 | name: model.name, |
| 142 | isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), | 243 | ...(model.shortName ? { shortName: model.shortName } : {}), |
| 244 | ...(model.subProvider ? { subProvider: model.subProvider } : {}), | ||
| 245 | driverKind: model.driverKind, | ||
| 246 | providerDisplayName: model.instanceDisplayName, | ||
| 247 | isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), | ||
| 143 | }, | 248 | }, |
| 144 | searchQuery, | 249 | searchQuery, |
| 145 | ), | 250 | ), |
| 146 | isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), | 251 | isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), |
| 147 | tieBreaker: buildModelPickerSearchText(model), | 252 | tieBreaker: buildModelPickerSearchText({ |
| 253 | name: model.name, | ||
| 254 | ...(model.shortName ? { shortName: model.shortName } : {}), | ||
| 255 | ...(model.subProvider ? { subProvider: model.subProvider } : {}), | ||
| 256 | driverKind: model.driverKind, | ||
| 257 | providerDisplayName: model.instanceDisplayName, | ||
| 258 | }), | ||
| 148 | })) | 259 | })) |
| 149 | .filter( | 260 | .filter( |
| 150 | ( | 261 | ( |
| ... | @@ -157,10 +268,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -157,10 +268,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 157 | } => rankedModel.score !== null, | 268 | } => rankedModel.score !== null, |
| 158 | ); | 269 | ); |
| 159 | 270 | ||
| 160 | // When searching, we only respect locked provider, ignoring sidebar selection | 271 | // When searching, we only respect locked provider (by driver kind), |
| 272 | // ignoring sidebar selection so account-scoped searches can find a | ||
| 273 | // model before the user chooses a specific instance rail item. | ||
| 161 | if (props.lockedProvider !== null) { | 274 | if (props.lockedProvider !== null) { |
| 162 | return rankedMatches | 275 | return rankedMatches |
| 163 | .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider) | 276 | .filter((rankedModel) => matchesLockedProvider(rankedModel.model)) |
| 164 | .toSorted((a, b) => { | 277 | .toSorted((a, b) => { |
| 165 | const scoreDelta = a.score - b.score; | 278 | const scoreDelta = a.score - b.score; |
| 166 | if (scoreDelta !== 0) { | 279 | if (scoreDelta !== 0) { |
| ... | @@ -188,72 +301,87 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -188,72 +301,87 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 188 | .map((rankedModel) => rankedModel.model); | 301 | .map((rankedModel) => rankedModel.model); |
| 189 | } | 302 | } |
| 190 | 303 | ||
| 191 | // Locked provider mode always shows that provider's models, with favorites first. | ||
| 192 | if (props.lockedProvider !== null) { | 304 | if (props.lockedProvider !== null) { |
| 193 | result = result.filter((m) => m.provider === props.lockedProvider); | 305 | result = result.filter((m) => matchesLockedProvider(m)); |
| 194 | } else if (selectedProvider === "favorites") { | 306 | if (showLockedInstanceSidebar) { |
| 195 | result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); | 307 | result = result.filter((m) => m.instanceId === selectedInstanceId); |
| 308 | } | ||
| 309 | } else if (selectedInstanceId === "favorites") { | ||
| 310 | result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); | ||
| 196 | } else { | 311 | } else { |
| 197 | result = result.filter((m) => m.provider === selectedProvider); | 312 | result = result.filter((m) => m.instanceId === selectedInstanceId); |
| 198 | } | 313 | } |
| 199 | 314 | ||
| 200 | return result.toSorted((a, b) => { | 315 | return sortProviderModelItems(result, { |
| 201 | const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`); | 316 | favoriteModelKeys: favoritesSet, |
| 202 | const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`); | 317 | groupFavorites: selectedInstanceId !== "favorites", |
| 203 | 318 | instanceOrder: selectedInstanceId === "favorites" ? instanceOrder : [], | |
| 204 | if (aOrder !== undefined && bOrder !== undefined) { | ||
| 205 | return aOrder - bOrder; | ||
| 206 | } | ||
| 207 | if (aOrder !== undefined) { | ||
| 208 | return -1; | ||
| 209 | } | ||
| 210 | if (bOrder !== undefined) { | ||
| 211 | return 1; | ||
| 212 | } | ||
| 213 | return 0; | ||
| 214 | }); | 319 | }); |
| 215 | }, [ | 320 | }, [ |
| 216 | favoriteOrder, | ||
| 217 | favoritesSet, | 321 | favoritesSet, |
| 218 | flatModels, | 322 | flatModels, |
| 323 | instanceOrder, | ||
| 324 | matchesLockedProvider, | ||
| 219 | props.lockedProvider, | 325 | props.lockedProvider, |
| 220 | searchQuery, | 326 | searchQuery, |
| 221 | selectedProvider, | 327 | showLockedInstanceSidebar, |
| 328 | selectedInstanceId, | ||
| 222 | ]); | 329 | ]); |
| 223 | 330 | ||
| 224 | const handleModelSelect = useCallback( | 331 | const handleModelSelect = useCallback( |
| 225 | (modelSlug: string, provider: ProviderKind) => { | 332 | (modelSlug: string, instanceId: ProviderInstanceId) => { |
| 226 | const resolvedModel = resolveSelectableModel( | 333 | const options = modelOptionsByInstance.get(instanceId); |
| 227 | provider, | 334 | if (!options) { |
| 228 | modelSlug, | 335 | return; |
| 229 | modelOptionsByProvider[provider], | 336 | } |
| 230 | ); | 337 | const entry = entryByInstanceId.get(instanceId); |
| 338 | if (!entry) { | ||
| 339 | return; | ||
| 340 | } | ||
| 341 | // `resolveSelectableModel` uses the driver kind for normalization | ||
| 342 | // (slug casing etc.). Custom instances share their driver's | ||
| 343 | // normalization rules, so pass the driver kind here. | ||
| 344 | const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); | ||
| 231 | if (resolvedModel) { | 345 | if (resolvedModel) { |
| 232 | onProviderModelChange(provider, resolvedModel); | 346 | onInstanceModelChange(instanceId, resolvedModel); |
| 233 | } | 347 | } |
| 234 | }, | 348 | }, |
| 235 | [modelOptionsByProvider, onProviderModelChange], | 349 | [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange], |
| 236 | ); | 350 | ); |
| 237 | 351 | ||
| 238 | const toggleFavorite = useCallback( | 352 | const toggleFavorite = useCallback( |
| 239 | (provider: ProviderKind, model: string) => { | 353 | (instanceId: ProviderInstanceId, model: string) => { |
| 240 | const newFavorites = [...favorites]; | 354 | const newFavorites = [...favorites]; |
| 241 | const index = newFavorites.findIndex((f) => f.provider === provider && f.model === model); | 355 | const index = newFavorites.findIndex((f) => f.provider === instanceId && f.model === model); |
| 242 | if (index >= 0) { | 356 | if (index >= 0) { |
| 243 | newFavorites.splice(index, 1); | 357 | newFavorites.splice(index, 1); |
| 244 | } else { | 358 | } else { |
| 245 | newFavorites.push({ provider, model }); | 359 | newFavorites.push({ provider: instanceId, model }); |
| 246 | } | 360 | } |
| 247 | updateSettings({ favorites: newFavorites }); | 361 | updateSettings({ favorites: newFavorites }); |
| 248 | }, | 362 | }, |
| 249 | [favorites, updateSettings], | 363 | [favorites, updateSettings], |
| 250 | ); | 364 | ); |
| 251 | 365 | ||
| 252 | const isLocked = props.lockedProvider !== null; | ||
| 253 | const isSearching = searchQuery.trim().length > 0; | ||
| 254 | const showSidebar = !isLocked && !isSearching; | ||
| 255 | const LockedProviderIcon = | 366 | const LockedProviderIcon = |
| 256 | isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; | 367 | isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; |
| 368 | // Header label for locked mode. Use the active instance's displayName | ||
| 369 | // when the lock narrows to exactly one instance (so "Codex Personal" | ||
| 370 | // shows instead of the generic driver label); fall back to the first | ||
| 371 | // matching entry otherwise. | ||
| 372 | const lockedHeaderLabel = useMemo(() => { | ||
| 373 | if (!isLocked || !props.lockedProvider) return null; | ||
| 374 | const matches = instanceEntries.filter((entry) => matchesLockedProvider(entry)); | ||
| 375 | if (matches.length === 0) return null; | ||
| 376 | const active = matches.find((entry) => entry.instanceId === props.activeInstanceId); | ||
| 377 | return (active ?? matches[0])?.displayName ?? null; | ||
| 378 | }, [ | ||
| 379 | isLocked, | ||
| 380 | matchesLockedProvider, | ||
| 381 | props.lockedProvider, | ||
| 382 | props.activeInstanceId, | ||
| 383 | instanceEntries, | ||
| 384 | ]); | ||
| 257 | const modelJumpCommandByKey = useMemo(() => { | 385 | const modelJumpCommandByKey = useMemo(() => { |
| 258 | const mapping = new Map< | 386 | const mapping = new Map< |
| 259 | string, | 387 | string, |
| ... | @@ -264,7 +392,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -264,7 +392,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 264 | if (!jumpCommand) { | 392 | if (!jumpCommand) { |
| 265 | return mapping; | 393 | return mapping; |
| 266 | } | 394 | } |
| 267 | mapping.set(`${model.provider}:${model.slug}`, jumpCommand); | 395 | mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand); |
| 268 | } | 396 | } |
| 269 | return mapping; | 397 | return mapping; |
| 270 | }, [filteredModels]); | 398 | }, [filteredModels]); |
| ... | @@ -273,16 +401,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -273,16 +401,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 273 | [modelJumpCommandByKey], | 401 | [modelJumpCommandByKey], |
| 274 | ); | 402 | ); |
| 275 | const allModelKeys = useMemo( | 403 | const allModelKeys = useMemo( |
| 276 | (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`), | 404 | (): string[] => flatModels.map((model) => `${model.instanceId}:${model.slug}`), |
| 277 | [flatModels], | 405 | [flatModels], |
| 278 | ); | 406 | ); |
| 279 | const filteredModelKeys = useMemo( | 407 | const filteredModelKeys = useMemo( |
| 280 | (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`), | 408 | (): string[] => filteredModels.map((model) => `${model.instanceId}:${model.slug}`), |
| 281 | [filteredModels], | 409 | [filteredModels], |
| 282 | ); | 410 | ); |
| 283 | const filteredModelByKey = useMemo( | 411 | const filteredModelByKey = useMemo( |
| 284 | (): ReadonlyMap<string, ModelPickerItem> => | 412 | (): ReadonlyMap<string, ModelPickerItem> => |
| 285 | new Map(filteredModels.map((model) => [`${model.provider}:${model.slug}`, model] as const)), | 413 | new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), |
| 286 | [filteredModels], | 414 | [filteredModels], |
| 287 | ); | 415 | ); |
| 288 | const modelJumpShortcutContext = useMemo( | 416 | const modelJumpShortcutContext = useMemo( |
| ... | @@ -331,10 +459,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -331,10 +459,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 331 | if (!targetModelKey) { | 459 | if (!targetModelKey) { |
| 332 | return; | 460 | return; |
| 333 | } | 461 | } |
| 334 | const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string]; | 462 | const { instanceId, slug } = splitInstanceModelKey(targetModelKey); |
| 335 | event.preventDefault(); | 463 | event.preventDefault(); |
| 336 | event.stopPropagation(); | 464 | event.stopPropagation(); |
| 337 | handleModelSelect(slug, provider); | 465 | handleModelSelect(slug, instanceId); |
| 338 | }; | 466 | }; |
| 339 | 467 | ||
| 340 | window.addEventListener("keydown", onWindowKeyDown, true); | 468 | window.addEventListener("keydown", onWindowKeyDown, true); |
| ... | @@ -392,25 +520,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -392,25 +520,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 392 | <div | 520 | <div |
| 393 | className={cn( | 521 | className={cn( |
| 394 | "relative flex h-screen max-h-96 w-screen max-w-100 overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | 522 | "relative flex h-screen max-h-96 w-screen max-w-100 overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", |
| 395 | isLocked ? "flex-col" : "flex-row", | 523 | isLocked && !showLockedInstanceSidebar ? "flex-col" : "flex-row", |
| 396 | )} | 524 | )} |
| 397 | > | 525 | > |
| 398 | {/* Locked provider header (only shown in locked mode) */} | 526 | {/* Locked provider header (only shown in locked mode) */} |
| 399 | {isLocked && LockedProviderIcon && props.lockedProvider && ( | 527 | {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && ( |
| 400 | <div className="flex items-center gap-2 px-4 py-3 border-b"> | 528 | <div className="flex items-center gap-2 px-4 py-3 border-b"> |
| 401 | <LockedProviderIcon className="size-5 shrink-0" /> | 529 | <LockedProviderIcon className="size-5 shrink-0" /> |
| 402 | <span className="font-medium text-sm"> | 530 | <span className="font-medium text-sm">{lockedHeaderLabel}</span> |
| 403 | {PROVIDER_DISPLAY_NAMES[props.lockedProvider]} | ||
| 404 | </span> | ||
| 405 | </div> | 531 | </div> |
| 406 | )} | 532 | )} |
| 407 | 533 | ||
| 408 | {/* Sidebar (only in unlocked mode) */} | 534 | {/* Sidebar (only in unlocked mode) */} |
| 409 | {showSidebar && ( | 535 | {showSidebar && ( |
| 410 | <ModelPickerSidebar | 536 | <ModelPickerSidebar |
| 411 | selectedProvider={selectedProvider} | 537 | selectedInstanceId={selectedInstanceId} |
| 412 | onSelectProvider={handleSelectProvider} | 538 | onSelectInstance={handleSelectInstance} |
| 413 | {...(props.providers && { providers: props.providers })} | 539 | instanceEntries={sidebarInstanceEntries} |
| 540 | showFavorites={!isLocked} | ||
| 541 | showComingSoon={!isLocked} | ||
| 414 | /> | 542 | /> |
| 415 | )} | 543 | )} |
| 416 | 544 | ||
| ... | @@ -422,7 +550,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -422,7 +550,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 422 | filter={null} | 550 | filter={null} |
| 423 | autoHighlight | 551 | autoHighlight |
| 424 | open | 552 | open |
| 425 | value={`${props.provider}:${props.model}`} | 553 | value={`${props.activeInstanceId}:${props.model}`} |
| 426 | onItemHighlighted={(modelKey) => { | 554 | onItemHighlighted={(modelKey) => { |
| 427 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; | 555 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; |
| 428 | }} | 556 | }} |
| ... | @@ -430,14 +558,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -430,14 +558,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 430 | if (typeof modelKey !== "string") { | 558 | if (typeof modelKey !== "string") { |
| 431 | return; | 559 | return; |
| 432 | } | 560 | } |
| 433 | const [provider, slug] = modelKey.split(":") as [ProviderKind, string]; | 561 | const { instanceId, slug } = splitInstanceModelKey(modelKey); |
| 434 | handleModelSelect(slug, provider); | 562 | handleModelSelect(slug, instanceId); |
| 435 | }} | 563 | }} |
| 436 | > | 564 | > |
| 437 | <div | 565 | <div |
| 438 | className={cn( | 566 | className={cn( |
| 439 | "flex min-h-0 flex-1 flex-col overflow-hidden", | 567 | "flex min-h-0 flex-1 flex-col overflow-hidden", |
| 440 | isLocked ? "min-w-0" : showSidebar && "border-l", | 568 | isLocked && !showLockedInstanceSidebar ? "min-w-0" : showSidebar && "border-l", |
| 441 | )} | 569 | )} |
| 442 | > | 570 | > |
| 443 | {/* Search bar */} | 571 | {/* Search bar */} |
| ... | @@ -464,11 +592,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -464,11 +592,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 464 | ).preventBaseUIHandler?.(); | 592 | ).preventBaseUIHandler?.(); |
| 465 | e.preventDefault(); | 593 | e.preventDefault(); |
| 466 | e.stopPropagation(); | 594 | e.stopPropagation(); |
| 467 | const [provider, slug] = highlightedModelKeyRef.current.split(":") as [ | 595 | const { instanceId, slug } = splitInstanceModelKey( |
| 468 | ProviderKind, | 596 | highlightedModelKeyRef.current, |
| 469 | string, | 597 | ); |
| 470 | ]; | 598 | handleModelSelect(slug, instanceId); |
| 471 | handleModelSelect(slug, provider); | ||
| 472 | return; | 599 | return; |
| 473 | } | 600 | } |
| 474 | e.stopPropagation(); | 601 | e.stopPropagation(); |
| ... | @@ -495,14 +622,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -495,14 +622,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 495 | key={modelKey} | 622 | key={modelKey} |
| 496 | index={index} | 623 | index={index} |
| 497 | model={model} | 624 | model={model} |
| 498 | provider={model.provider} | 625 | instanceId={model.instanceId} |
| 626 | driverKind={model.driverKind} | ||
| 627 | providerDisplayName={model.instanceDisplayName} | ||
| 628 | providerAccentColor={model.instanceAccentColor} | ||
| 499 | isFavorite={favoritesSet.has(modelKey)} | 629 | isFavorite={favoritesSet.has(modelKey)} |
| 500 | showProvider={!isLocked} | 630 | showProvider={!isLocked || showLockedInstanceSidebar} |
| 501 | preferShortName={!isLocked} | 631 | preferShortName={!isLocked} |
| 502 | useTriggerLabel={isLocked} | 632 | useTriggerLabel={isLocked && !showLockedInstanceSidebar} |
| 503 | showNewBadge={isModelPickerNewModel(model.provider, model.slug)} | 633 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} |
| 504 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} | 634 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} |
| 505 | onToggleFavorite={() => toggleFavorite(model.provider, model.slug)} | 635 | onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} |
| 506 | /> | 636 | /> |
| 507 | ); | 637 | ); |
| 508 | })} | 638 | })} |
apps/web/src/components/chat/ModelPickerSidebar.tsx+213-160| ... | @@ -1,28 +1,32 @@ | ... | @@ -1,28 +1,32 @@ |
| 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo, useMemo } from "react"; |
| 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; | 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; |
| 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; | 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; |
| 5 | import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; | 5 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 6 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | import { cn } from "~/lib/utils"; | 8 | import { cn } from "~/lib/utils"; |
| 8 | import { getProviderSnapshot } from "../../providerModels"; | 9 | import type { ProviderInstanceEntry } from "../../providerInstances"; |
| 9 | 10 | ||
| 10 | function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string { | 11 | /** |
| 11 | if (!live) { | 12 | * Build the hover tooltip for an instance button. Mirrors the old |
| 12 | return `${label} — waiting for provider status…`; | 13 | * kind-based copy but uses the entry's configured `displayName` so custom |
| 13 | } | 14 | * instances get their user-authored name (e.g. "Codex Personal — Unavailable."). |
| 14 | if (live.status === "ready") { | 15 | */ |
| 16 | function describeUnavailableInstance(entry: ProviderInstanceEntry): string { | ||
| 17 | const label = entry.displayName; | ||
| 18 | if (entry.status === "ready") { | ||
| 15 | return label; | 19 | return label; |
| 16 | } | 20 | } |
| 17 | const kind = | 21 | const kind = |
| 18 | live.status === "error" | 22 | entry.status === "error" |
| 19 | ? "Unavailable" | 23 | ? "Unavailable" |
| 20 | : live.status === "warning" | 24 | : entry.status === "warning" |
| 21 | ? "Limited" | 25 | ? "Limited" |
| 22 | : live.status === "disabled" | 26 | : entry.status === "disabled" |
| 23 | ? "Disabled in settings" | 27 | ? "Disabled in settings" |
| 24 | : "Not ready"; | 28 | : "Not ready"; |
| 25 | const msg = live.message?.trim(); | 29 | const msg = entry.snapshot.message?.trim(); |
| 26 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; | 30 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; |
| 27 | } | 31 | } |
| 28 | 32 | ||
| ... | @@ -39,172 +43,221 @@ const PICKER_TOOLTIP_SIDE = "left" as const; | ... | @@ -39,172 +43,221 @@ const PICKER_TOOLTIP_SIDE = "left" as const; |
| 39 | const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; | 43 | const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; |
| 40 | 44 | ||
| 41 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | 45 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 42 | selectedProvider: ProviderKind | "favorites"; | 46 | selectedInstanceId: ProviderInstanceId | "favorites"; |
| 43 | onSelectProvider: (provider: ProviderKind | "favorites") => void; | 47 | onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; |
| 44 | providers?: ReadonlyArray<ServerProvider>; | 48 | /** |
| 49 | * Instance entries to render as rail buttons. Each entry becomes one icon | ||
| 50 | * keyed by `instanceId`, so the default built-in Codex and a user-authored | ||
| 51 | * `codex_personal` appear as two distinct rail items, each routing to | ||
| 52 | * their own model list. | ||
| 53 | */ | ||
| 54 | instanceEntries: ReadonlyArray<ProviderInstanceEntry>; | ||
| 55 | /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ | ||
| 56 | showFavorites?: boolean; | ||
| 57 | /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ | ||
| 58 | showComingSoon?: boolean; | ||
| 59 | /** | ||
| 60 | * Instance id values that should render the "new" sparkle badge. Callers | ||
| 61 | * pass the subset of default built-in ids they want flagged (custom | ||
| 62 | * instances are never flagged — the user just made them). | ||
| 63 | */ | ||
| 64 | newBadgeInstanceIds?: ReadonlySet<ProviderInstanceId>; | ||
| 45 | }) { | 65 | }) { |
| 46 | const handleProviderClick = (provider: ProviderKind | "favorites") => { | 66 | const handleSelect = (instanceId: ProviderInstanceId | "favorites") => { |
| 47 | props.onSelectProvider(provider); | 67 | props.onSelectInstance(instanceId); |
| 48 | }; | 68 | }; |
| 69 | const showFavorites = props.showFavorites ?? true; | ||
| 70 | const showComingSoon = props.showComingSoon ?? true; | ||
| 71 | const duplicateDriverCounts = useMemo(() => { | ||
| 72 | const counts = new Map<string, number>(); | ||
| 73 | for (const entry of props.instanceEntries) { | ||
| 74 | counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); | ||
| 75 | } | ||
| 76 | return counts; | ||
| 77 | }, [props.instanceEntries]); | ||
| 49 | 78 | ||
| 50 | return ( | 79 | return ( |
| 51 | <div className="flex flex-col w-12 border-r bg-muted/30 p-1 overflow-y-auto gap-1"> | 80 | <ScrollArea |
| 52 | {/* Favorites section */} | 81 | hideScrollbars |
| 53 | <div className="pb-1 mb-1 border-b"> | 82 | scrollFade |
| 54 | <div className="relative w-full"> | 83 | className="w-12 shrink-0 border-r bg-muted/30" |
| 55 | {props.selectedProvider === "favorites" && <div className={SELECTED_INDICATOR_CLASS} />} | 84 | data-model-picker-sidebar="true" |
| 56 | <Tooltip> | 85 | > |
| 57 | <TooltipTrigger | 86 | <div className="flex min-h-full flex-col gap-1 p-1"> |
| 58 | render={ | 87 | {/* Favorites section */} |
| 59 | <button | 88 | {showFavorites ? ( |
| 60 | className={cn( | 89 | <div className="pb-1 mb-1 border-b"> |
| 61 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | 90 | <div className="relative w-full"> |
| 62 | props.selectedProvider === "favorites" && SELECTED_BUTTON_CLASS, | 91 | {props.selectedInstanceId === "favorites" && ( |
| 63 | )} | 92 | <div className={SELECTED_INDICATOR_CLASS} /> |
| 64 | onClick={() => handleProviderClick("favorites")} | 93 | )} |
| 65 | type="button" | 94 | <Tooltip> |
| 66 | data-model-picker-provider="favorites" | 95 | <TooltipTrigger |
| 67 | aria-label="Favorites" | 96 | render={ |
| 97 | <button | ||
| 98 | className={cn( | ||
| 99 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | ||
| 100 | props.selectedInstanceId === "favorites" && SELECTED_BUTTON_CLASS, | ||
| 101 | )} | ||
| 102 | onClick={() => handleSelect("favorites")} | ||
| 103 | type="button" | ||
| 104 | data-model-picker-provider="favorites" | ||
| 105 | aria-label="Favorites" | ||
| 106 | > | ||
| 107 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | ||
| 108 | </button> | ||
| 109 | } | ||
| 110 | /> | ||
| 111 | <TooltipPopup | ||
| 112 | side={PICKER_TOOLTIP_SIDE} | ||
| 113 | align="center" | ||
| 114 | className={PICKER_TOOLTIP_CLASS} | ||
| 68 | > | 115 | > |
| 69 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | 116 | Favorites |
| 70 | </button> | 117 | </TooltipPopup> |
| 71 | } | 118 | </Tooltip> |
| 72 | /> | 119 | </div> |
| 73 | <TooltipPopup | 120 | </div> |
| 74 | side={PICKER_TOOLTIP_SIDE} | 121 | ) : null} |
| 75 | align="center" | ||
| 76 | className={PICKER_TOOLTIP_CLASS} | ||
| 77 | > | ||
| 78 | Favorites | ||
| 79 | </TooltipPopup> | ||
| 80 | </Tooltip> | ||
| 81 | </div> | ||
| 82 | </div> | ||
| 83 | 122 | ||
| 84 | {/* Provider buttons */} | 123 | {/* Instance buttons (one per configured instance — built-in + custom) */} |
| 85 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | 124 | {props.instanceEntries.map((entry) => { |
| 86 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | 125 | const isDisabled = !entry.isAvailable || entry.status !== "ready"; |
| 87 | const liveProvider = props.providers | 126 | const isSelected = props.selectedInstanceId === entry.instanceId; |
| 88 | ? getProviderSnapshot(props.providers, option.value) | 127 | const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; |
| 89 | : undefined; | 128 | const showInstanceBadge = |
| 129 | Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; | ||
| 90 | 130 | ||
| 91 | const isDisabled = !liveProvider || liveProvider.status !== "ready"; | 131 | const tooltip = isDisabled |
| 92 | const isSelected = props.selectedProvider === option.value; | 132 | ? describeUnavailableInstance(entry) |
| 93 | const badge = option.pickerSidebarBadge; | 133 | : showNewBadge |
| 134 | ? `${entry.displayName} — New` | ||
| 135 | : entry.displayName; | ||
| 94 | 136 | ||
| 95 | const providerTooltip = isDisabled | 137 | const button = ( |
| 96 | ? describeUnavailableProvider(option.label, liveProvider) | 138 | <button |
| 97 | : badge === "new" | 139 | data-model-picker-provider={entry.instanceId} |
| 98 | ? `${option.label} — New` | 140 | className={cn( |
| 99 | : option.label; | 141 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", |
| 142 | isSelected && SELECTED_BUTTON_CLASS, | ||
| 143 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", | ||
| 144 | )} | ||
| 145 | data-provider-accent-color={entry.accentColor} | ||
| 146 | onClick={() => !isDisabled && handleSelect(entry.instanceId)} | ||
| 147 | disabled={isDisabled} | ||
| 148 | type="button" | ||
| 149 | aria-label={ | ||
| 150 | isDisabled | ||
| 151 | ? tooltip | ||
| 152 | : showNewBadge | ||
| 153 | ? `${entry.displayName}, new` | ||
| 154 | : entry.displayName | ||
| 155 | } | ||
| 156 | > | ||
| 157 | <ProviderInstanceIcon | ||
| 158 | driverKind={entry.driverKind} | ||
| 159 | displayName={entry.displayName} | ||
| 160 | accentColor={entry.accentColor} | ||
| 161 | showBadge={showInstanceBadge} | ||
| 162 | className="size-6" | ||
| 163 | iconClassName="size-5" | ||
| 164 | /> | ||
| 165 | {showNewBadge ? ( | ||
| 166 | <span className={NEW_BADGE_CLASS} aria-hidden> | ||
| 167 | <SparklesIcon className="size-2" /> | ||
| 168 | </span> | ||
| 169 | ) : null} | ||
| 170 | </button> | ||
| 171 | ); | ||
| 100 | 172 | ||
| 101 | const button = ( | 173 | const trigger = isDisabled ? ( |
| 102 | <button | 174 | <span className="relative block w-full">{button}</span> |
| 103 | data-model-picker-provider={option.value} | 175 | ) : ( |
| 104 | className={cn( | 176 | button |
| 105 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | 177 | ); |
| 106 | isSelected && SELECTED_BUTTON_CLASS, | ||
| 107 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", | ||
| 108 | )} | ||
| 109 | onClick={() => !isDisabled && handleProviderClick(option.value)} | ||
| 110 | disabled={isDisabled} | ||
| 111 | type="button" | ||
| 112 | aria-label={ | ||
| 113 | isDisabled | ||
| 114 | ? (providerTooltip ?? option.label) | ||
| 115 | : badge === "new" | ||
| 116 | ? `${option.label}, new` | ||
| 117 | : option.label | ||
| 118 | } | ||
| 119 | > | ||
| 120 | <OptionIcon className="size-5 shrink-0" aria-hidden /> | ||
| 121 | {badge === "new" ? ( | ||
| 122 | <span className={NEW_BADGE_CLASS} aria-hidden> | ||
| 123 | <SparklesIcon className="size-2" /> | ||
| 124 | </span> | ||
| 125 | ) : badge === "soon" ? ( | ||
| 126 | <span className={SOON_BADGE_CLASS} aria-hidden> | ||
| 127 | <Clock3Icon className="size-2" /> | ||
| 128 | </span> | ||
| 129 | ) : null} | ||
| 130 | </button> | ||
| 131 | ); | ||
| 132 | 178 | ||
| 133 | const trigger = isDisabled ? ( | 179 | return ( |
| 134 | <span className="relative block w-full">{button}</span> | 180 | <div key={entry.instanceId} className="relative w-full"> |
| 135 | ) : ( | 181 | {isSelected && <div className={SELECTED_INDICATOR_CLASS} />} |
| 136 | button | 182 | <Tooltip> |
| 137 | ); | 183 | <TooltipTrigger render={trigger} /> |
| 184 | <TooltipPopup | ||
| 185 | side={PICKER_TOOLTIP_SIDE} | ||
| 186 | align="center" | ||
| 187 | className={PICKER_TOOLTIP_CLASS} | ||
| 188 | > | ||
| 189 | {tooltip} | ||
| 190 | </TooltipPopup> | ||
| 191 | </Tooltip> | ||
| 192 | </div> | ||
| 193 | ); | ||
| 194 | })} | ||
| 138 | 195 | ||
| 139 | return ( | 196 | {showComingSoon ? ( |
| 140 | <div key={option.value} className="relative w-full"> | 197 | <> |
| 141 | {isSelected && <div className={SELECTED_INDICATOR_CLASS} />} | 198 | {/* Gemini button (coming soon) */} |
| 142 | <Tooltip> | 199 | <Tooltip> |
| 143 | <TooltipTrigger render={trigger} /> | 200 | <TooltipTrigger |
| 201 | render={ | ||
| 202 | <span className="relative block w-full"> | ||
| 203 | <button | ||
| 204 | className={cn( | ||
| 205 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | ||
| 206 | )} | ||
| 207 | disabled | ||
| 208 | type="button" | ||
| 209 | data-model-picker-provider="gemini-coming-soon" | ||
| 210 | aria-label="Gemini — coming soon" | ||
| 211 | > | ||
| 212 | <Gemini className="size-5 text-muted-foreground/85" aria-hidden /> | ||
| 213 | <span className={SOON_BADGE_CLASS} aria-hidden> | ||
| 214 | <Clock3Icon className="size-2" /> | ||
| 215 | </span> | ||
| 216 | </button> | ||
| 217 | </span> | ||
| 218 | } | ||
| 219 | /> | ||
| 144 | <TooltipPopup | 220 | <TooltipPopup |
| 145 | side={PICKER_TOOLTIP_SIDE} | 221 | side={PICKER_TOOLTIP_SIDE} |
| 146 | align="center" | 222 | align="center" |
| 147 | className={PICKER_TOOLTIP_CLASS} | 223 | className={PICKER_TOOLTIP_CLASS} |
| 148 | > | 224 | > |
| 149 | {providerTooltip} | 225 | Gemini — Coming soon |
| 150 | </TooltipPopup> | 226 | </TooltipPopup> |
| 151 | </Tooltip> | 227 | </Tooltip> |
| 152 | </div> | 228 | {/* Github Copilot button (coming soon) */} |
| 153 | ); | 229 | <Tooltip> |
| 154 | })} | 230 | <TooltipTrigger |
| 155 | 231 | render={ | |
| 156 | {/* Gemini button (coming soon) */} | 232 | <span className="relative block w-full"> |
| 157 | <Tooltip> | 233 | <button |
| 158 | <TooltipTrigger | 234 | className={cn( |
| 159 | render={ | 235 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", |
| 160 | <span className="relative block w-full"> | 236 | )} |
| 161 | <button | 237 | disabled |
| 162 | className={cn( | 238 | type="button" |
| 163 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | 239 | data-model-picker-provider="github-copilot-coming-soon" |
| 164 | )} | 240 | aria-label="Github Copilot — coming soon" |
| 165 | disabled | 241 | > |
| 166 | type="button" | 242 | <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden /> |
| 167 | data-model-picker-provider="gemini-coming-soon" | 243 | <span className={SOON_BADGE_CLASS} aria-hidden> |
| 168 | aria-label="Gemini — coming soon" | 244 | <Clock3Icon className="size-2" /> |
| 169 | > | 245 | </span> |
| 170 | <Gemini className="size-5 text-muted-foreground/85" aria-hidden /> | 246 | </button> |
| 171 | <span className={SOON_BADGE_CLASS} aria-hidden> | 247 | </span> |
| 172 | <Clock3Icon className="size-2" /> | 248 | } |
| 173 | </span> | 249 | /> |
| 174 | </button> | 250 | <TooltipPopup |
| 175 | </span> | 251 | side={PICKER_TOOLTIP_SIDE} |
| 176 | } | 252 | align="center" |
| 177 | /> | 253 | className={PICKER_TOOLTIP_CLASS} |
| 178 | <TooltipPopup side={PICKER_TOOLTIP_SIDE} align="center" className={PICKER_TOOLTIP_CLASS}> | ||
| 179 | Gemini — Coming soon | ||
| 180 | </TooltipPopup> | ||
| 181 | </Tooltip> | ||
| 182 | {/* Github Copilot button (coming soon) */} | ||
| 183 | <Tooltip> | ||
| 184 | <TooltipTrigger | ||
| 185 | render={ | ||
| 186 | <span className="relative block w-full"> | ||
| 187 | <button | ||
| 188 | className={cn( | ||
| 189 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | ||
| 190 | )} | ||
| 191 | disabled | ||
| 192 | type="button" | ||
| 193 | data-model-picker-provider="github-copilot-coming-soon" | ||
| 194 | aria-label="Github Copilot — coming soon" | ||
| 195 | > | 254 | > |
| 196 | <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden /> | 255 | Github Copilot — Coming soon |
| 197 | <span className={SOON_BADGE_CLASS} aria-hidden> | 256 | </TooltipPopup> |
| 198 | <Clock3Icon className="size-2" /> | 257 | </Tooltip> |
| 199 | </span> | 258 | </> |
| 200 | </button> | 259 | ) : null} |
| 201 | </span> | 260 | </div> |
| 202 | } | 261 | </ScrollArea> |
| 203 | /> | ||
| 204 | <TooltipPopup side={PICKER_TOOLTIP_SIDE} align="center" className={PICKER_TOOLTIP_CLASS}> | ||
| 205 | Github Copilot — Coming soon | ||
| 206 | </TooltipPopup> | ||
| 207 | </Tooltip> | ||
| 208 | </div> | ||
| 209 | ); | 262 | ); |
| 210 | }); | 263 | }); |
apps/web/src/components/chat/ProviderInstanceIcon.tsx created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { type CSSProperties, memo } from "react"; | ||
| 2 | import { type ProviderDriverKind } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; | ||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | |||
| 7 | export function providerInstanceInitials(label: string): string { | ||
| 8 | const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean); | ||
| 9 | if (words.length === 0) return ""; | ||
| 10 | if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase(); | ||
| 11 | return words | ||
| 12 | .slice(0, 2) | ||
| 13 | .map((word) => word[0]?.toUpperCase() ?? "") | ||
| 14 | .join(""); | ||
| 15 | } | ||
| 16 | |||
| 17 | export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { | ||
| 18 | driverKind: ProviderDriverKind; | ||
| 19 | displayName: string; | ||
| 20 | accentColor?: string | undefined; | ||
| 21 | showBadge?: boolean; | ||
| 22 | className?: string; | ||
| 23 | iconClassName?: string; | ||
| 24 | badgeClassName?: string; | ||
| 25 | statusDotClassName?: string; | ||
| 26 | }) { | ||
| 27 | const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; | ||
| 28 | const accentStyle = props.accentColor | ||
| 29 | ? ({ "--provider-accent": props.accentColor } as CSSProperties) | ||
| 30 | : undefined; | ||
| 31 | |||
| 32 | return ( | ||
| 33 | <span | ||
| 34 | className={cn( | ||
| 35 | "relative isolate inline-flex shrink-0 items-center justify-center", | ||
| 36 | props.className, | ||
| 37 | )} | ||
| 38 | style={accentStyle} | ||
| 39 | data-provider-accent-color={props.accentColor} | ||
| 40 | > | ||
| 41 | {Icon ? ( | ||
| 42 | <Icon className={cn("size-5 shrink-0", props.iconClassName)} aria-hidden /> | ||
| 43 | ) : ( | ||
| 44 | <span className={cn("text-[10px] font-semibold leading-none", props.iconClassName)}> | ||
| 45 | {providerInstanceInitials(props.displayName)} | ||
| 46 | </span> | ||
| 47 | )} | ||
| 48 | {props.statusDotClassName ? ( | ||
| 49 | <span | ||
| 50 | className={cn( | ||
| 51 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | ||
| 52 | props.statusDotClassName, | ||
| 53 | )} | ||
| 54 | aria-hidden | ||
| 55 | /> | ||
| 56 | ) : null} | ||
| 57 | {props.showBadge ? ( | ||
| 58 | <span | ||
| 59 | className={cn( | ||
| 60 | "pointer-events-none absolute right-0 bottom-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full border border-background px-0.5 text-[8px] font-semibold leading-none shadow-sm", | ||
| 61 | props.accentColor | ||
| 62 | ? "bg-[var(--provider-accent)] text-white" | ||
| 63 | : "bg-muted text-muted-foreground", | ||
| 64 | props.badgeClassName, | ||
| 65 | )} | ||
| 66 | aria-hidden | ||
| 67 | > | ||
| 68 | {providerInstanceInitials(props.displayName)} | ||
| 69 | </span> | ||
| 70 | ) : null} | ||
| 71 | </span> | ||
| 72 | ); | ||
| 73 | }); | ||
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+195-57| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | 3 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 4 | import { page, userEvent } from "vitest/browser"; | 4 | import { page, userEvent } from "vitest/browser"; |
| ... | @@ -6,8 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ... | @@ -6,8 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 6 | import { render } from "vitest-browser-react"; | 6 | import { render } from "vitest-browser-react"; |
| 7 | 7 | ||
| 8 | import { ProviderModelPicker } from "./ProviderModelPicker"; | 8 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 9 | import { getCustomModelOptionsByProvider } from "../../modelSelection"; | 9 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 10 | import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 10 | import { |
| 11 | deriveProviderInstanceEntries, | ||
| 12 | sortProviderInstanceEntries, | ||
| 13 | } from "../../providerInstances"; | ||
| 14 | import type { ModelEsque } from "./providerIconUtils"; | ||
| 15 | import { | ||
| 16 | DEFAULT_CLIENT_SETTINGS, | ||
| 17 | DEFAULT_UNIFIED_SETTINGS, | ||
| 18 | type UnifiedSettings, | ||
| 19 | } from "@t3tools/contracts/settings"; | ||
| 11 | import { __resetLocalApiForTests } from "../../localApi"; | 20 | import { __resetLocalApiForTests } from "../../localApi"; |
| 12 | 21 | ||
| 13 | // Mock the environments/runtime module to provide a mock primary environment connection | 22 | // Mock the environments/runtime module to provide a mock primary environment connection |
| ... | @@ -93,7 +102,8 @@ function booleanDescriptor(id: string, label: string) { | ... | @@ -93,7 +102,8 @@ function booleanDescriptor(id: string, label: string) { |
| 93 | 102 | ||
| 94 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | 103 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 95 | { | 104 | { |
| 96 | provider: "codex", | 105 | driver: ProviderDriverKind.make("codex"), |
| 106 | instanceId: ProviderInstanceId.make("codex"), | ||
| 97 | displayName: "Codex", | 107 | displayName: "Codex", |
| 98 | enabled: true, | 108 | enabled: true, |
| 99 | installed: true, | 109 | installed: true, |
| ... | @@ -137,7 +147,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | ... | @@ -137,7 +147,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 137 | ], | 147 | ], |
| 138 | }, | 148 | }, |
| 139 | { | 149 | { |
| 140 | provider: "claudeAgent", | 150 | driver: ProviderDriverKind.make("claudeAgent"), |
| 151 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 141 | displayName: "Claude", | 152 | displayName: "Claude", |
| 142 | enabled: true, | 153 | enabled: true, |
| 143 | installed: true, | 154 | installed: true, |
| ... | @@ -199,9 +210,14 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | ... | @@ -199,9 +210,14 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 199 | }, | 210 | }, |
| 200 | ]; | 211 | ]; |
| 201 | 212 | ||
| 213 | const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); | ||
| 214 | const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent"); | ||
| 215 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); | ||
| 216 | |||
| 202 | function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { | 217 | function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { |
| 203 | return { | 218 | return { |
| 204 | provider: "codex", | 219 | driver: ProviderDriverKind.make("codex"), |
| 220 | instanceId: ProviderInstanceId.make("codex"), | ||
| 205 | displayName: "Codex", | 221 | displayName: "Codex", |
| 206 | enabled: true, | 222 | enabled: true, |
| 207 | installed: true, | 223 | installed: true, |
| ... | @@ -217,7 +233,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { | ... | @@ -217,7 +233,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { |
| 217 | 233 | ||
| 218 | function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { | 234 | function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { |
| 219 | return { | 235 | return { |
| 220 | provider: "opencode", | 236 | driver: ProviderDriverKind.make("opencode"), |
| 237 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 221 | enabled: true, | 238 | enabled: true, |
| 222 | installed: true, | 239 | installed: true, |
| 223 | version: "1.0.0", | 240 | version: "1.0.0", |
| ... | @@ -231,37 +248,47 @@ function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider | ... | @@ -231,37 +248,47 @@ function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider |
| 231 | } | 248 | } |
| 232 | 249 | ||
| 233 | async function mountPicker(props: { | 250 | async function mountPicker(props: { |
| 234 | provider: ProviderKind; | 251 | activeInstanceId?: ProviderInstanceId; |
| 235 | model: string; | 252 | model: string; |
| 236 | lockedProvider: ProviderKind | null; | 253 | lockedProvider: ProviderDriverKind | null; |
| 254 | lockedContinuationGroupKey?: string | null; | ||
| 237 | providers?: ReadonlyArray<ServerProvider>; | 255 | providers?: ReadonlyArray<ServerProvider>; |
| 256 | settings?: UnifiedSettings; | ||
| 238 | triggerVariant?: "ghost" | "outline"; | 257 | triggerVariant?: "ghost" | "outline"; |
| 239 | }) { | 258 | }) { |
| 240 | const host = document.createElement("div"); | 259 | const host = document.createElement("div"); |
| 241 | document.body.append(host); | 260 | document.body.append(host); |
| 242 | const onProviderModelChange = vi.fn(); | 261 | const onInstanceModelChange = vi.fn(); |
| 243 | const providers = props.providers ?? TEST_PROVIDERS; | 262 | const providers = props.providers ?? TEST_PROVIDERS; |
| 244 | const modelOptionsByProvider = getCustomModelOptionsByProvider( | 263 | const instanceEntries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); |
| 245 | DEFAULT_UNIFIED_SETTINGS, | 264 | const activeInstanceId = props.activeInstanceId ?? CODEX_INSTANCE_ID; |
| 265 | const modelOptionsByInstance = getCustomModelOptionsByInstance( | ||
| 266 | props.settings ?? DEFAULT_UNIFIED_SETTINGS, | ||
| 246 | providers, | 267 | providers, |
| 247 | props.provider, | 268 | activeInstanceId, |
| 248 | props.model, | 269 | props.model, |
| 249 | ); | 270 | ); |
| 250 | const screen = await render( | 271 | const screen = await render( |
| 251 | <ProviderModelPicker | 272 | <ProviderModelPicker |
| 252 | provider={props.provider} | 273 | activeInstanceId={activeInstanceId} |
| 253 | model={props.model} | 274 | model={props.model} |
| 254 | lockedProvider={props.lockedProvider} | 275 | lockedProvider={props.lockedProvider} |
| 255 | providers={providers} | 276 | lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} |
| 256 | modelOptionsByProvider={modelOptionsByProvider} | 277 | instanceEntries={instanceEntries} |
| 278 | modelOptionsByInstance={modelOptionsByInstance} | ||
| 257 | triggerVariant={props.triggerVariant} | 279 | triggerVariant={props.triggerVariant} |
| 258 | onProviderModelChange={onProviderModelChange} | 280 | onInstanceModelChange={onInstanceModelChange} |
| 259 | />, | 281 | />, |
| 260 | { container: host }, | 282 | { container: host }, |
| 261 | ); | 283 | ); |
| 262 | 284 | ||
| 263 | return { | 285 | return { |
| 264 | onProviderModelChange, | 286 | onInstanceModelChange, |
| 287 | // Back-compat alias used by callers that still assert on the old callback | ||
| 288 | // name. Delegates to the instance-aware mock so existing expectations work. | ||
| 289 | get onProviderModelChange() { | ||
| 290 | return onInstanceModelChange; | ||
| 291 | }, | ||
| 265 | cleanup: async () => { | 292 | cleanup: async () => { |
| 266 | await screen.unmount(); | 293 | await screen.unmount(); |
| 267 | host.remove(); | 294 | host.remove(); |
| ... | @@ -304,7 +331,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -304,7 +331,7 @@ describe("ProviderModelPicker", () => { |
| 304 | 331 | ||
| 305 | it("shows provider sidebar in unlocked mode", async () => { | 332 | it("shows provider sidebar in unlocked mode", async () => { |
| 306 | const mounted = await mountPicker({ | 333 | const mounted = await mountPicker({ |
| 307 | provider: "claudeAgent", | 334 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 308 | model: "claude-opus-4-6", | 335 | model: "claude-opus-4-6", |
| 309 | lockedProvider: null, | 336 | lockedProvider: null, |
| 310 | }); | 337 | }); |
| ... | @@ -325,7 +352,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -325,7 +352,7 @@ describe("ProviderModelPicker", () => { |
| 325 | 352 | ||
| 326 | it("shows favorites first in the provider sidebar", async () => { | 353 | it("shows favorites first in the provider sidebar", async () => { |
| 327 | const mounted = await mountPicker({ | 354 | const mounted = await mountPicker({ |
| 328 | provider: "claudeAgent", | 355 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 329 | model: "claude-opus-4-6", | 356 | model: "claude-opus-4-6", |
| 330 | lockedProvider: null, | 357 | lockedProvider: null, |
| 331 | }); | 358 | }); |
| ... | @@ -347,7 +374,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -347,7 +374,7 @@ describe("ProviderModelPicker", () => { |
| 347 | 374 | ||
| 348 | it("filters models by selected provider in sidebar", async () => { | 375 | it("filters models by selected provider in sidebar", async () => { |
| 349 | const mounted = await mountPicker({ | 376 | const mounted = await mountPicker({ |
| 350 | provider: "claudeAgent", | 377 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 351 | model: "claude-opus-4-6", | 378 | model: "claude-opus-4-6", |
| 352 | lockedProvider: null, | 379 | lockedProvider: null, |
| 353 | }); | 380 | }); |
| ... | @@ -379,9 +406,37 @@ describe("ProviderModelPicker", () => { | ... | @@ -379,9 +406,37 @@ describe("ProviderModelPicker", () => { |
| 379 | } | 406 | } |
| 380 | }); | 407 | }); |
| 381 | 408 | ||
| 409 | it("uses client model visibility and ordering preferences", async () => { | ||
| 410 | const mounted = await mountPicker({ | ||
| 411 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 412 | model: "claude-opus-4-6", | ||
| 413 | lockedProvider: null, | ||
| 414 | settings: { | ||
| 415 | ...DEFAULT_UNIFIED_SETTINGS, | ||
| 416 | providerModelPreferences: { | ||
| 417 | [CLAUDE_INSTANCE_ID]: { | ||
| 418 | hiddenModels: ["claude-opus-4-6"], | ||
| 419 | modelOrder: ["claude-haiku-4-5", "claude-sonnet-4-6"], | ||
| 420 | }, | ||
| 421 | }, | ||
| 422 | }, | ||
| 423 | }); | ||
| 424 | |||
| 425 | try { | ||
| 426 | await page.getByRole("button").click(); | ||
| 427 | |||
| 428 | await vi.waitFor(() => { | ||
| 429 | expect(getVisibleModelNames()).toEqual(["Claude Haiku 4.5", "Claude Sonnet 4.6"]); | ||
| 430 | expect(getModelPickerListText()).not.toContain("Claude Opus 4.6"); | ||
| 431 | }); | ||
| 432 | } finally { | ||
| 433 | await mounted.cleanup(); | ||
| 434 | } | ||
| 435 | }); | ||
| 436 | |||
| 382 | it("focuses the search input after selecting a sidebar provider", async () => { | 437 | it("focuses the search input after selecting a sidebar provider", async () => { |
| 383 | const mounted = await mountPicker({ | 438 | const mounted = await mountPicker({ |
| 384 | provider: "claudeAgent", | 439 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 385 | model: "claude-opus-4-6", | 440 | model: "claude-opus-4-6", |
| 386 | lockedProvider: null, | 441 | lockedProvider: null, |
| 387 | }); | 442 | }); |
| ... | @@ -419,9 +474,9 @@ describe("ProviderModelPicker", () => { | ... | @@ -419,9 +474,9 @@ describe("ProviderModelPicker", () => { |
| 419 | ); | 474 | ); |
| 420 | 475 | ||
| 421 | const mounted = await mountPicker({ | 476 | const mounted = await mountPicker({ |
| 422 | provider: "claudeAgent", | 477 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 423 | model: "claude-opus-4-6", | 478 | model: "claude-opus-4-6", |
| 424 | lockedProvider: "claudeAgent", | 479 | lockedProvider: ProviderDriverKind.make("claudeAgent"), |
| 425 | }); | 480 | }); |
| 426 | 481 | ||
| 427 | try { | 482 | try { |
| ... | @@ -443,27 +498,115 @@ describe("ProviderModelPicker", () => { | ... | @@ -443,27 +498,115 @@ describe("ProviderModelPicker", () => { |
| 443 | } | 498 | } |
| 444 | }); | 499 | }); |
| 445 | 500 | ||
| 501 | it("keeps an instance sidebar in locked mode when that provider has multiple instances", async () => { | ||
| 502 | const defaultCodexModels: ServerProvider["models"] = [ | ||
| 503 | { | ||
| 504 | slug: "gpt-work", | ||
| 505 | name: "GPT Work", | ||
| 506 | isCustom: false, | ||
| 507 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 508 | }, | ||
| 509 | ]; | ||
| 510 | const personalCodexModels: ServerProvider["models"] = [ | ||
| 511 | { | ||
| 512 | slug: "gpt-personal", | ||
| 513 | name: "GPT Personal", | ||
| 514 | isCustom: false, | ||
| 515 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 516 | }, | ||
| 517 | ]; | ||
| 518 | const isolatedCodexModels: ServerProvider["models"] = [ | ||
| 519 | { | ||
| 520 | slug: "gpt-isolated", | ||
| 521 | name: "GPT Isolated", | ||
| 522 | isCustom: false, | ||
| 523 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 524 | }, | ||
| 525 | ]; | ||
| 526 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 527 | { | ||
| 528 | ...buildCodexProvider(defaultCodexModels), | ||
| 529 | instanceId: "codex" as ProviderInstanceId, | ||
| 530 | displayName: "Codex Work", | ||
| 531 | accentColor: "#2563eb", | ||
| 532 | continuation: { groupKey: "codex:home:/Users/julius/.codex" }, | ||
| 533 | }, | ||
| 534 | { | ||
| 535 | ...buildCodexProvider(personalCodexModels), | ||
| 536 | instanceId: "codex_personal" as ProviderInstanceId, | ||
| 537 | displayName: "Codex Personal", | ||
| 538 | accentColor: "#dc2626", | ||
| 539 | continuation: { groupKey: "codex:home:/Users/julius/.codex" }, | ||
| 540 | }, | ||
| 541 | { | ||
| 542 | ...buildCodexProvider(isolatedCodexModels), | ||
| 543 | instanceId: "codex_isolated" as ProviderInstanceId, | ||
| 544 | displayName: "Codex Isolated", | ||
| 545 | accentColor: "#16a34a", | ||
| 546 | continuation: { groupKey: "codex:home:/Users/julius/.codex_isolated" }, | ||
| 547 | }, | ||
| 548 | TEST_PROVIDERS[1]!, | ||
| 549 | ]; | ||
| 550 | const mounted = await mountPicker({ | ||
| 551 | activeInstanceId: "codex" as ProviderInstanceId, | ||
| 552 | model: "gpt-work", | ||
| 553 | lockedProvider: ProviderDriverKind.make("codex"), | ||
| 554 | lockedContinuationGroupKey: "codex:home:/Users/julius/.codex", | ||
| 555 | providers, | ||
| 556 | }); | ||
| 557 | |||
| 558 | try { | ||
| 559 | await page.getByRole("button").click(); | ||
| 560 | |||
| 561 | await vi.waitFor(() => { | ||
| 562 | expect(getSidebarProviderOrder()).toEqual(["codex", "codex_personal"]); | ||
| 563 | expect(getModelPickerListText()).not.toContain("Codex Isolated"); | ||
| 564 | expect( | ||
| 565 | document.querySelector<HTMLElement>('[data-model-picker-provider="codex_personal"]') | ||
| 566 | ?.dataset.providerAccentColor, | ||
| 567 | ).toBe("#dc2626"); | ||
| 568 | expect(getModelPickerListText()).toContain("Codex Work"); | ||
| 569 | expect(getVisibleModelNames()).toEqual(["GPT Work"]); | ||
| 570 | }); | ||
| 571 | |||
| 572 | await page.getByRole("button", { name: "Codex Personal" }).click(); | ||
| 573 | |||
| 574 | await vi.waitFor(() => { | ||
| 575 | expect(getModelPickerListText()).toContain("Codex Personal"); | ||
| 576 | expect(getVisibleModelNames()).toEqual(["GPT Personal"]); | ||
| 577 | }); | ||
| 578 | } finally { | ||
| 579 | await mounted.cleanup(); | ||
| 580 | } | ||
| 581 | }); | ||
| 582 | |||
| 446 | it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { | 583 | it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { |
| 447 | const host = document.createElement("div"); | 584 | const host = document.createElement("div"); |
| 448 | document.body.append(host); | 585 | document.body.append(host); |
| 449 | const onProviderModelChange = vi.fn(); | 586 | const onInstanceModelChange = vi.fn(); |
| 450 | const modelOptionsByProvider = { | 587 | const modelOptionsByInstance = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>([ |
| 451 | claudeAgent: [ | 588 | [ |
| 452 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | 589 | "claudeAgent" as ProviderInstanceId, |
| 453 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | 590 | [ |
| 591 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | ||
| 592 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | ||
| 593 | ], | ||
| 454 | ], | 594 | ], |
| 455 | codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }], | 595 | ["codex" as ProviderInstanceId, [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }]], |
| 456 | cursor: [], | 596 | ["cursor" as ProviderInstanceId, []], |
| 457 | opencode: [], | 597 | ["opencode" as ProviderInstanceId, []], |
| 458 | } as const; | 598 | ]); |
| 599 | const instanceEntries = sortProviderInstanceEntries( | ||
| 600 | deriveProviderInstanceEntries(TEST_PROVIDERS), | ||
| 601 | ); | ||
| 459 | const screen = await render( | 602 | const screen = await render( |
| 460 | <ProviderModelPicker | 603 | <ProviderModelPicker |
| 461 | provider="claudeAgent" | 604 | activeInstanceId={"claudeAgent" as ProviderInstanceId} |
| 462 | model="gpt-5-codex" | 605 | model="gpt-5-codex" |
| 463 | lockedProvider={null} | 606 | lockedProvider={null} |
| 464 | providers={TEST_PROVIDERS} | 607 | instanceEntries={instanceEntries} |
| 465 | modelOptionsByProvider={modelOptionsByProvider} | 608 | modelOptionsByInstance={modelOptionsByInstance} |
| 466 | onProviderModelChange={onProviderModelChange} | 609 | onInstanceModelChange={onInstanceModelChange} |
| 467 | />, | 610 | />, |
| 468 | { container: host }, | 611 | { container: host }, |
| 469 | ); | 612 | ); |
| ... | @@ -504,9 +647,9 @@ describe("ProviderModelPicker", () => { | ... | @@ -504,9 +647,9 @@ describe("ProviderModelPicker", () => { |
| 504 | ]), | 647 | ]), |
| 505 | ]; | 648 | ]; |
| 506 | const mounted = await mountPicker({ | 649 | const mounted = await mountPicker({ |
| 507 | provider: "opencode", | 650 | activeInstanceId: OPENCODE_INSTANCE_ID, |
| 508 | model: "github-copilot/claude-opus-4.5", | 651 | model: "github-copilot/claude-opus-4.5", |
| 509 | lockedProvider: "opencode", | 652 | lockedProvider: ProviderDriverKind.make("opencode"), |
| 510 | providers, | 653 | providers, |
| 511 | }); | 654 | }); |
| 512 | 655 | ||
| ... | @@ -531,7 +674,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -531,7 +674,7 @@ describe("ProviderModelPicker", () => { |
| 531 | 674 | ||
| 532 | it("searches models by name in flat list", async () => { | 675 | it("searches models by name in flat list", async () => { |
| 533 | const mounted = await mountPicker({ | 676 | const mounted = await mountPicker({ |
| 534 | provider: "claudeAgent", | 677 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 535 | model: "claude-opus-4-6", | 678 | model: "claude-opus-4-6", |
| 536 | lockedProvider: null, | 679 | lockedProvider: null, |
| 537 | }); | 680 | }); |
| ... | @@ -561,9 +704,9 @@ describe("ProviderModelPicker", () => { | ... | @@ -561,9 +704,9 @@ describe("ProviderModelPicker", () => { |
| 561 | 704 | ||
| 562 | it("supports arrow-key navigation in the model picker", async () => { | 705 | it("supports arrow-key navigation in the model picker", async () => { |
| 563 | const mounted = await mountPicker({ | 706 | const mounted = await mountPicker({ |
| 564 | provider: "claudeAgent", | 707 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 565 | model: "claude-opus-4-6", | 708 | model: "claude-opus-4-6", |
| 566 | lockedProvider: "claudeAgent", | 709 | lockedProvider: ProviderDriverKind.make("claudeAgent"), |
| 567 | }); | 710 | }); |
| 568 | 711 | ||
| 569 | try { | 712 | try { |
| ... | @@ -600,7 +743,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -600,7 +743,7 @@ describe("ProviderModelPicker", () => { |
| 600 | 743 | ||
| 601 | it("hides the provider sidebar while searching", async () => { | 744 | it("hides the provider sidebar while searching", async () => { |
| 602 | const mounted = await mountPicker({ | 745 | const mounted = await mountPicker({ |
| 603 | provider: "claudeAgent", | 746 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 604 | model: "claude-opus-4-6", | 747 | model: "claude-opus-4-6", |
| 605 | lockedProvider: null, | 748 | lockedProvider: null, |
| 606 | }); | 749 | }); |
| ... | @@ -624,7 +767,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -624,7 +767,7 @@ describe("ProviderModelPicker", () => { |
| 624 | 767 | ||
| 625 | it("closes the picker when escape is pressed in search", async () => { | 768 | it("closes the picker when escape is pressed in search", async () => { |
| 626 | const mounted = await mountPicker({ | 769 | const mounted = await mountPicker({ |
| 627 | provider: "claudeAgent", | 770 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 628 | model: "claude-opus-4-6", | 771 | model: "claude-opus-4-6", |
| 629 | lockedProvider: null, | 772 | lockedProvider: null, |
| 630 | }); | 773 | }); |
| ... | @@ -652,7 +795,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -652,7 +795,7 @@ describe("ProviderModelPicker", () => { |
| 652 | 795 | ||
| 653 | it("searches models by provider name", async () => { | 796 | it("searches models by provider name", async () => { |
| 654 | const mounted = await mountPicker({ | 797 | const mounted = await mountPicker({ |
| 655 | provider: "claudeAgent", | 798 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 656 | model: "claude-opus-4-6", | 799 | model: "claude-opus-4-6", |
| 657 | lockedProvider: null, | 800 | lockedProvider: null, |
| 658 | }); | 801 | }); |
| ... | @@ -718,7 +861,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -718,7 +861,7 @@ describe("ProviderModelPicker", () => { |
| 718 | ]), | 861 | ]), |
| 719 | ]; | 862 | ]; |
| 720 | const mounted = await mountPicker({ | 863 | const mounted = await mountPicker({ |
| 721 | provider: "opencode", | 864 | activeInstanceId: OPENCODE_INSTANCE_ID, |
| 722 | model: "github-copilot/claude-opus-4.7", | 865 | model: "github-copilot/claude-opus-4.7", |
| 723 | lockedProvider: null, | 866 | lockedProvider: null, |
| 724 | providers, | 867 | providers, |
| ... | @@ -780,7 +923,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -780,7 +923,7 @@ describe("ProviderModelPicker", () => { |
| 780 | }, | 923 | }, |
| 781 | ]; | 924 | ]; |
| 782 | const mounted = await mountPicker({ | 925 | const mounted = await mountPicker({ |
| 783 | provider: "opencode", | 926 | activeInstanceId: OPENCODE_INSTANCE_ID, |
| 784 | model: "github-copilot/claude-opus-4.7", | 927 | model: "github-copilot/claude-opus-4.7", |
| 785 | lockedProvider: null, | 928 | lockedProvider: null, |
| 786 | providers, | 929 | providers, |
| ... | @@ -805,7 +948,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -805,7 +948,7 @@ describe("ProviderModelPicker", () => { |
| 805 | localStorage.removeItem("t3code:client-settings:v1"); | 948 | localStorage.removeItem("t3code:client-settings:v1"); |
| 806 | 949 | ||
| 807 | const mounted = await mountPicker({ | 950 | const mounted = await mountPicker({ |
| 808 | provider: "claudeAgent", | 951 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 809 | model: "claude-opus-4-6", | 952 | model: "claude-opus-4-6", |
| 810 | lockedProvider: null, | 953 | lockedProvider: null, |
| 811 | }); | 954 | }); |
| ... | @@ -850,7 +993,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -850,7 +993,7 @@ describe("ProviderModelPicker", () => { |
| 850 | localStorage.removeItem("t3code:client-settings:v1"); | 993 | localStorage.removeItem("t3code:client-settings:v1"); |
| 851 | 994 | ||
| 852 | const mounted = await mountPicker({ | 995 | const mounted = await mountPicker({ |
| 853 | provider: "claudeAgent", | 996 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 854 | model: "claude-opus-4-6", | 997 | model: "claude-opus-4-6", |
| 855 | lockedProvider: null, | 998 | lockedProvider: null, |
| 856 | }); | 999 | }); |
| ... | @@ -890,7 +1033,6 @@ describe("ProviderModelPicker", () => { | ... | @@ -890,7 +1033,6 @@ describe("ProviderModelPicker", () => { |
| 890 | ); | 1033 | ); |
| 891 | 1034 | ||
| 892 | const mounted = await mountPicker({ | 1035 | const mounted = await mountPicker({ |
| 893 | provider: "codex", | ||
| 894 | model: "gpt-5-codex", | 1036 | model: "gpt-5-codex", |
| 895 | lockedProvider: null, | 1037 | lockedProvider: null, |
| 896 | }); | 1038 | }); |
| ... | @@ -910,9 +1052,9 @@ describe("ProviderModelPicker", () => { | ... | @@ -910,9 +1052,9 @@ describe("ProviderModelPicker", () => { |
| 910 | 1052 | ||
| 911 | it("dispatches callback with correct provider and model when selected", async () => { | 1053 | it("dispatches callback with correct provider and model when selected", async () => { |
| 912 | const mounted = await mountPicker({ | 1054 | const mounted = await mountPicker({ |
| 913 | provider: "claudeAgent", | 1055 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| 914 | model: "claude-opus-4-6", | 1056 | model: "claude-opus-4-6", |
| 915 | lockedProvider: "claudeAgent", | 1057 | lockedProvider: ProviderDriverKind.make("claudeAgent"), |
| 916 | }); | 1058 | }); |
| 917 | 1059 | ||
| 918 | try { | 1060 | try { |
| ... | @@ -995,7 +1137,6 @@ describe("ProviderModelPicker", () => { | ... | @@ -995,7 +1137,6 @@ describe("ProviderModelPicker", () => { |
| 995 | ]; | 1137 | ]; |
| 996 | 1138 | ||
| 997 | const hidden = await mountPicker({ | 1139 | const hidden = await mountPicker({ |
| 998 | provider: "codex", | ||
| 999 | model: "gpt-5.3-codex", | 1140 | model: "gpt-5.3-codex", |
| 1000 | lockedProvider: null, | 1141 | lockedProvider: null, |
| 1001 | providers: providersWithoutSpark, | 1142 | providers: providersWithoutSpark, |
| ... | @@ -1014,7 +1155,6 @@ describe("ProviderModelPicker", () => { | ... | @@ -1014,7 +1155,6 @@ describe("ProviderModelPicker", () => { |
| 1014 | } | 1155 | } |
| 1015 | 1156 | ||
| 1016 | const visible = await mountPicker({ | 1157 | const visible = await mountPicker({ |
| 1017 | provider: "codex", | ||
| 1018 | model: "gpt-5.3-codex", | 1158 | model: "gpt-5.3-codex", |
| 1019 | lockedProvider: null, | 1159 | lockedProvider: null, |
| 1020 | providers: providersWithSpark, | 1160 | providers: providersWithSpark, |
| ... | @@ -1034,7 +1174,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -1034,7 +1174,7 @@ describe("ProviderModelPicker", () => { |
| 1034 | it("shows disabled providers grayed out in sidebar", async () => { | 1174 | it("shows disabled providers grayed out in sidebar", async () => { |
| 1035 | const disabledProviders = TEST_PROVIDERS.slice(); | 1175 | const disabledProviders = TEST_PROVIDERS.slice(); |
| 1036 | const claudeIndex = disabledProviders.findIndex( | 1176 | const claudeIndex = disabledProviders.findIndex( |
| 1037 | (provider) => provider.provider === "claudeAgent", | 1177 | (provider) => provider.instanceId === ProviderInstanceId.make("claudeAgent"), |
| 1038 | ); | 1178 | ); |
| 1039 | if (claudeIndex >= 0) { | 1179 | if (claudeIndex >= 0) { |
| 1040 | const claudeProvider = disabledProviders[claudeIndex]!; | 1180 | const claudeProvider = disabledProviders[claudeIndex]!; |
| ... | @@ -1046,7 +1186,6 @@ describe("ProviderModelPicker", () => { | ... | @@ -1046,7 +1186,6 @@ describe("ProviderModelPicker", () => { |
| 1046 | } | 1186 | } |
| 1047 | 1187 | ||
| 1048 | const mounted = await mountPicker({ | 1188 | const mounted = await mountPicker({ |
| 1049 | provider: "codex", | ||
| 1050 | model: "gpt-5-codex", | 1189 | model: "gpt-5-codex", |
| 1051 | lockedProvider: null, | 1190 | lockedProvider: null, |
| 1052 | providers: disabledProviders, | 1191 | providers: disabledProviders, |
| ... | @@ -1068,7 +1207,6 @@ describe("ProviderModelPicker", () => { | ... | @@ -1068,7 +1207,6 @@ describe("ProviderModelPicker", () => { |
| 1068 | 1207 | ||
| 1069 | it("accepts outline trigger styling", async () => { | 1208 | it("accepts outline trigger styling", async () => { |
| 1070 | const mounted = await mountPicker({ | 1209 | const mounted = await mountPicker({ |
| 1071 | provider: "codex", | ||
| 1072 | model: "gpt-5-codex", | 1210 | model: "gpt-5-codex", |
| 1073 | lockedProvider: null, | 1211 | lockedProvider: null, |
| 1074 | triggerVariant: "outline", | 1212 | triggerVariant: "outline", |
apps/web/src/components/chat/ProviderModelPicker.tsx+56-27| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderKind, | 2 | type ProviderInstanceId, |
| 3 | type ProviderDriverKind, | ||
| 3 | type ResolvedKeybindingsConfig, | 4 | type ResolvedKeybindingsConfig, |
| 4 | type ServerProvider, | ||
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { memo, useEffect, useState } from "react"; | 6 | import { memo, useEffect, useMemo, useState } from "react"; |
| 7 | import type { VariantProps } from "class-variance-authority"; | 7 | import type { VariantProps } from "class-variance-authority"; |
| 8 | import { ChevronDownIcon } from "lucide-react"; | 8 | import { ChevronDownIcon } from "lucide-react"; |
| 9 | import { Button, buttonVariants } from "../ui/button"; | 9 | import { Button, buttonVariants } from "../ui/button"; |
| ... | @@ -11,21 +11,28 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ... | @@ -11,21 +11,28 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 12 | import { cn } from "~/lib/utils"; | 12 | import { cn } from "~/lib/utils"; |
| 13 | import { ModelPickerContent } from "./ModelPickerContent"; | 13 | import { ModelPickerContent } from "./ModelPickerContent"; |
| 14 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; | ||
| 14 | import { | 15 | import { |
| 15 | ModelEsque, | 16 | ModelEsque, |
| 16 | PROVIDER_ICON_BY_PROVIDER, | ||
| 17 | getTriggerDisplayModelLabel, | 17 | getTriggerDisplayModelLabel, |
| 18 | getTriggerDisplayModelName, | 18 | getTriggerDisplayModelName, |
| 19 | } from "./providerIconUtils"; | 19 | } from "./providerIconUtils"; |
| 20 | import { setModelPickerOpen } from "../../modelPickerOpenState"; | 20 | import { setModelPickerOpen } from "../../modelPickerOpenState"; |
| 21 | import type { ProviderInstanceEntry } from "../../providerInstances"; | ||
| 21 | 22 | ||
| 22 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | 23 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 23 | provider: ProviderKind; | 24 | /** |
| 25 | * The instance currently selected in the composer. Drives the trigger | ||
| 26 | * icon, label and the default-highlighted combobox row. | ||
| 27 | */ | ||
| 28 | activeInstanceId: ProviderInstanceId; | ||
| 24 | model: string; | 29 | model: string; |
| 25 | lockedProvider: ProviderKind | null; | 30 | lockedProvider: ProviderDriverKind | null; |
| 26 | providers?: ReadonlyArray<ServerProvider>; | 31 | lockedContinuationGroupKey?: string | null; |
| 32 | /** Instance entries rendered in the sidebar + used to resolve display name. */ | ||
| 33 | instanceEntries: ReadonlyArray<ProviderInstanceEntry>; | ||
| 27 | keybindings?: ResolvedKeybindingsConfig; | 34 | keybindings?: ResolvedKeybindingsConfig; |
| 28 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<ModelEsque>>; | 35 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>; |
| 29 | activeProviderIconClassName?: string; | 36 | activeProviderIconClassName?: string; |
| 30 | compact?: boolean; | 37 | compact?: boolean; |
| 31 | disabled?: boolean; | 38 | disabled?: boolean; |
| ... | @@ -34,22 +41,36 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -34,22 +41,36 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 34 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; | 41 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 35 | triggerClassName?: string; | 42 | triggerClassName?: string; |
| 36 | onOpenChange?: (open: boolean) => void; | 43 | onOpenChange?: (open: boolean) => void; |
| 37 | onProviderModelChange: (provider: ProviderKind, model: string) => void; | 44 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 38 | }) { | 45 | }) { |
| 39 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); | 46 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| 40 | const activeProvider = props.lockedProvider ?? props.provider; | ||
| 41 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; | 47 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; |
| 42 | const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; | 48 | |
| 43 | // If the current slug belongs to a different provider (for example after a provider | 49 | // Resolve the active instance entry by exact routing key. The composer |
| 44 | // switch or disable), prefer the active provider's first option so the trigger icon | 50 | // resolves fallbacks before rendering this component; if the selected |
| 45 | // and label stay in sync instead of showing a stale foreign slug. | 51 | // instance disappears, do not infer a replacement from its driver kind. |
| 52 | const activeEntry = useMemo(() => { | ||
| 53 | return ( | ||
| 54 | props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null | ||
| 55 | ); | ||
| 56 | }, [props.activeInstanceId, props.instanceEntries]); | ||
| 57 | |||
| 58 | const activeInstanceId = props.activeInstanceId; | ||
| 59 | const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; | ||
| 60 | // If the current slug belongs to a different instance (for example after | ||
| 61 | // a provider switch or disable), prefer the active instance's first | ||
| 62 | // option so the trigger icon and label stay in sync instead of showing | ||
| 63 | // a stale foreign slug. | ||
| 46 | const selectedModel = | 64 | const selectedModel = |
| 47 | selectedProviderOptions.find((option) => option.slug === props.model) ?? | 65 | selectedInstanceOptions.find((option) => option.slug === props.model) ?? |
| 48 | selectedProviderOptions[0]; | 66 | selectedInstanceOptions[0]; |
| 49 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; | ||
| 50 | const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; | 67 | const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; |
| 51 | const triggerSubtitle = selectedModel?.subProvider; | 68 | const triggerSubtitle = selectedModel?.subProvider; |
| 52 | const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; | 69 | const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; |
| 70 | const duplicateDriverCount = props.instanceEntries.filter( | ||
| 71 | (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, | ||
| 72 | ).length; | ||
| 73 | const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; | ||
| 53 | 74 | ||
| 54 | const setIsMenuOpen = (open: boolean) => { | 75 | const setIsMenuOpen = (open: boolean) => { |
| 55 | props.onOpenChange?.(open); | 76 | props.onOpenChange?.(open); |
| ... | @@ -65,9 +86,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -65,9 +86,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 65 | }; | 86 | }; |
| 66 | }, [isMenuOpen]); | 87 | }, [isMenuOpen]); |
| 67 | 88 | ||
| 68 | const handleProviderModelChange = (provider: ProviderKind, model: string) => { | 89 | const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { |
| 69 | if (props.disabled) return; | 90 | if (props.disabled) return; |
| 70 | props.onProviderModelChange(provider, model); | 91 | props.onInstanceModelChange(instanceId, model); |
| 71 | setIsMenuOpen(false); | 92 | setIsMenuOpen(false); |
| 72 | }; | 93 | }; |
| 73 | 94 | ||
| ... | @@ -103,10 +124,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -103,10 +124,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 103 | props.compact ? "max-w-36 sm:pl-1" : undefined, | 124 | props.compact ? "max-w-36 sm:pl-1" : undefined, |
| 104 | )} | 125 | )} |
| 105 | > | 126 | > |
| 106 | <ProviderIcon | 127 | {activeEntry ? ( |
| 107 | aria-hidden="true" | 128 | <ProviderInstanceIcon |
| 108 | className={cn("size-4 shrink-0", props.activeProviderIconClassName)} | 129 | driverKind={activeEntry.driverKind} |
| 109 | /> | 130 | displayName={activeEntry.displayName} |
| 131 | accentColor={activeEntry.accentColor} | ||
| 132 | showBadge={showInstanceBadge} | ||
| 133 | className={showInstanceBadge ? "size-5" : "size-4"} | ||
| 134 | iconClassName={cn("size-4", props.activeProviderIconClassName)} | ||
| 135 | badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" | ||
| 136 | /> | ||
| 137 | ) : null} | ||
| 110 | <Tooltip> | 138 | <Tooltip> |
| 111 | <TooltipTrigger | 139 | <TooltipTrigger |
| 112 | render={ | 140 | render={ |
| ... | @@ -142,15 +170,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -142,15 +170,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 142 | className="border-0 bg-transparent p-0 shadow-none before:hidden [--viewport-inline-padding:0] *:data-[slot=popover-viewport]:p-0" | 170 | className="border-0 bg-transparent p-0 shadow-none before:hidden [--viewport-inline-padding:0] *:data-[slot=popover-viewport]:p-0" |
| 143 | > | 171 | > |
| 144 | <ModelPickerContent | 172 | <ModelPickerContent |
| 145 | provider={props.provider} | 173 | activeInstanceId={activeInstanceId} |
| 146 | model={props.model} | 174 | model={props.model} |
| 147 | lockedProvider={props.lockedProvider} | 175 | lockedProvider={props.lockedProvider} |
| 148 | {...(props.providers && { providers: props.providers })} | 176 | lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} |
| 177 | instanceEntries={props.instanceEntries} | ||
| 149 | {...(props.keybindings ? { keybindings: props.keybindings } : {})} | 178 | {...(props.keybindings ? { keybindings: props.keybindings } : {})} |
| 150 | modelOptionsByProvider={props.modelOptionsByProvider} | 179 | modelOptionsByInstance={props.modelOptionsByInstance} |
| 151 | terminalOpen={props.terminalOpen ?? false} | 180 | terminalOpen={props.terminalOpen ?? false} |
| 152 | onRequestClose={() => setIsMenuOpen(false)} | 181 | onRequestClose={() => setIsMenuOpen(false)} |
| 153 | onProviderModelChange={handleProviderModelChange} | 182 | onInstanceModelChange={handleInstanceModelChange} |
| 154 | /> | 183 | /> |
| 155 | </PopoverPopup> | 184 | </PopoverPopup> |
| 156 | </Popover> | 185 | </Popover> |
apps/web/src/components/chat/ProviderStatusBanner.tsx+2-2| ... | @@ -2,7 +2,7 @@ import { type ServerProvider } from "@t3tools/contracts"; | ... | @@ -2,7 +2,7 @@ import { type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 4 | import { CircleAlertIcon } from "lucide-react"; | 4 | import { CircleAlertIcon } from "lucide-react"; |
| 5 | import { formatProviderKindLabel } from "../../providerModels"; | 5 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 6 | 6 | ||
| 7 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ | 7 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 8 | status, | 8 | status, |
| ... | @@ -13,7 +13,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ | ... | @@ -13,7 +13,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 13 | return null; | 13 | return null; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | const providerLabel = status.displayName?.trim() || formatProviderKindLabel(status.provider); | 16 | const providerLabel = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); |
| 17 | const defaultMessage = | 17 | const defaultMessage = |
| 18 | status.status === "error" | 18 | status.status === "error" |
| 19 | ? `${providerLabel} provider is unavailable.` | 19 | ? `${providerLabel} provider is unavailable.` |
apps/web/src/components/chat/TraitsPicker.tsx+5-5| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderKind, | 2 | type ProviderDriverKind, |
| 3 | type ProviderOptionDescriptor, | 3 | type ProviderOptionDescriptor, |
| 4 | type ProviderOptionSelection, | 4 | type ProviderOptionSelection, |
| 5 | type ScopedThreadRef, | 5 | type ScopedThreadRef, |
| ... | @@ -76,7 +76,7 @@ function getDescriptorStringValue( | ... | @@ -76,7 +76,7 @@ function getDescriptorStringValue( |
| 76 | } | 76 | } |
| 77 | 77 | ||
| 78 | function getSelectedTraits( | 78 | function getSelectedTraits( |
| 79 | provider: ProviderKind, | 79 | provider: ProviderDriverKind, |
| 80 | models: ReadonlyArray<ServerProviderModel>, | 80 | models: ReadonlyArray<ServerProviderModel>, |
| 81 | model: string | null | undefined, | 81 | model: string | null | undefined, |
| 82 | prompt: string, | 82 | prompt: string, |
| ... | @@ -150,7 +150,7 @@ function getSelectedTraits( | ... | @@ -150,7 +150,7 @@ function getSelectedTraits( |
| 150 | } | 150 | } |
| 151 | 151 | ||
| 152 | function getTraitsSectionVisibility(input: { | 152 | function getTraitsSectionVisibility(input: { |
| 153 | provider: ProviderKind; | 153 | provider: ProviderDriverKind; |
| 154 | models: ReadonlyArray<ServerProviderModel>; | 154 | models: ReadonlyArray<ServerProviderModel>; |
| 155 | model: string | null | undefined; | 155 | model: string | null | undefined; |
| 156 | prompt: string; | 156 | prompt: string; |
| ... | @@ -184,7 +184,7 @@ function getTraitsSectionVisibility(input: { | ... | @@ -184,7 +184,7 @@ function getTraitsSectionVisibility(input: { |
| 184 | } | 184 | } |
| 185 | 185 | ||
| 186 | export function shouldRenderTraitsControls(input: { | 186 | export function shouldRenderTraitsControls(input: { |
| 187 | provider: ProviderKind; | 187 | provider: ProviderDriverKind; |
| 188 | models: ReadonlyArray<ServerProviderModel>; | 188 | models: ReadonlyArray<ServerProviderModel>; |
| 189 | model: string | null | undefined; | 189 | model: string | null | undefined; |
| 190 | prompt: string; | 190 | prompt: string; |
| ... | @@ -195,7 +195,7 @@ export function shouldRenderTraitsControls(input: { | ... | @@ -195,7 +195,7 @@ export function shouldRenderTraitsControls(input: { |
| 195 | } | 195 | } |
| 196 | 196 | ||
| 197 | export interface TraitsMenuContentProps { | 197 | export interface TraitsMenuContentProps { |
| 198 | provider: ProviderKind; | 198 | provider: ProviderDriverKind; |
| 199 | models: ReadonlyArray<ServerProviderModel>; | 199 | models: ReadonlyArray<ServerProviderModel>; |
| 200 | model: string | null | undefined; | 200 | model: string | null | undefined; |
| 201 | prompt: string; | 201 | prompt: string; |
apps/web/src/components/chat/composerProviderState.test.tsx+6-6| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { | 2 | import { |
| 3 | ProviderKind, | 3 | ProviderDriverKind, |
| 4 | ProviderOptionDescriptor, | 4 | type ProviderOptionDescriptor, |
| 5 | ProviderOptionSelection, | 5 | type ProviderOptionSelection, |
| 6 | ServerProviderModel, | 6 | type ServerProviderModel, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { | 8 | import { |
| 9 | getComposerProviderState, | 9 | getComposerProviderState, |
| ... | @@ -15,7 +15,7 @@ import { | ... | @@ -15,7 +15,7 @@ import { |
| 15 | // optionDescriptors, so these tests use a single synthetic provider/model and | 15 | // optionDescriptors, so these tests use a single synthetic provider/model and |
| 16 | // vary only the descriptor shape per scenario. | 16 | // vary only the descriptor shape per scenario. |
| 17 | 17 | ||
| 18 | const PROVIDER: ProviderKind = "codex"; | 18 | const PROVIDER: ProviderDriverKind = ProviderDriverKind.make("codex"); |
| 19 | const MODEL = "test-model"; | 19 | const MODEL = "test-model"; |
| 20 | 20 | ||
| 21 | function selectDescriptor( | 21 | function selectDescriptor( |
apps/web/src/components/chat/composerProviderState.tsx+4-4| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderKind, | 2 | type ProviderDriverKind, |
| 3 | type ProviderOptionSelection, | 3 | type ProviderOptionSelection, |
| 4 | type ScopedThreadRef, | 4 | type ScopedThreadRef, |
| 5 | type ServerProviderModel, | 5 | type ServerProviderModel, |
| ... | @@ -17,7 +17,7 @@ import { getProviderModelCapabilities } from "../../providerModels"; | ... | @@ -17,7 +17,7 @@ import { getProviderModelCapabilities } from "../../providerModels"; |
| 17 | import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; | 17 | import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 18 | 18 | ||
| 19 | export type ComposerProviderStateInput = { | 19 | export type ComposerProviderStateInput = { |
| 20 | provider: ProviderKind; | 20 | provider: ProviderDriverKind; |
| 21 | model: string; | 21 | model: string; |
| 22 | models: ReadonlyArray<ServerProviderModel>; | 22 | models: ReadonlyArray<ServerProviderModel>; |
| 23 | prompt: string; | 23 | prompt: string; |
| ... | @@ -25,7 +25,7 @@ export type ComposerProviderStateInput = { | ... | @@ -25,7 +25,7 @@ export type ComposerProviderStateInput = { |
| 25 | }; | 25 | }; |
| 26 | 26 | ||
| 27 | export type ComposerProviderState = { | 27 | export type ComposerProviderState = { |
| 28 | provider: ProviderKind; | 28 | provider: ProviderDriverKind; |
| 29 | promptEffort: string | null; | 29 | promptEffort: string | null; |
| 30 | modelOptionsForDispatch: ReadonlyArray<ProviderOptionSelection> | undefined; | 30 | modelOptionsForDispatch: ReadonlyArray<ProviderOptionSelection> | undefined; |
| 31 | composerFrameClassName?: string; | 31 | composerFrameClassName?: string; |
| ... | @@ -34,7 +34,7 @@ export type ComposerProviderState = { | ... | @@ -34,7 +34,7 @@ export type ComposerProviderState = { |
| 34 | }; | 34 | }; |
| 35 | 35 | ||
| 36 | type TraitsRenderInput = { | 36 | type TraitsRenderInput = { |
| 37 | provider: ProviderKind; | 37 | provider: ProviderDriverKind; |
| 38 | threadRef?: ScopedThreadRef; | 38 | threadRef?: ScopedThreadRef; |
| 39 | draftId?: DraftId; | 39 | draftId?: DraftId; |
| 40 | model: string; | 40 | model: string; |
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+7-4| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import type { ComposerCommandItem } from "./ComposerCommandMenu"; | 4 | import type { ComposerCommandItem } from "./ComposerCommandMenu"; |
| 4 | import { searchSlashCommandItems } from "./composerSlashCommandSearch"; | 5 | import { searchSlashCommandItems } from "./composerSlashCommandSearch"; |
| 5 | 6 | ||
| 6 | describe("searchSlashCommandItems", () => { | 7 | describe("searchSlashCommandItems", () => { |
| 8 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); | ||
| 9 | |||
| 7 | it("moves exact provider command matches ahead of broader description matches", () => { | 10 | it("moves exact provider command matches ahead of broader description matches", () => { |
| 8 | const items = [ | 11 | const items = [ |
| 9 | { | 12 | { |
| ... | @@ -16,7 +19,7 @@ describe("searchSlashCommandItems", () => { | ... | @@ -16,7 +19,7 @@ describe("searchSlashCommandItems", () => { |
| 16 | { | 19 | { |
| 17 | id: "provider-slash-command:claudeAgent:ui", | 20 | id: "provider-slash-command:claudeAgent:ui", |
| 18 | type: "provider-slash-command", | 21 | type: "provider-slash-command", |
| 19 | provider: "claudeAgent", | 22 | provider: claudeDriver, |
| 20 | command: { name: "ui" }, | 23 | command: { name: "ui" }, |
| 21 | label: "/ui", | 24 | label: "/ui", |
| 22 | description: "Explore, build, and refine UI.", | 25 | description: "Explore, build, and refine UI.", |
| ... | @@ -24,7 +27,7 @@ describe("searchSlashCommandItems", () => { | ... | @@ -24,7 +27,7 @@ describe("searchSlashCommandItems", () => { |
| 24 | { | 27 | { |
| 25 | id: "provider-slash-command:claudeAgent:frontend-design", | 28 | id: "provider-slash-command:claudeAgent:frontend-design", |
| 26 | type: "provider-slash-command", | 29 | type: "provider-slash-command", |
| 27 | provider: "claudeAgent", | 30 | provider: claudeDriver, |
| 28 | command: { name: "frontend-design" }, | 31 | command: { name: "frontend-design" }, |
| 29 | label: "/frontend-design", | 32 | label: "/frontend-design", |
| 30 | description: "Create distinctive, production-grade frontend interfaces", | 33 | description: "Create distinctive, production-grade frontend interfaces", |
| ... | @@ -44,7 +47,7 @@ describe("searchSlashCommandItems", () => { | ... | @@ -44,7 +47,7 @@ describe("searchSlashCommandItems", () => { |
| 44 | { | 47 | { |
| 45 | id: "provider-slash-command:claudeAgent:gh-fix-ci", | 48 | id: "provider-slash-command:claudeAgent:gh-fix-ci", |
| 46 | type: "provider-slash-command", | 49 | type: "provider-slash-command", |
| 47 | provider: "claudeAgent", | 50 | provider: claudeDriver, |
| 48 | command: { name: "gh-fix-ci" }, | 51 | command: { name: "gh-fix-ci" }, |
| 49 | label: "/gh-fix-ci", | 52 | label: "/gh-fix-ci", |
| 50 | description: "Fix failing GitHub Actions", | 53 | description: "Fix failing GitHub Actions", |
| ... | @@ -52,7 +55,7 @@ describe("searchSlashCommandItems", () => { | ... | @@ -52,7 +55,7 @@ describe("searchSlashCommandItems", () => { |
| 52 | { | 55 | { |
| 53 | id: "provider-slash-command:claudeAgent:github", | 56 | id: "provider-slash-command:claudeAgent:github", |
| 54 | type: "provider-slash-command", | 57 | type: "provider-slash-command", |
| 55 | provider: "claudeAgent", | 58 | provider: claudeDriver, |
| 56 | command: { name: "github" }, | 59 | command: { name: "github" }, |
| 57 | label: "/github", | 60 | label: "/github", |
| 58 | description: "General GitHub help", | 61 | description: "General GitHub help", |
apps/web/src/components/chat/modelPickerModelHighlights.ts+2-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | 1 | import type { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | /** | 3 | /** |
| 4 | * Model slugs that show a gold "NEW" chip in the model picker list. | 4 | * Model slugs that show a gold "NEW" chip in the model picker list. |
| ... | @@ -8,6 +8,6 @@ const NEW_MODEL_KEYS = new Set<string>([ | ... | @@ -8,6 +8,6 @@ const NEW_MODEL_KEYS = new Set<string>([ |
| 8 | // Example: "claudeAgent:claude-opus-4-7", | 8 | // Example: "claudeAgent:claude-opus-4-7", |
| 9 | ]); | 9 | ]); |
| 10 | 10 | ||
| 11 | export function isModelPickerNewModel(provider: ProviderKind, slug: string): boolean { | 11 | export function isModelPickerNewModel(provider: ProviderDriverKind, slug: string): boolean { |
| 12 | return NEW_MODEL_KEYS.has(`${provider}:${slug}`); | 12 | return NEW_MODEL_KEYS.has(`${provider}:${slug}`); |
| 13 | } | 13 | } |
apps/web/src/components/chat/modelPickerSearch.test.ts+31-9| ... | @@ -6,7 +6,8 @@ describe("buildModelPickerSearchText", () => { | ... | @@ -6,7 +6,8 @@ describe("buildModelPickerSearchText", () => { |
| 6 | it("builds provider-agnostic search text from generic fields", () => { | 6 | it("builds provider-agnostic search text from generic fields", () => { |
| 7 | expect( | 7 | expect( |
| 8 | buildModelPickerSearchText({ | 8 | buildModelPickerSearchText({ |
| 9 | provider: "opencode", | 9 | driverKind: "opencode", |
| 10 | providerDisplayName: "opencode", | ||
| 10 | name: "Claude Opus 4.7", | 11 | name: "Claude Opus 4.7", |
| 11 | subProvider: "GitHub Copilot", | 12 | subProvider: "GitHub Copilot", |
| 12 | }), | 13 | }), |
| ... | @@ -19,7 +20,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -19,7 +20,8 @@ describe("scoreModelPickerSearch", () => { |
| 19 | expect( | 20 | expect( |
| 20 | scoreModelPickerSearch( | 21 | scoreModelPickerSearch( |
| 21 | { | 22 | { |
| 22 | provider: "opencode", | 23 | driverKind: "opencode", |
| 24 | providerDisplayName: "opencode", | ||
| 23 | name: "Claude Opus 4.7", | 25 | name: "Claude Opus 4.7", |
| 24 | subProvider: "GitHub Copilot", | 26 | subProvider: "GitHub Copilot", |
| 25 | }, | 27 | }, |
| ... | @@ -32,7 +34,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -32,7 +34,8 @@ describe("scoreModelPickerSearch", () => { |
| 32 | expect( | 34 | expect( |
| 33 | scoreModelPickerSearch( | 35 | scoreModelPickerSearch( |
| 34 | { | 36 | { |
| 35 | provider: "codex", | 37 | driverKind: "codex", |
| 38 | providerDisplayName: "codex", | ||
| 36 | name: "GPT-5 Codex", | 39 | name: "GPT-5 Codex", |
| 37 | }, | 40 | }, |
| 38 | "coplt op", | 41 | "coplt op", |
| ... | @@ -43,7 +46,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -43,7 +46,8 @@ describe("scoreModelPickerSearch", () => { |
| 43 | it("ranks exact token matches ahead of fuzzier matches", () => { | 46 | it("ranks exact token matches ahead of fuzzier matches", () => { |
| 44 | const exactScore = scoreModelPickerSearch( | 47 | const exactScore = scoreModelPickerSearch( |
| 45 | { | 48 | { |
| 46 | provider: "opencode", | 49 | driverKind: "opencode", |
| 50 | providerDisplayName: "opencode", | ||
| 47 | name: "Claude Opus 4.7", | 51 | name: "Claude Opus 4.7", |
| 48 | subProvider: "GitHub Copilot", | 52 | subProvider: "GitHub Copilot", |
| 49 | }, | 53 | }, |
| ... | @@ -51,7 +55,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -51,7 +55,8 @@ describe("scoreModelPickerSearch", () => { |
| 51 | ); | 55 | ); |
| 52 | const fuzzyScore = scoreModelPickerSearch( | 56 | const fuzzyScore = scoreModelPickerSearch( |
| 53 | { | 57 | { |
| 54 | provider: "opencode", | 58 | driverKind: "opencode", |
| 59 | providerDisplayName: "opencode", | ||
| 55 | name: "Claude Opus 4.7", | 60 | name: "Claude Opus 4.7", |
| 56 | subProvider: "GitHub Copilot", | 61 | subProvider: "GitHub Copilot", |
| 57 | }, | 62 | }, |
| ... | @@ -66,7 +71,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -66,7 +71,8 @@ describe("scoreModelPickerSearch", () => { |
| 66 | it("gives favorite models a strong enough ranking boost for partial queries", () => { | 71 | it("gives favorite models a strong enough ranking boost for partial queries", () => { |
| 67 | const favoriteScore = scoreModelPickerSearch( | 72 | const favoriteScore = scoreModelPickerSearch( |
| 68 | { | 73 | { |
| 69 | provider: "claudeAgent", | 74 | driverKind: "claudeAgent", |
| 75 | providerDisplayName: "Claude", | ||
| 70 | name: "Claude Opus 4.7", | 76 | name: "Claude Opus 4.7", |
| 71 | isFavorite: true, | 77 | isFavorite: true, |
| 72 | }, | 78 | }, |
| ... | @@ -74,7 +80,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -74,7 +80,8 @@ describe("scoreModelPickerSearch", () => { |
| 74 | ); | 80 | ); |
| 75 | const nonFavoriteScore = scoreModelPickerSearch( | 81 | const nonFavoriteScore = scoreModelPickerSearch( |
| 76 | { | 82 | { |
| 77 | provider: "cursor", | 83 | driverKind: "cursor", |
| 84 | providerDisplayName: "Cursor", | ||
| 78 | name: "Opus 4.5", | 85 | name: "Opus 4.5", |
| 79 | }, | 86 | }, |
| 80 | "opu", | 87 | "opu", |
| ... | @@ -88,7 +95,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -88,7 +95,8 @@ describe("scoreModelPickerSearch", () => { |
| 88 | it("does not let the favorite boost outrank clearly better textual matches", () => { | 95 | it("does not let the favorite boost outrank clearly better textual matches", () => { |
| 89 | const favoriteScore = scoreModelPickerSearch( | 96 | const favoriteScore = scoreModelPickerSearch( |
| 90 | { | 97 | { |
| 91 | provider: "claudeAgent", | 98 | driverKind: "claudeAgent", |
| 99 | providerDisplayName: "Claude", | ||
| 92 | name: "Claude Opus 4.7", | 100 | name: "Claude Opus 4.7", |
| 93 | isFavorite: true, | 101 | isFavorite: true, |
| 94 | }, | 102 | }, |
| ... | @@ -96,7 +104,8 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -96,7 +104,8 @@ describe("scoreModelPickerSearch", () => { |
| 96 | ); | 104 | ); |
| 97 | const nonFavoriteExactScore = scoreModelPickerSearch( | 105 | const nonFavoriteExactScore = scoreModelPickerSearch( |
| 98 | { | 106 | { |
| 99 | provider: "cursor", | 107 | driverKind: "cursor", |
| 108 | providerDisplayName: "Cursor", | ||
| 100 | name: "Opus 4.7", | 109 | name: "Opus 4.7", |
| 101 | }, | 110 | }, |
| 102 | "opus 4.7", | 111 | "opus 4.7", |
| ... | @@ -106,4 +115,17 @@ describe("scoreModelPickerSearch", () => { | ... | @@ -106,4 +115,17 @@ describe("scoreModelPickerSearch", () => { |
| 106 | expect(nonFavoriteExactScore).not.toBeNull(); | 115 | expect(nonFavoriteExactScore).not.toBeNull(); |
| 107 | expect(nonFavoriteExactScore!).toBeLessThan(favoriteScore!); | 116 | expect(nonFavoriteExactScore!).toBeLessThan(favoriteScore!); |
| 108 | }); | 117 | }); |
| 118 | |||
| 119 | it("matches a custom instance's display name against its models", () => { | ||
| 120 | expect( | ||
| 121 | scoreModelPickerSearch( | ||
| 122 | { | ||
| 123 | driverKind: "codex", | ||
| 124 | providerDisplayName: "Codex Personal", | ||
| 125 | name: "GPT-5 Codex", | ||
| 126 | }, | ||
| 127 | "personal", | ||
| 128 | ), | ||
| 129 | ).not.toBeNull(); | ||
| 130 | }); | ||
| 109 | }); | 131 | }); |
apps/web/src/components/chat/modelPickerSearch.ts+11-11| ... | @@ -1,8 +1,14 @@ | ... | @@ -1,8 +1,14 @@ |
| 1 | import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | ||
| 2 | import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; | 1 | import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; |
| 3 | 2 | ||
| 4 | type ModelPickerSearchableModel = { | 3 | type ModelPickerSearchableModel = { |
| 5 | provider: ProviderKind; | 4 | /** Driver kind — indexed so "codex" still matches a Codex Personal instance. */ |
| 5 | driverKind: string; | ||
| 6 | /** | ||
| 7 | * Instance display name (e.g. "Codex Personal"). Indexed as a search | ||
| 8 | * field so typing the custom instance's user-authored name matches its | ||
| 9 | * models directly instead of just the driver kind. | ||
| 10 | */ | ||
| 11 | providerDisplayName: string; | ||
| 6 | name: string; | 12 | name: string; |
| 7 | shortName?: string; | 13 | shortName?: string; |
| 8 | subProvider?: string; | 14 | subProvider?: string; |
| ... | @@ -16,8 +22,8 @@ function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] | ... | @@ -16,8 +22,8 @@ function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] |
| 16 | normalizeSearchQuery(model.name), | 22 | normalizeSearchQuery(model.name), |
| 17 | ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []), | 23 | ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []), |
| 18 | ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []), | 24 | ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []), |
| 19 | normalizeSearchQuery(model.provider), | 25 | normalizeSearchQuery(model.driverKind), |
| 20 | normalizeSearchQuery(PROVIDER_DISPLAY_NAMES[model.provider]), | 26 | normalizeSearchQuery(model.providerDisplayName), |
| 21 | buildModelPickerSearchText(model), | 27 | buildModelPickerSearchText(model), |
| 22 | ]; | 28 | ]; |
| 23 | } | 29 | } |
| ... | @@ -40,13 +46,7 @@ function scoreModelPickerSearchToken( | ... | @@ -40,13 +46,7 @@ function scoreModelPickerSearchToken( |
| 40 | 46 | ||
| 41 | export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string { | 47 | export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string { |
| 42 | return normalizeSearchQuery( | 48 | return normalizeSearchQuery( |
| 43 | [ | 49 | [model.name, model.shortName, model.subProvider, model.driverKind, model.providerDisplayName] |
| 44 | model.name, | ||
| 45 | model.shortName, | ||
| 46 | model.subProvider, | ||
| 47 | model.provider, | ||
| 48 | PROVIDER_DISPLAY_NAMES[model.provider], | ||
| 49 | ] | ||
| 50 | .filter((value): value is string => typeof value === "string" && value.length > 0) | 50 | .filter((value): value is string => typeof value === "string" && value.length > 0) |
| 51 | .join(" "), | 51 | .join(" "), |
| 52 | ); | 52 | ); |
apps/web/src/components/chat/providerIconUtils.ts+7-12| ... | @@ -1,16 +1,16 @@ | ... | @@ -1,16 +1,16 @@ |
| 1 | import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 2 | import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; | 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; |
| 4 | 4 | ||
| 5 | export const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = { | 5 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 6 | codex: OpenAI, | 6 | [ProviderDriverKind.make("codex")]: OpenAI, |
| 7 | claudeAgent: ClaudeAI, | 7 | [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, |
| 8 | opencode: OpenCodeIcon, | 8 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, |
| 9 | cursor: CursorIcon, | 9 | [ProviderDriverKind.make("cursor")]: CursorIcon, |
| 10 | }; | 10 | }; |
| 11 | 11 | ||
| 12 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | 12 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { |
| 13 | value: ProviderKind; | 13 | value: ProviderDriverKind; |
| 14 | label: string; | 14 | label: string; |
| 15 | available: true; | 15 | available: true; |
| 16 | pickerSidebarBadge?: "new" | "soon"; | 16 | pickerSidebarBadge?: "new" | "soon"; |
| ... | @@ -27,11 +27,6 @@ export type ModelEsque = { | ... | @@ -27,11 +27,6 @@ export type ModelEsque = { |
| 27 | subProvider?: string | undefined; | 27 | subProvider?: string | undefined; |
| 28 | }; | 28 | }; |
| 29 | 29 | ||
| 30 | export function getProviderLabel(provider: ProviderKind, model: ModelEsque): string { | ||
| 31 | const providerLabel = PROVIDER_DISPLAY_NAMES[provider]; | ||
| 32 | return model.subProvider ? `${providerLabel} · ${model.subProvider}` : providerLabel; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function getDisplayModelName( | 30 | export function getDisplayModelName( |
| 36 | model: ModelEsque, | 31 | model: ModelEsque, |
| 37 | options?: { preferShortName?: boolean }, | 32 | options?: { preferShortName?: boolean }, |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx created+493| ... | @@ -0,0 +1,493 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { CheckIcon } from "lucide-react"; | ||
| 4 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | ||
| 5 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 6 | import { | ||
| 7 | ProviderInstanceId, | ||
| 8 | ProviderDriverKind, | ||
| 9 | type ProviderInstanceConfig, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | ||
| 13 | import { cn } from "../../lib/utils"; | ||
| 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; | ||
| 15 | import { Button } from "../ui/button"; | ||
| 16 | import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons"; | ||
| 17 | import { | ||
| 18 | Dialog, | ||
| 19 | DialogDescription, | ||
| 20 | DialogFooter, | ||
| 21 | DialogHeader, | ||
| 22 | DialogPopup, | ||
| 23 | DialogTitle, | ||
| 24 | } from "../ui/dialog"; | ||
| 25 | import { Badge } from "../ui/badge"; | ||
| 26 | import { Input } from "../ui/input"; | ||
| 27 | import { RadioGroup } from "../ui/radio-group"; | ||
| 28 | import { toastManager } from "../ui/toast"; | ||
| 29 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta"; | ||
| 30 | |||
| 31 | const PROVIDER_ACCENT_SWATCHES = [ | ||
| 32 | "#2563eb", | ||
| 33 | "#16a34a", | ||
| 34 | "#ea580c", | ||
| 35 | "#dc2626", | ||
| 36 | "#7c3aed", | ||
| 37 | "#0891b2", | ||
| 38 | ] as const; | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Normalize a user-provided label into a slug suffix for the instance id. | ||
| 42 | * The full id is formed by prefixing the driver slug — e.g. label "Work" on | ||
| 43 | * driver "codex" becomes `codex_work`. Output is trimmed to 48 chars so the | ||
| 44 | * final composed id stays under the 64-char slug cap enforced by | ||
| 45 | * `ProviderInstanceId` in `@t3tools/contracts`. | ||
| 46 | */ | ||
| 47 | function slugifyLabel(value: string): string { | ||
| 48 | return value | ||
| 49 | .trim() | ||
| 50 | .toLowerCase() | ||
| 51 | .replace(/[^a-z0-9]+/g, "_") | ||
| 52 | .replace(/^_+|_+$/g, "") | ||
| 53 | .slice(0, 48); | ||
| 54 | } | ||
| 55 | |||
| 56 | function deriveInstanceId(driver: ProviderDriverKind, label: string): string { | ||
| 57 | const slug = slugifyLabel(label); | ||
| 58 | return slug ? `${driver}_${slug}` : ""; | ||
| 59 | } | ||
| 60 | |||
| 61 | const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | ||
| 62 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | ||
| 63 | const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; | ||
| 64 | const COMING_SOON_DRIVER_OPTIONS: readonly DriverOption[] = [ | ||
| 65 | { | ||
| 66 | value: ProviderDriverKind.make("githubCopilot"), | ||
| 67 | label: "Github Copilot", | ||
| 68 | icon: GithubCopilotIcon, | ||
| 69 | fields: [], | ||
| 70 | }, | ||
| 71 | { | ||
| 72 | value: ProviderDriverKind.make("gemini"), | ||
| 73 | label: "Gemini", | ||
| 74 | icon: Gemini, | ||
| 75 | fields: [], | ||
| 76 | }, | ||
| 77 | { | ||
| 78 | value: ProviderDriverKind.make("acpRegistry"), | ||
| 79 | label: "ACP Registry", | ||
| 80 | icon: ACPRegistryIcon, | ||
| 81 | fields: [], | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | value: ProviderDriverKind.make("piAgent"), | ||
| 85 | label: "Pi Agent", | ||
| 86 | icon: PiAgentIcon, | ||
| 87 | fields: [], | ||
| 88 | }, | ||
| 89 | ]; | ||
| 90 | |||
| 91 | /** | ||
| 92 | * Validate an instance id against the same slug rules the server applies in | ||
| 93 | * `ProviderInstanceId` (see `packages/contracts/src/providerInstance.ts`). | ||
| 94 | * Returns a user-facing error string, or `null` if valid. | ||
| 95 | */ | ||
| 96 | function validateInstanceId(id: string, existing: ReadonlySet<string>): string | null { | ||
| 97 | if (id.length === 0) return "Instance ID is required."; | ||
| 98 | if (id.length > 64) return "Instance ID must be 64 characters or fewer."; | ||
| 99 | if (!INSTANCE_ID_PATTERN.test(id)) { | ||
| 100 | return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; | ||
| 101 | } | ||
| 102 | if (existing.has(id)) return `An instance named '${id}' already exists.`; | ||
| 103 | return null; | ||
| 104 | } | ||
| 105 | |||
| 106 | interface AddProviderInstanceDialogProps { | ||
| 107 | open: boolean; | ||
| 108 | onOpenChange: (open: boolean) => void; | ||
| 109 | } | ||
| 110 | |||
| 111 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { | ||
| 112 | const settings = useSettings(); | ||
| 113 | const { updateSettings } = useUpdateSettings(); | ||
| 114 | |||
| 115 | const [wizardStep, setWizardStep] = useState(0); | ||
| 116 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); | ||
| 117 | const [label, setLabel] = useState(""); | ||
| 118 | const [accentColor, setAccentColor] = useState<string>(""); | ||
| 119 | const [instanceId, setInstanceId] = useState(""); | ||
| 120 | const [instanceIdDirty, setInstanceIdDirty] = useState(false); | ||
| 121 | // Driver-specific field values keyed by `${driver}:${fieldKey}` so toggling | ||
| 122 | // between drivers during the same dialog session doesn't lose in-progress | ||
| 123 | // input. Only the active driver's values are persisted on save. | ||
| 124 | const [fieldValues, setFieldValues] = useState<Record<string, string>>({}); | ||
| 125 | // Errors are suppressed until the user has tried to submit once. After that | ||
| 126 | // they update live so fixing the problem clears the message in place. | ||
| 127 | const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); | ||
| 128 | |||
| 129 | const existingIds = useMemo( | ||
| 130 | () => new Set(Object.keys(settings.providerInstances ?? {})), | ||
| 131 | [settings.providerInstances], | ||
| 132 | ); | ||
| 133 | |||
| 134 | // Reset the form every time the dialog opens so each creation starts | ||
| 135 | // from a clean slate. | ||
| 136 | useEffect(() => { | ||
| 137 | if (!open) return; | ||
| 138 | setDriver(DEFAULT_DRIVER_KIND); | ||
| 139 | setLabel(""); | ||
| 140 | setAccentColor(""); | ||
| 141 | setInstanceId(""); | ||
| 142 | setWizardStep(0); | ||
| 143 | setInstanceIdDirty(false); | ||
| 144 | setFieldValues({}); | ||
| 145 | setHasAttemptedSubmit(false); | ||
| 146 | }, [open]); | ||
| 147 | |||
| 148 | // Auto-derive the instance id from driver + label until the user types | ||
| 149 | // in the Instance ID field directly (after which they own its value). | ||
| 150 | useEffect(() => { | ||
| 151 | if (instanceIdDirty) return; | ||
| 152 | setInstanceId(deriveInstanceId(driver, label)); | ||
| 153 | }, [driver, label, instanceIdDirty]); | ||
| 154 | |||
| 155 | const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; | ||
| 156 | const instanceIdError = validateInstanceId(instanceId, existingIds); | ||
| 157 | const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; | ||
| 158 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; | ||
| 159 | const wizardSteps = ["Driver", "Identity", "Config"] as const; | ||
| 160 | const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; | ||
| 161 | |||
| 162 | const getFieldValue = useCallback( | ||
| 163 | (fieldKey: string) => fieldValues[`${driver}:${fieldKey}`] ?? "", | ||
| 164 | [driver, fieldValues], | ||
| 165 | ); | ||
| 166 | |||
| 167 | const setFieldValue = useCallback( | ||
| 168 | (fieldKey: string, value: string) => { | ||
| 169 | setFieldValues((existing) => ({ | ||
| 170 | ...existing, | ||
| 171 | [`${driver}:${fieldKey}`]: value, | ||
| 172 | })); | ||
| 173 | }, | ||
| 174 | [driver], | ||
| 175 | ); | ||
| 176 | |||
| 177 | const handleSave = useCallback(() => { | ||
| 178 | setHasAttemptedSubmit(true); | ||
| 179 | if (instanceIdError !== null) return; | ||
| 180 | |||
| 181 | // Build the config blob from non-empty driver-specific field values. | ||
| 182 | // Empty strings are dropped so defaults remain in effect on the server. | ||
| 183 | const config: Record<string, string> = {}; | ||
| 184 | for (const field of driverOption.fields) { | ||
| 185 | const value = (fieldValues[`${driver}:${field.key}`] ?? "").trim(); | ||
| 186 | if (value.length > 0) config[field.key] = value; | ||
| 187 | } | ||
| 188 | const hasConfig = Object.keys(config).length > 0; | ||
| 189 | const normalizedAccentColor = normalizeProviderAccentColor(accentColor); | ||
| 190 | |||
| 191 | const nextInstance: ProviderInstanceConfig = { | ||
| 192 | driver, | ||
| 193 | enabled: true, | ||
| 194 | ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), | ||
| 195 | ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), | ||
| 196 | ...(hasConfig ? { config } : {}), | ||
| 197 | }; | ||
| 198 | // `ProviderInstanceId.make` revalidates the slug; we've already checked | ||
| 199 | // it via `validateInstanceId`, but going through the brand constructor | ||
| 200 | // keeps the type boundary honest and guards against any future drift in | ||
| 201 | // the slug rules. | ||
| 202 | const brandedId = ProviderInstanceId.make(instanceId); | ||
| 203 | const nextMap = { | ||
| 204 | ...settings.providerInstances, | ||
| 205 | [brandedId]: nextInstance, | ||
| 206 | }; | ||
| 207 | try { | ||
| 208 | updateSettings({ providerInstances: nextMap }); | ||
| 209 | toastManager.add({ | ||
| 210 | type: "success", | ||
| 211 | title: "Provider instance added", | ||
| 212 | description: `${driverOption.label} instance '${instanceId}' was added.`, | ||
| 213 | }); | ||
| 214 | onOpenChange(false); | ||
| 215 | } catch (error) { | ||
| 216 | toastManager.add({ | ||
| 217 | type: "error", | ||
| 218 | title: "Could not add provider instance", | ||
| 219 | description: error instanceof Error ? error.message : "Update failed.", | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | }, [ | ||
| 223 | driver, | ||
| 224 | driverOption, | ||
| 225 | fieldValues, | ||
| 226 | instanceId, | ||
| 227 | instanceIdError, | ||
| 228 | label, | ||
| 229 | accentColor, | ||
| 230 | onOpenChange, | ||
| 231 | settings.providerInstances, | ||
| 232 | updateSettings, | ||
| 233 | ]); | ||
| 234 | |||
| 235 | return ( | ||
| 236 | <Dialog open={open} onOpenChange={onOpenChange}> | ||
| 237 | <DialogPopup className="max-w-xl overflow-hidden"> | ||
| 238 | <div className="flex min-h-0 flex-col overflow-hidden border-foreground/10 bg-background shadow-2xl"> | ||
| 239 | <DialogHeader className="border-b border-border/70 bg-background"> | ||
| 240 | <DialogTitle>Add provider instance</DialogTitle> | ||
| 241 | <DialogDescription> | ||
| 242 | Configure an additional provider instance — for example, a second Codex install | ||
| 243 | pointed at a different workspace. | ||
| 244 | </DialogDescription> | ||
| 245 | <div className="grid grid-cols-3 gap-2"> | ||
| 246 | {wizardSteps.map((step, index) => ( | ||
| 247 | <button | ||
| 248 | key={step} | ||
| 249 | type="button" | ||
| 250 | className={cn( | ||
| 251 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left transition", | ||
| 252 | index === wizardStep | ||
| 253 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" | ||
| 254 | : index < wizardStep | ||
| 255 | ? "border-border bg-background" | ||
| 256 | : "border-border bg-muted/40", | ||
| 257 | )} | ||
| 258 | onClick={() => setWizardStep(index)} | ||
| 259 | > | ||
| 260 | <span | ||
| 261 | className={cn( | ||
| 262 | "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border", | ||
| 263 | index < wizardStep | ||
| 264 | ? "border-primary bg-primary text-primary-foreground" | ||
| 265 | : index === wizardStep | ||
| 266 | ? "border-primary bg-background" | ||
| 267 | : "border-muted-foreground/35 bg-background", | ||
| 268 | )} | ||
| 269 | aria-hidden | ||
| 270 | > | ||
| 271 | {index < wizardStep ? <CheckIcon className="size-3" /> : null} | ||
| 272 | </span> | ||
| 273 | <span className="text-[10px] font-medium uppercase text-muted-foreground"> | ||
| 274 | Step {index + 1} | ||
| 275 | </span> | ||
| 276 | <span className="truncate text-xs font-semibold text-foreground"> | ||
| 277 | {step} | ||
| 278 | {index < wizardStep && wizardStepSummaries[index] | ||
| 279 | ? `: ${wizardStepSummaries[index]}` | ||
| 280 | : ""} | ||
| 281 | </span> | ||
| 282 | </button> | ||
| 283 | ))} | ||
| 284 | </div> | ||
| 285 | </DialogHeader> | ||
| 286 | |||
| 287 | <div | ||
| 288 | data-slot="dialog-panel" | ||
| 289 | className="space-y-4 border-b border-border/70 bg-muted/20 px-6 py-5" | ||
| 290 | > | ||
| 291 | <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}> | ||
| 292 | <span id="add-instance-driver-label" className="text-xs font-medium text-foreground"> | ||
| 293 | Driver | ||
| 294 | </span> | ||
| 295 | <RadioGroup | ||
| 296 | value={driver} | ||
| 297 | onValueChange={(value) => setDriver(ProviderDriverKind.make(value))} | ||
| 298 | aria-labelledby="add-instance-driver-label" | ||
| 299 | className="grid grid-cols-2 gap-2.5" | ||
| 300 | > | ||
| 301 | {DRIVER_OPTIONS.map((option) => { | ||
| 302 | const IconComponent = option.icon; | ||
| 303 | const isSelected = option.value === driver; | ||
| 304 | return ( | ||
| 305 | <RadioPrimitive.Root | ||
| 306 | key={option.value} | ||
| 307 | value={option.value} | ||
| 308 | className={cn( | ||
| 309 | "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 310 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 311 | isSelected | ||
| 312 | ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | ||
| 313 | : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | ||
| 314 | )} | ||
| 315 | > | ||
| 316 | <IconComponent className="size-5 shrink-0" aria-hidden /> | ||
| 317 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 318 | {option.label} | ||
| 319 | </span> | ||
| 320 | {option.badgeLabel ? ( | ||
| 321 | <Badge variant="warning" size="sm"> | ||
| 322 | {option.badgeLabel} | ||
| 323 | </Badge> | ||
| 324 | ) : null} | ||
| 325 | </RadioPrimitive.Root> | ||
| 326 | ); | ||
| 327 | })} | ||
| 328 | {COMING_SOON_DRIVER_OPTIONS.map((option) => { | ||
| 329 | const IconComponent = option.icon; | ||
| 330 | return ( | ||
| 331 | <RadioPrimitive.Root | ||
| 332 | key={option.value} | ||
| 333 | value={option.value} | ||
| 334 | disabled | ||
| 335 | className={cn( | ||
| 336 | "relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 outline-none", | ||
| 337 | )} | ||
| 338 | > | ||
| 339 | <IconComponent | ||
| 340 | className="size-5 shrink-0 text-muted-foreground" | ||
| 341 | aria-hidden | ||
| 342 | /> | ||
| 343 | <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"> | ||
| 344 | {option.label} | ||
| 345 | </span> | ||
| 346 | <Badge variant="warning" size="sm"> | ||
| 347 | Coming Soon | ||
| 348 | </Badge> | ||
| 349 | </RadioPrimitive.Root> | ||
| 350 | ); | ||
| 351 | })} | ||
| 352 | </RadioGroup> | ||
| 353 | </div> | ||
| 354 | |||
| 355 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 356 | <span className="text-xs font-medium text-foreground">Label</span> | ||
| 357 | <Input | ||
| 358 | className="bg-background" | ||
| 359 | placeholder="e.g. Work" | ||
| 360 | value={label} | ||
| 361 | onChange={(event) => setLabel(event.target.value)} | ||
| 362 | /> | ||
| 363 | <span className="text-[11px] text-muted-foreground"> | ||
| 364 | Shown in the provider list. Optional. | ||
| 365 | </span> | ||
| 366 | </label> | ||
| 367 | |||
| 368 | <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 369 | <span className="text-xs font-medium text-foreground">Instance ID</span> | ||
| 370 | <Input | ||
| 371 | className="bg-background" | ||
| 372 | placeholder={`${driver}_work`} | ||
| 373 | value={instanceId} | ||
| 374 | onChange={(event) => { | ||
| 375 | setInstanceIdDirty(true); | ||
| 376 | setInstanceId(event.target.value); | ||
| 377 | }} | ||
| 378 | aria-invalid={showInstanceIdError} | ||
| 379 | /> | ||
| 380 | {showInstanceIdError ? ( | ||
| 381 | <span className="text-[11px] text-destructive">{instanceIdError}</span> | ||
| 382 | ) : ( | ||
| 383 | <span className="text-[11px] text-muted-foreground"> | ||
| 384 | Routing key used by threads and sessions. Letters, digits, '-', or '_'. | ||
| 385 | </span> | ||
| 386 | )} | ||
| 387 | </label> | ||
| 388 | |||
| 389 | <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}> | ||
| 390 | <span className="text-xs font-medium text-foreground">Accent color</span> | ||
| 391 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | ||
| 392 | <input | ||
| 393 | type="color" | ||
| 394 | value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]} | ||
| 395 | onChange={(event) => setAccentColor(event.target.value)} | ||
| 396 | aria-label="Provider instance accent color" | ||
| 397 | className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" | ||
| 398 | /> | ||
| 399 | <div className="flex flex-wrap gap-1.5"> | ||
| 400 | {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | ||
| 401 | const selected = accentColor.toLowerCase() === swatch; | ||
| 402 | return ( | ||
| 403 | <button | ||
| 404 | key={swatch} | ||
| 405 | type="button" | ||
| 406 | className={cn( | ||
| 407 | "size-6 cursor-pointer rounded-full border transition", | ||
| 408 | selected | ||
| 409 | ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | ||
| 410 | : "border-black/10 hover:scale-105 dark:border-white/20", | ||
| 411 | )} | ||
| 412 | style={{ backgroundColor: swatch }} | ||
| 413 | onClick={() => setAccentColor(swatch)} | ||
| 414 | aria-label={`Use ${swatch} accent`} | ||
| 415 | /> | ||
| 416 | ); | ||
| 417 | })} | ||
| 418 | </div> | ||
| 419 | {accentColor ? ( | ||
| 420 | <Button | ||
| 421 | type="button" | ||
| 422 | size="sm" | ||
| 423 | variant="ghost" | ||
| 424 | className="h-7 px-2 text-xs text-muted-foreground" | ||
| 425 | onClick={() => setAccentColor("")} | ||
| 426 | > | ||
| 427 | Clear | ||
| 428 | </Button> | ||
| 429 | ) : null} | ||
| 430 | </div> | ||
| 431 | <span className="text-[11px] text-muted-foreground"> | ||
| 432 | Optional marker shown in the picker. | ||
| 433 | </span> | ||
| 434 | </div> | ||
| 435 | |||
| 436 | {driverOption.fields.length > 0 ? ( | ||
| 437 | <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> | ||
| 438 | {driverOption.fields.map((field) => ( | ||
| 439 | <label key={field.key} className="grid gap-1.5"> | ||
| 440 | <span className="text-xs font-medium text-foreground">{field.label}</span> | ||
| 441 | <Input | ||
| 442 | className="bg-background" | ||
| 443 | type={field.type === "password" ? "password" : undefined} | ||
| 444 | autoComplete={field.type === "password" ? "off" : undefined} | ||
| 445 | placeholder={field.placeholder} | ||
| 446 | value={getFieldValue(field.key)} | ||
| 447 | onChange={(event) => setFieldValue(field.key, event.target.value)} | ||
| 448 | spellCheck={false} | ||
| 449 | /> | ||
| 450 | {field.description ? ( | ||
| 451 | <span className="text-[11px] text-muted-foreground">{field.description}</span> | ||
| 452 | ) : null} | ||
| 453 | </label> | ||
| 454 | ))} | ||
| 455 | </div> | ||
| 456 | ) : wizardStep === 2 ? ( | ||
| 457 | <div className="grid gap-2"> | ||
| 458 | <p className="text-sm text-muted-foreground"> | ||
| 459 | This driver has no required configuration. You can add the instance now. | ||
| 460 | </p> | ||
| 461 | </div> | ||
| 462 | ) : null} | ||
| 463 | </div> | ||
| 464 | |||
| 465 | <DialogFooter className="border-t bg-background"> | ||
| 466 | <Button | ||
| 467 | variant="outline" | ||
| 468 | size="sm" | ||
| 469 | onClick={() => { | ||
| 470 | if (wizardStep === 0) { | ||
| 471 | onOpenChange(false); | ||
| 472 | return; | ||
| 473 | } | ||
| 474 | setWizardStep((step) => Math.max(0, step - 1)); | ||
| 475 | }} | ||
| 476 | > | ||
| 477 | {wizardStep === 0 ? "Cancel" : "Back"} | ||
| 478 | </Button> | ||
| 479 | {wizardStep < wizardSteps.length - 1 ? ( | ||
| 480 | <Button size="sm" onClick={() => setWizardStep((step) => Math.min(2, step + 1))}> | ||
| 481 | Next | ||
| 482 | </Button> | ||
| 483 | ) : ( | ||
| 484 | <Button size="sm" onClick={handleSave}> | ||
| 485 | Add instance | ||
| 486 | </Button> | ||
| 487 | )} | ||
| 488 | </DialogFooter> | ||
| 489 | </div> | ||
| 490 | </DialogPopup> | ||
| 491 | </Dialog> | ||
| 492 | ); | ||
| 493 | } | ||
apps/web/src/components/settings/ProviderInstanceCard.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; | ||
| 5 | |||
| 6 | describe("deriveProviderModelsForDisplay", () => { | ||
| 7 | it("uses current config custom models instead of stale live custom rows", () => { | ||
| 8 | const liveModels: ReadonlyArray<ServerProviderModel> = [ | ||
| 9 | { | ||
| 10 | slug: "server-model", | ||
| 11 | name: "Server Model", | ||
| 12 | isCustom: false, | ||
| 13 | capabilities: null, | ||
| 14 | }, | ||
| 15 | { | ||
| 16 | slug: "removed-custom", | ||
| 17 | name: "Removed Custom", | ||
| 18 | isCustom: true, | ||
| 19 | capabilities: null, | ||
| 20 | }, | ||
| 21 | { | ||
| 22 | slug: "kept-custom", | ||
| 23 | name: "Kept Custom", | ||
| 24 | isCustom: true, | ||
| 25 | capabilities: null, | ||
| 26 | }, | ||
| 27 | ]; | ||
| 28 | |||
| 29 | expect( | ||
| 30 | deriveProviderModelsForDisplay({ | ||
| 31 | liveModels, | ||
| 32 | customModels: ["kept-custom"], | ||
| 33 | }).map((model) => model.slug), | ||
| 34 | ).toEqual(["server-model", "kept-custom"]); | ||
| 35 | }); | ||
| 36 | }); | ||
apps/web/src/components/settings/ProviderInstanceCard.tsx created+814| ... | @@ -0,0 +1,814 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; | ||
| 4 | import { useEffect, useMemo, useState } from "react"; | ||
| 5 | import { | ||
| 6 | isProviderDriverKind, | ||
| 7 | type ProviderInstanceConfig, | ||
| 8 | type ProviderInstanceEnvironmentVariable, | ||
| 9 | type ProviderInstanceId, | ||
| 10 | type ProviderDriverKind, | ||
| 11 | type ServerProvider, | ||
| 12 | type ServerProviderModel, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | import { cn } from "../../lib/utils"; | ||
| 16 | import { normalizeProviderAccentColor } from "../../providerInstances"; | ||
| 17 | import { Badge } from "../ui/badge"; | ||
| 18 | import { Button } from "../ui/button"; | ||
| 19 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | ||
| 20 | import { DraftInput } from "../ui/draft-input"; | ||
| 21 | import { Switch } from "../ui/switch"; | ||
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 23 | import type { DriverOption } from "./providerDriverMeta"; | ||
| 24 | import { ProviderModelsSection } from "./ProviderModelsSection"; | ||
| 25 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ||
| 26 | import { | ||
| 27 | PROVIDER_STATUS_STYLES, | ||
| 28 | getProviderSummary, | ||
| 29 | getProviderVersionLabel, | ||
| 30 | type ProviderStatusKey, | ||
| 31 | } from "./providerStatus"; | ||
| 32 | |||
| 33 | const PROVIDER_ACCENT_SWATCHES = [ | ||
| 34 | "#2563eb", | ||
| 35 | "#16a34a", | ||
| 36 | "#ea580c", | ||
| 37 | "#dc2626", | ||
| 38 | "#7c3aed", | ||
| 39 | "#0891b2", | ||
| 40 | ] as const; | ||
| 41 | |||
| 42 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | ||
| 43 | |||
| 44 | const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; | ||
| 45 | |||
| 46 | let environmentVariableDraftId = 0; | ||
| 47 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; | ||
| 48 | |||
| 49 | type EnvironmentDraftRow = { | ||
| 50 | readonly id: string; | ||
| 51 | readonly name: string; | ||
| 52 | readonly value: string; | ||
| 53 | readonly sensitive: boolean; | ||
| 54 | readonly valueRedacted?: boolean; | ||
| 55 | }; | ||
| 56 | |||
| 57 | function makeEnvironmentDraftRow( | ||
| 58 | variable: ProviderInstanceEnvironmentVariable, | ||
| 59 | index: number, | ||
| 60 | ): EnvironmentDraftRow { | ||
| 61 | return { | ||
| 62 | id: `${index}:${variable.name}`, | ||
| 63 | name: variable.name, | ||
| 64 | value: variable.value, | ||
| 65 | sensitive: variable.sensitive, | ||
| 66 | ...(variable.valueRedacted !== undefined ? { valueRedacted: variable.valueRedacted } : {}), | ||
| 67 | }; | ||
| 68 | } | ||
| 69 | |||
| 70 | function redactedEmailPlaceholder(email: string): string { | ||
| 71 | let state = 0x811c9dc5; | ||
| 72 | for (let index = 0; index < email.length; index += 1) { | ||
| 73 | state ^= email.charCodeAt(index); | ||
| 74 | state = Math.imul(state, 0x01000193); | ||
| 75 | } | ||
| 76 | |||
| 77 | const nextChar = () => { | ||
| 78 | state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); | ||
| 79 | state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); | ||
| 80 | return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x"; | ||
| 81 | }; | ||
| 82 | |||
| 83 | return Array.from(email, (char) => { | ||
| 84 | if (char === "@" || char === "." || char === "-" || char === "_") return char; | ||
| 85 | return nextChar(); | ||
| 86 | }).join(""); | ||
| 87 | } | ||
| 88 | |||
| 89 | /** | ||
| 90 | * Read a string value at `key` from the opaque per-driver config blob. | ||
| 91 | * Returns an empty string when the key is missing or the stored value is | ||
| 92 | * not a string. The permissive shape reflects that `config` is | ||
| 93 | * `Schema.Unknown` at the contract boundary — forks may populate it with | ||
| 94 | * non-string values that the built-in UI should round-trip without | ||
| 95 | * throwing. | ||
| 96 | */ | ||
| 97 | function readConfigString(config: unknown, key: string): string { | ||
| 98 | if (config === null || typeof config !== "object") return ""; | ||
| 99 | const value = (config as Record<string, unknown>)[key]; | ||
| 100 | return typeof value === "string" ? value : ""; | ||
| 101 | } | ||
| 102 | |||
| 103 | /** | ||
| 104 | * Read a string[] at `key` from the opaque config blob, filtering out | ||
| 105 | * non-string entries. Used for `customModels`, which is always typed as | ||
| 106 | * `string[]` by the concrete driver schemas but arrives here as | ||
| 107 | * `Schema.Unknown`. | ||
| 108 | */ | ||
| 109 | function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> { | ||
| 110 | if (config === null || typeof config !== "object") return []; | ||
| 111 | const value = (config as Record<string, unknown>)[key]; | ||
| 112 | if (!Array.isArray(value)) return []; | ||
| 113 | return value.filter((entry): entry is string => typeof entry === "string"); | ||
| 114 | } | ||
| 115 | |||
| 116 | /** | ||
| 117 | * Produce the next config blob after setting `key` to `value`. Empty | ||
| 118 | * strings drop the key so server defaults stay in effect, mirroring the | ||
| 119 | * save-time normalization in `AddProviderInstanceDialog`. Returns | ||
| 120 | * `undefined` when the resulting blob has no keys, which matches | ||
| 121 | * `ProviderInstanceConfig.config` being optional. | ||
| 122 | * | ||
| 123 | * Non-string values already stored in the blob are carried through | ||
| 124 | * verbatim so fork-owned fields survive edits made through this UI. | ||
| 125 | */ | ||
| 126 | function nextConfigBlobWithString( | ||
| 127 | config: unknown, | ||
| 128 | key: string, | ||
| 129 | value: string, | ||
| 130 | ): Record<string, unknown> | undefined { | ||
| 131 | const base: Record<string, unknown> = | ||
| 132 | config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {}; | ||
| 133 | const trimmed = value.trim(); | ||
| 134 | if (trimmed.length > 0) { | ||
| 135 | base[key] = value; | ||
| 136 | } else { | ||
| 137 | delete base[key]; | ||
| 138 | } | ||
| 139 | return Object.keys(base).length > 0 ? base : undefined; | ||
| 140 | } | ||
| 141 | |||
| 142 | /** | ||
| 143 | * Set `key` to an arbitrary value on the opaque config blob. Unlike | ||
| 144 | * `nextConfigBlobWithString`, does not drop empty-looking values — the | ||
| 145 | * caller is responsible for deciding whether an empty array / empty | ||
| 146 | * object should be stored explicitly (e.g. `customModels: []` is a | ||
| 147 | * meaningful "user cleared their custom list" state distinct from | ||
| 148 | * "driver default"). | ||
| 149 | */ | ||
| 150 | function nextConfigBlobWithValue( | ||
| 151 | config: unknown, | ||
| 152 | key: string, | ||
| 153 | value: unknown, | ||
| 154 | ): Record<string, unknown> { | ||
| 155 | const base: Record<string, unknown> = | ||
| 156 | config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {}; | ||
| 157 | base[key] = value; | ||
| 158 | return base; | ||
| 159 | } | ||
| 160 | |||
| 161 | export function deriveProviderModelsForDisplay(input: { | ||
| 162 | readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined; | ||
| 163 | readonly customModels: ReadonlyArray<string>; | ||
| 164 | }): ReadonlyArray<ServerProviderModel> { | ||
| 165 | const liveCustomModelsBySlug = new Map( | ||
| 166 | (input.liveModels ?? []) | ||
| 167 | .filter((model) => model.isCustom) | ||
| 168 | .map((model) => [model.slug, model] as const), | ||
| 169 | ); | ||
| 170 | const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; | ||
| 171 | const customModels = input.customModels.map( | ||
| 172 | (slug) => | ||
| 173 | liveCustomModelsBySlug.get(slug) ?? { | ||
| 174 | slug, | ||
| 175 | name: slug, | ||
| 176 | isCustom: true, | ||
| 177 | capabilities: null, | ||
| 178 | }, | ||
| 179 | ); | ||
| 180 | return [...serverModels, ...customModels]; | ||
| 181 | } | ||
| 182 | |||
| 183 | function ProviderAuthEmail(props: { | ||
| 184 | readonly email: string | undefined; | ||
| 185 | readonly prefix?: string; | ||
| 186 | readonly separator?: boolean; | ||
| 187 | }) { | ||
| 188 | const [revealed, setRevealed] = useState(false); | ||
| 189 | const trimmed = props.email?.trim(); | ||
| 190 | const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]); | ||
| 191 | if (!trimmed) return null; | ||
| 192 | |||
| 193 | return ( | ||
| 194 | <span className="inline-flex min-w-0 items-center gap-1.5"> | ||
| 195 | {props.separator ? <span aria-hidden>·</span> : null} | ||
| 196 | {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null} | ||
| 197 | <Tooltip> | ||
| 198 | <TooltipTrigger | ||
| 199 | render={ | ||
| 200 | <button | ||
| 201 | type="button" | ||
| 202 | className={cn( | ||
| 203 | "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground", | ||
| 204 | revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]", | ||
| 205 | )} | ||
| 206 | onClick={() => setRevealed((value) => !value)} | ||
| 207 | aria-label={revealed ? "Hide account email" : "Reveal account email"} | ||
| 208 | > | ||
| 209 | {revealed ? trimmed : redacted} | ||
| 210 | </button> | ||
| 211 | } | ||
| 212 | /> | ||
| 213 | <TooltipPopup side="top"> | ||
| 214 | {revealed ? "Click to hide email" : "Click to reveal email"} | ||
| 215 | </TooltipPopup> | ||
| 216 | </Tooltip> | ||
| 217 | </span> | ||
| 218 | ); | ||
| 219 | } | ||
| 220 | |||
| 221 | function ProviderAccentColorPicker(props: { | ||
| 222 | readonly displayName: string; | ||
| 223 | readonly value: string | undefined; | ||
| 224 | readonly onCommit: (value: string) => void; | ||
| 225 | }) { | ||
| 226 | const [draft, setDraft] = useState(props.value ?? ""); | ||
| 227 | const [isEditing, setIsEditing] = useState(false); | ||
| 228 | const draftColor = normalizeProviderAccentColor(draft); | ||
| 229 | |||
| 230 | useEffect(() => { | ||
| 231 | if (isEditing) return; | ||
| 232 | setDraft(props.value ?? ""); | ||
| 233 | }, [isEditing, props.value]); | ||
| 234 | |||
| 235 | const commitDraft = () => { | ||
| 236 | setIsEditing(false); | ||
| 237 | props.onCommit(draftColor ?? ""); | ||
| 238 | }; | ||
| 239 | |||
| 240 | const commitSwatch = (swatch: string) => { | ||
| 241 | setIsEditing(false); | ||
| 242 | setDraft(swatch); | ||
| 243 | props.onCommit(swatch); | ||
| 244 | }; | ||
| 245 | |||
| 246 | return ( | ||
| 247 | <div className="grid gap-2"> | ||
| 248 | <span className="text-xs font-medium text-foreground">Accent color</span> | ||
| 249 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | ||
| 250 | <input | ||
| 251 | type="color" | ||
| 252 | value={draftColor ?? PROVIDER_ACCENT_SWATCHES[0]} | ||
| 253 | onFocus={() => setIsEditing(true)} | ||
| 254 | onInput={(event) => { | ||
| 255 | setIsEditing(true); | ||
| 256 | setDraft(event.currentTarget.value); | ||
| 257 | }} | ||
| 258 | onChange={(event) => { | ||
| 259 | setIsEditing(true); | ||
| 260 | setDraft(event.currentTarget.value); | ||
| 261 | }} | ||
| 262 | onBlur={commitDraft} | ||
| 263 | aria-label={`Accent color for ${props.displayName}`} | ||
| 264 | className="h-8 w-10 cursor-pointer rounded border border-input bg-background p-0.5" | ||
| 265 | /> | ||
| 266 | <div className="flex flex-wrap gap-1.5"> | ||
| 267 | {PROVIDER_ACCENT_SWATCHES.map((swatch) => { | ||
| 268 | const selected = draftColor?.toLowerCase() === swatch; | ||
| 269 | return ( | ||
| 270 | <button | ||
| 271 | key={swatch} | ||
| 272 | type="button" | ||
| 273 | className={cn( | ||
| 274 | "size-6 cursor-pointer rounded-full border transition", | ||
| 275 | selected | ||
| 276 | ? "border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background" | ||
| 277 | : "border-black/10 hover:scale-105 dark:border-white/20", | ||
| 278 | )} | ||
| 279 | style={{ backgroundColor: swatch }} | ||
| 280 | onClick={() => commitSwatch(swatch)} | ||
| 281 | aria-label={`Use ${swatch} accent`} | ||
| 282 | /> | ||
| 283 | ); | ||
| 284 | })} | ||
| 285 | </div> | ||
| 286 | {draftColor ? ( | ||
| 287 | <Button | ||
| 288 | type="button" | ||
| 289 | size="sm" | ||
| 290 | variant="ghost" | ||
| 291 | className="h-7 px-2 text-xs text-muted-foreground" | ||
| 292 | onClick={() => { | ||
| 293 | setIsEditing(false); | ||
| 294 | setDraft(""); | ||
| 295 | props.onCommit(""); | ||
| 296 | }} | ||
| 297 | > | ||
| 298 | Clear | ||
| 299 | </Button> | ||
| 300 | ) : null} | ||
| 301 | </div> | ||
| 302 | <span className="text-xs text-muted-foreground"> | ||
| 303 | Used to distinguish this instance in picker rails and model lists. | ||
| 304 | </span> | ||
| 305 | </div> | ||
| 306 | ); | ||
| 307 | } | ||
| 308 | |||
| 309 | function ProviderEnvironmentSection(props: { | ||
| 310 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; | ||
| 311 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; | ||
| 312 | }) { | ||
| 313 | const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() => | ||
| 314 | props.environment.map(makeEnvironmentDraftRow), | ||
| 315 | ); | ||
| 316 | |||
| 317 | useEffect(() => { | ||
| 318 | setRows(props.environment.map(makeEnvironmentDraftRow)); | ||
| 319 | }, [props.environment]); | ||
| 320 | |||
| 321 | const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => { | ||
| 322 | const published: ProviderInstanceEnvironmentVariable[] = []; | ||
| 323 | for (const row of nextRows) { | ||
| 324 | const name = row.name.trim(); | ||
| 325 | if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) { | ||
| 326 | if ( | ||
| 327 | name.length > 0 || | ||
| 328 | row.value.length > 0 || | ||
| 329 | row.sensitive !== true || | ||
| 330 | row.valueRedacted !== undefined | ||
| 331 | ) { | ||
| 332 | return; | ||
| 333 | } | ||
| 334 | continue; | ||
| 335 | } | ||
| 336 | const { id: _id, ...rest } = row; | ||
| 337 | published.push({ ...rest, name }); | ||
| 338 | } | ||
| 339 | props.onChange(published); | ||
| 340 | }; | ||
| 341 | |||
| 342 | const updateVariable = (id: string, patch: Partial<Omit<EnvironmentDraftRow, "id">>) => { | ||
| 343 | const nextRows = rows.map((row) => | ||
| 344 | row.id === id | ||
| 345 | ? { | ||
| 346 | ...row, | ||
| 347 | ...patch, | ||
| 348 | ...(patch.value !== undefined ? { valueRedacted: false } : {}), | ||
| 349 | } | ||
| 350 | : row, | ||
| 351 | ); | ||
| 352 | setRows(nextRows); | ||
| 353 | publishRows(nextRows); | ||
| 354 | }; | ||
| 355 | |||
| 356 | const removeVariable = (id: string) => { | ||
| 357 | const nextRows = rows.filter((row) => row.id !== id); | ||
| 358 | setRows(nextRows); | ||
| 359 | publishRows(nextRows); | ||
| 360 | }; | ||
| 361 | |||
| 362 | return ( | ||
| 363 | <div className="grid gap-2"> | ||
| 364 | <div className="flex items-center justify-between gap-3"> | ||
| 365 | <span className="text-xs font-medium text-foreground">Environment variables</span> | ||
| 366 | <Button | ||
| 367 | type="button" | ||
| 368 | size="sm" | ||
| 369 | variant="outline" | ||
| 370 | className="h-7 gap-1.5 px-2 text-xs" | ||
| 371 | onClick={() => | ||
| 372 | setRows([ | ||
| 373 | ...rows, | ||
| 374 | { | ||
| 375 | id: nextEnvironmentVariableDraftId(), | ||
| 376 | name: "", | ||
| 377 | value: "", | ||
| 378 | sensitive: true, | ||
| 379 | }, | ||
| 380 | ]) | ||
| 381 | } | ||
| 382 | > | ||
| 383 | <PlusIcon className="size-3" /> | ||
| 384 | Add | ||
| 385 | </Button> | ||
| 386 | </div> | ||
| 387 | {rows.length === 0 ? ( | ||
| 388 | <p className="text-xs text-muted-foreground"> | ||
| 389 | Add variables to pass API keys, base URLs, or other per-instance CLI settings. | ||
| 390 | </p> | ||
| 391 | ) : ( | ||
| 392 | <div className="grid gap-2"> | ||
| 393 | {rows.map((variable, index) => ( | ||
| 394 | <div | ||
| 395 | key={variable.id} | ||
| 396 | className="grid gap-2 rounded-md border border-border/70 bg-muted/20 p-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_auto_auto] sm:items-center" | ||
| 397 | > | ||
| 398 | <DraftInput | ||
| 399 | value={variable.name} | ||
| 400 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} | ||
| 401 | placeholder="VARIABLE_NAME" | ||
| 402 | spellCheck={false} | ||
| 403 | aria-label={`Environment variable name ${index + 1}`} | ||
| 404 | /> | ||
| 405 | <DraftInput | ||
| 406 | value={variable.valueRedacted ? "" : variable.value} | ||
| 407 | onCommit={(value) => updateVariable(variable.id, { value })} | ||
| 408 | type={variable.sensitive ? "password" : undefined} | ||
| 409 | autoComplete="off" | ||
| 410 | placeholder={ | ||
| 411 | variable.valueRedacted ? "Stored secret - enter a new value to replace" : "Value" | ||
| 412 | } | ||
| 413 | spellCheck={false} | ||
| 414 | aria-label={`Environment variable value ${index + 1}`} | ||
| 415 | /> | ||
| 416 | <label className="inline-flex h-8 items-center gap-2 text-xs text-muted-foreground"> | ||
| 417 | <input | ||
| 418 | type="checkbox" | ||
| 419 | className="size-3.5" | ||
| 420 | checked={variable.sensitive} | ||
| 421 | onChange={(event) => { | ||
| 422 | const sensitive = event.currentTarget.checked; | ||
| 423 | updateVariable(variable.id, { | ||
| 424 | sensitive, | ||
| 425 | ...(sensitive && variable.valueRedacted === undefined | ||
| 426 | ? {} | ||
| 427 | : { valueRedacted: sensitive ? variable.valueRedacted : false }), | ||
| 428 | }); | ||
| 429 | }} | ||
| 430 | /> | ||
| 431 | Sensitive | ||
| 432 | </label> | ||
| 433 | <Button | ||
| 434 | type="button" | ||
| 435 | size="icon-sm" | ||
| 436 | variant="ghost" | ||
| 437 | className="size-8 justify-self-start text-muted-foreground hover:text-destructive sm:justify-self-end" | ||
| 438 | onClick={() => removeVariable(variable.id)} | ||
| 439 | aria-label={`Remove environment variable ${variable.name || index + 1}`} | ||
| 440 | > | ||
| 441 | <XIcon className="size-3.5" /> | ||
| 442 | </Button> | ||
| 443 | </div> | ||
| 444 | ))} | ||
| 445 | </div> | ||
| 446 | )} | ||
| 447 | <span className="text-xs text-muted-foreground"> | ||
| 448 | Sensitive values are stored separately and are not returned to the app after saving. | ||
| 449 | </span> | ||
| 450 | </div> | ||
| 451 | ); | ||
| 452 | } | ||
| 453 | |||
| 454 | interface ProviderInstanceCardProps { | ||
| 455 | readonly instanceId: ProviderInstanceId; | ||
| 456 | readonly instance: ProviderInstanceConfig; | ||
| 457 | readonly driverOption: DriverOption | undefined; | ||
| 458 | readonly liveProvider: ServerProvider | undefined; | ||
| 459 | readonly isExpanded: boolean; | ||
| 460 | readonly onExpandedChange: (open: boolean) => void; | ||
| 461 | readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; | ||
| 462 | /** | ||
| 463 | * Pass `undefined` to hide the delete button entirely. Built-in default | ||
| 464 | * instance slots use `undefined` — they can't be deleted without losing | ||
| 465 | * the slot, and their "reset to defaults" affordance lives on an outer | ||
| 466 | * reset button instead. Explicit `| undefined` in the type accommodates | ||
| 467 | * `exactOptionalPropertyTypes: true`, where an absent key and | ||
| 468 | * `{ onDelete: undefined }` are treated as distinct shapes. | ||
| 469 | */ | ||
| 470 | readonly onDelete?: (() => void) | undefined; | ||
| 471 | /** | ||
| 472 | * Optional outer reset button rendered next to the driver icon. Built-in | ||
| 473 | * default slots supply a reset-to-factory control here; custom instances | ||
| 474 | * omit it. | ||
| 475 | */ | ||
| 476 | readonly headerAction?: React.ReactNode | undefined; | ||
| 477 | readonly hiddenModels: ReadonlyArray<string>; | ||
| 478 | readonly favoriteModels: ReadonlyArray<string>; | ||
| 479 | readonly modelOrder: ReadonlyArray<string>; | ||
| 480 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; | ||
| 481 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; | ||
| 482 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; | ||
| 483 | } | ||
| 484 | |||
| 485 | /** | ||
| 486 | * A single configured provider-instance row in the Providers settings | ||
| 487 | * section. Used for every row — both the built-in default instance for a | ||
| 488 | * driver (rendered with `onDelete` omitted) and user-authored custom | ||
| 489 | * instances (`onDelete` supplied). The only UI difference between the two | ||
| 490 | * is whether the trash button is visible; every other field (display | ||
| 491 | * name, config fields, models) behaves identically. | ||
| 492 | * | ||
| 493 | * Behavior notes: | ||
| 494 | * - `liveProvider` is matched by the caller via `instanceId`; when no | ||
| 495 | * match is available (e.g. the server hasn't probed yet, or the | ||
| 496 | * driver is not shipped by the current build) the card still renders | ||
| 497 | * with a neutral "checking" summary. | ||
| 498 | * - Unknown drivers (`driverOption === undefined`) get a read-only | ||
| 499 | * notice instead of editable fields, so fork instances round-trip | ||
| 500 | * without accidentally destroying their config. | ||
| 501 | * - The enabled Switch writes to the envelope's `instance.enabled` | ||
| 502 | * field; the server's registry consults this at `entry.enabled ?? true` | ||
| 503 | * before materializing the instance, and the probe also checks its | ||
| 504 | * driver-specific `config.enabled`. We treat the envelope flag as the | ||
| 505 | * single source of truth from the UI — built-in cards used to write | ||
| 506 | * the inner flag, but on the promotion-to-instance path every edit | ||
| 507 | * flows through the envelope. | ||
| 508 | */ | ||
| 509 | export function ProviderInstanceCard({ | ||
| 510 | instanceId, | ||
| 511 | instance, | ||
| 512 | driverOption, | ||
| 513 | liveProvider, | ||
| 514 | isExpanded, | ||
| 515 | onExpandedChange, | ||
| 516 | onUpdate, | ||
| 517 | onDelete, | ||
| 518 | headerAction, | ||
| 519 | hiddenModels, | ||
| 520 | favoriteModels, | ||
| 521 | modelOrder, | ||
| 522 | onHiddenModelsChange, | ||
| 523 | onFavoriteModelsChange, | ||
| 524 | onModelOrderChange, | ||
| 525 | }: ProviderInstanceCardProps) { | ||
| 526 | const enabled = instance.enabled ?? true; | ||
| 527 | // The server-reported status wins when present; otherwise fall back to | ||
| 528 | // "disabled"/"warning" based on the local `enabled` flag so the dot | ||
| 529 | // reflects the persisted intent even before the first probe completes. | ||
| 530 | const statusKey: ProviderStatusKey = | ||
| 531 | (liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled"); | ||
| 532 | const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; | ||
| 533 | const rawSummary = getProviderSummary(liveProvider); | ||
| 534 | const authEmail = liveProvider?.auth.email; | ||
| 535 | const hasAuthenticatedEmail = | ||
| 536 | liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim()); | ||
| 537 | const authenticatedDetail = hasAuthenticatedEmail | ||
| 538 | ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) | ||
| 539 | : null; | ||
| 540 | const summary = rawSummary; | ||
| 541 | const versionLabel = getProviderVersionLabel(liveProvider?.version); | ||
| 542 | const FallbackIconComponent = driverOption?.icon; | ||
| 543 | const displayName = | ||
| 544 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); | ||
| 545 | const accentColor = normalizeProviderAccentColor(instance.accentColor); | ||
| 546 | |||
| 547 | // Narrow `instance.driver` for callers that key on the closed | ||
| 548 | // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom | ||
| 549 | // fork drivers pass through as `null` and those callers fall back to | ||
| 550 | // verbatim behaviour. | ||
| 551 | const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) | ||
| 552 | ? instance.driver | ||
| 553 | : null; | ||
| 554 | |||
| 555 | const customModels = readConfigStringArray(instance.config, "customModels"); | ||
| 556 | // Server-returned models may lag behind settings writes. Treat probe | ||
| 557 | // models as the source for built-ins only; custom rows come directly | ||
| 558 | // from the current instance config so add/remove reflects immediately. | ||
| 559 | const modelsForDisplay = deriveProviderModelsForDisplay({ | ||
| 560 | liveModels: liveProvider?.models, | ||
| 561 | customModels, | ||
| 562 | }); | ||
| 563 | |||
| 564 | const updateDisplayName = (value: string) => { | ||
| 565 | const trimmed = value.trim(); | ||
| 566 | const { displayName: _omit, ...rest } = instance; | ||
| 567 | onUpdate( | ||
| 568 | trimmed.length > 0 | ||
| 569 | ? ({ ...rest, displayName: trimmed } as ProviderInstanceConfig) | ||
| 570 | : (rest as ProviderInstanceConfig), | ||
| 571 | ); | ||
| 572 | }; | ||
| 573 | |||
| 574 | const updateEnabled = (value: boolean) => { | ||
| 575 | onUpdate({ ...instance, enabled: value }); | ||
| 576 | }; | ||
| 577 | |||
| 578 | const updateAccentColor = (value: string) => { | ||
| 579 | const normalized = normalizeProviderAccentColor(value); | ||
| 580 | const { accentColor: _omit, ...rest } = instance; | ||
| 581 | onUpdate( | ||
| 582 | normalized | ||
| 583 | ? ({ ...rest, accentColor: normalized } as ProviderInstanceConfig) | ||
| 584 | : (rest as ProviderInstanceConfig), | ||
| 585 | ); | ||
| 586 | }; | ||
| 587 | |||
| 588 | const updateConfigField = (key: string, value: string) => { | ||
| 589 | const nextConfig = nextConfigBlobWithString(instance.config, key, value); | ||
| 590 | const { config: _omit, ...rest } = instance; | ||
| 591 | onUpdate( | ||
| 592 | nextConfig !== undefined | ||
| 593 | ? ({ ...rest, config: nextConfig } as ProviderInstanceConfig) | ||
| 594 | : (rest as ProviderInstanceConfig), | ||
| 595 | ); | ||
| 596 | }; | ||
| 597 | |||
| 598 | const updateCustomModels = (next: ReadonlyArray<string>) => { | ||
| 599 | const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]); | ||
| 600 | const { config: _omit, ...rest } = instance; | ||
| 601 | onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); | ||
| 602 | }; | ||
| 603 | |||
| 604 | const updateEnvironment = (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => { | ||
| 605 | const cleaned = environment.filter((variable) => variable.name.trim().length > 0); | ||
| 606 | const { environment: _omit, ...rest } = instance; | ||
| 607 | onUpdate( | ||
| 608 | cleaned.length > 0 | ||
| 609 | ? ({ ...rest, environment: cleaned } as ProviderInstanceConfig) | ||
| 610 | : (rest as ProviderInstanceConfig), | ||
| 611 | ); | ||
| 612 | }; | ||
| 613 | |||
| 614 | return ( | ||
| 615 | <div className="border-t border-border first:border-t-0"> | ||
| 616 | <div className="px-4 py-4 sm:px-5"> | ||
| 617 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | ||
| 618 | <div className="min-w-0 flex-1 space-y-1"> | ||
| 619 | <div className="flex min-h-5 items-center gap-1.5"> | ||
| 620 | {driverKind ? ( | ||
| 621 | <ProviderInstanceIcon | ||
| 622 | driverKind={driverKind} | ||
| 623 | displayName={displayName} | ||
| 624 | accentColor={accentColor} | ||
| 625 | showBadge={Boolean(accentColor)} | ||
| 626 | statusDotClassName={statusStyle.dot} | ||
| 627 | className="size-5" | ||
| 628 | iconClassName="size-4 text-foreground/80" | ||
| 629 | badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" | ||
| 630 | /> | ||
| 631 | ) : FallbackIconComponent ? ( | ||
| 632 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> | ||
| 633 | <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden /> | ||
| 634 | <span | ||
| 635 | className={cn( | ||
| 636 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | ||
| 637 | statusStyle.dot, | ||
| 638 | )} | ||
| 639 | aria-hidden | ||
| 640 | /> | ||
| 641 | </span> | ||
| 642 | ) : ( | ||
| 643 | <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} /> | ||
| 644 | )} | ||
| 645 | <h3 className="truncate text-sm font-medium text-foreground">{displayName}</h3> | ||
| 646 | {String(instanceId) !== String(instance.driver) ? ( | ||
| 647 | // Hide the id chip on a default slot whose id === the | ||
| 648 | // driver slug — it's redundant with the driver icon + | ||
| 649 | // label. Custom instances (and any instance the user has | ||
| 650 | // since renamed) keep the chip so their slug stays | ||
| 651 | // visible for copy/paste + disambiguation. | ||
| 652 | <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground"> | ||
| 653 | {instanceId} | ||
| 654 | </code> | ||
| 655 | ) : null} | ||
| 656 | {driverOption?.badgeLabel ? ( | ||
| 657 | <Badge variant="warning" size="sm" className="shrink-0"> | ||
| 658 | {driverOption.badgeLabel} | ||
| 659 | </Badge> | ||
| 660 | ) : null} | ||
| 661 | {versionLabel ? ( | ||
| 662 | <code className="text-xs text-muted-foreground">{versionLabel}</code> | ||
| 663 | ) : null} | ||
| 664 | {headerAction ? ( | ||
| 665 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | ||
| 666 | {headerAction} | ||
| 667 | </span> | ||
| 668 | ) : null} | ||
| 669 | {onDelete ? ( | ||
| 670 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | ||
| 671 | <Tooltip> | ||
| 672 | <TooltipTrigger | ||
| 673 | render={ | ||
| 674 | <Button | ||
| 675 | size="icon-xs" | ||
| 676 | variant="ghost" | ||
| 677 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive" | ||
| 678 | onClick={onDelete} | ||
| 679 | aria-label={`Delete provider instance ${instanceId}`} | ||
| 680 | > | ||
| 681 | <Trash2Icon className="size-3" /> | ||
| 682 | </Button> | ||
| 683 | } | ||
| 684 | /> | ||
| 685 | <TooltipPopup side="top">Delete instance</TooltipPopup> | ||
| 686 | </Tooltip> | ||
| 687 | </span> | ||
| 688 | ) : null} | ||
| 689 | </div> | ||
| 690 | <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> | ||
| 691 | {hasAuthenticatedEmail ? ( | ||
| 692 | <> | ||
| 693 | <span>Authenticated as</span> | ||
| 694 | <ProviderAuthEmail email={authEmail} /> | ||
| 695 | {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null} | ||
| 696 | </> | ||
| 697 | ) : ( | ||
| 698 | <> | ||
| 699 | <span>{summary.headline}</span> | ||
| 700 | <ProviderAuthEmail email={authEmail} separator prefix="Email" /> | ||
| 701 | </> | ||
| 702 | )} | ||
| 703 | {summary.detail ? <span>- {summary.detail}</span> : null} | ||
| 704 | </p> | ||
| 705 | </div> | ||
| 706 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | ||
| 707 | <Button | ||
| 708 | size="sm" | ||
| 709 | variant="ghost" | ||
| 710 | className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" | ||
| 711 | onClick={() => onExpandedChange(!isExpanded)} | ||
| 712 | aria-label={`Toggle ${displayName} details`} | ||
| 713 | > | ||
| 714 | <ChevronDownIcon | ||
| 715 | className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")} | ||
| 716 | /> | ||
| 717 | </Button> | ||
| 718 | <Switch | ||
| 719 | checked={enabled} | ||
| 720 | onCheckedChange={(checked) => updateEnabled(Boolean(checked))} | ||
| 721 | aria-label={`Enable ${displayName}`} | ||
| 722 | /> | ||
| 723 | </div> | ||
| 724 | </div> | ||
| 725 | </div> | ||
| 726 | |||
| 727 | <Collapsible open={isExpanded} onOpenChange={onExpandedChange}> | ||
| 728 | <CollapsibleContent> | ||
| 729 | <div className="space-y-0"> | ||
| 730 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 731 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | ||
| 732 | <span className="text-xs font-medium text-foreground">Display name</span> | ||
| 733 | <DraftInput | ||
| 734 | id={`provider-instance-${instanceId}-display-name`} | ||
| 735 | className="mt-1.5" | ||
| 736 | value={instance.displayName ?? ""} | ||
| 737 | onCommit={updateDisplayName} | ||
| 738 | placeholder={driverOption?.label ?? "Instance label"} | ||
| 739 | spellCheck={false} | ||
| 740 | /> | ||
| 741 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 742 | Optional label shown in the provider list. | ||
| 743 | </span> | ||
| 744 | </label> | ||
| 745 | </div> | ||
| 746 | |||
| 747 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 748 | <ProviderAccentColorPicker | ||
| 749 | displayName={displayName} | ||
| 750 | value={accentColor} | ||
| 751 | onCommit={updateAccentColor} | ||
| 752 | /> | ||
| 753 | </div> | ||
| 754 | |||
| 755 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 756 | <ProviderEnvironmentSection | ||
| 757 | environment={instance.environment ?? []} | ||
| 758 | onChange={updateEnvironment} | ||
| 759 | /> | ||
| 760 | </div> | ||
| 761 | |||
| 762 | {driverOption?.fields.map((field) => ( | ||
| 763 | <div key={field.key} className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 764 | <label htmlFor={`provider-instance-${instanceId}-${field.key}`} className="block"> | ||
| 765 | <span className="text-xs font-medium text-foreground">{field.label}</span> | ||
| 766 | <DraftInput | ||
| 767 | id={`provider-instance-${instanceId}-${field.key}`} | ||
| 768 | className="mt-1.5" | ||
| 769 | type={field.type === "password" ? "password" : undefined} | ||
| 770 | autoComplete={field.type === "password" ? "off" : undefined} | ||
| 771 | value={readConfigString(instance.config, field.key)} | ||
| 772 | onCommit={(next) => updateConfigField(field.key, next)} | ||
| 773 | placeholder={field.placeholder} | ||
| 774 | spellCheck={false} | ||
| 775 | /> | ||
| 776 | {field.description ? ( | ||
| 777 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 778 | {field.description} | ||
| 779 | </span> | ||
| 780 | ) : null} | ||
| 781 | </label> | ||
| 782 | </div> | ||
| 783 | ))} | ||
| 784 | |||
| 785 | {driverOption !== undefined ? ( | ||
| 786 | <ProviderModelsSection | ||
| 787 | instanceId={instanceId} | ||
| 788 | driverKind={driverKind} | ||
| 789 | models={modelsForDisplay} | ||
| 790 | customModels={customModels} | ||
| 791 | hiddenModels={hiddenModels} | ||
| 792 | favoriteModels={favoriteModels} | ||
| 793 | modelOrder={modelOrder} | ||
| 794 | onChange={updateCustomModels} | ||
| 795 | onHiddenModelsChange={onHiddenModelsChange} | ||
| 796 | onFavoriteModelsChange={onFavoriteModelsChange} | ||
| 797 | onModelOrderChange={onModelOrderChange} | ||
| 798 | /> | ||
| 799 | ) : ( | ||
| 800 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 801 | <p className="text-xs text-muted-foreground"> | ||
| 802 | This instance uses a driver ( | ||
| 803 | <code className="text-foreground">{String(instance.driver)}</code>) that is not | ||
| 804 | shipped with the current build. Configuration values are preserved but cannot be | ||
| 805 | edited from this surface. | ||
| 806 | </p> | ||
| 807 | </div> | ||
| 808 | )} | ||
| 809 | </div> | ||
| 810 | </CollapsibleContent> | ||
| 811 | </Collapsible> | ||
| 812 | </div> | ||
| 813 | ); | ||
| 814 | } | ||
apps/web/src/components/settings/ProviderModelsSection.tsx created+411| ... | @@ -0,0 +1,411 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ArrowDownIcon, | ||
| 5 | ArrowUpIcon, | ||
| 6 | EyeIcon, | ||
| 7 | EyeOffIcon, | ||
| 8 | InfoIcon, | ||
| 9 | PlusIcon, | ||
| 10 | StarIcon, | ||
| 11 | XIcon, | ||
| 12 | } from "lucide-react"; | ||
| 13 | import { useMemo, useRef, useState } from "react"; | ||
| 14 | import { | ||
| 15 | ProviderDriverKind, | ||
| 16 | type ProviderInstanceId, | ||
| 17 | type ServerProviderModel, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import { normalizeModelSlug } from "@t3tools/shared/model"; | ||
| 20 | |||
| 21 | import { cn } from "../../lib/utils"; | ||
| 22 | import { sortModelsForProviderInstance } from "../../modelOrdering"; | ||
| 23 | import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; | ||
| 24 | import { Button } from "../ui/button"; | ||
| 25 | import { Input } from "../ui/input"; | ||
| 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 27 | |||
| 28 | /** | ||
| 29 | * Placeholder text for the "add a custom model" input, keyed by driver | ||
| 30 | * kind. Mirrors the prior hardcoded switch in `SettingsPanels.tsx` so the | ||
| 31 | * UX is unchanged — only the owning component has moved. | ||
| 32 | */ | ||
| 33 | const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, string>> = { | ||
| 34 | [ProviderDriverKind.make("codex")]: "gpt-6.7-codex-ultra-preview", | ||
| 35 | [ProviderDriverKind.make("claudeAgent")]: "claude-sonnet-5-0", | ||
| 36 | [ProviderDriverKind.make("cursor")]: "claude-sonnet-4-6", | ||
| 37 | [ProviderDriverKind.make("opencode")]: "openai/gpt-5", | ||
| 38 | }; | ||
| 39 | |||
| 40 | interface ProviderModelsSectionProps { | ||
| 41 | /** Identifier used to namespace input ids within the DOM. */ | ||
| 42 | readonly instanceId: ProviderInstanceId; | ||
| 43 | /** | ||
| 44 | * Driver kind for slug normalization + input placeholder. `null` when | ||
| 45 | * the section is rendered without enough provider metadata. | ||
| 46 | */ | ||
| 47 | readonly driverKind: ProviderDriverKind | null; | ||
| 48 | /** | ||
| 49 | * The live model list to display. Includes both built-in (probe-reported) | ||
| 50 | * and custom entries, distinguished by `isCustom`. | ||
| 51 | */ | ||
| 52 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 53 | /** | ||
| 54 | * The persisted custom-model slug list for this instance. Drives dedup, | ||
| 55 | * and is the array we hand back verbatim (with the new slug appended / | ||
| 56 | * removed) via `onChange`. | ||
| 57 | */ | ||
| 58 | readonly customModels: ReadonlyArray<string>; | ||
| 59 | /** Server-returned model slugs hidden from the model picker. */ | ||
| 60 | readonly hiddenModels: ReadonlyArray<string>; | ||
| 61 | /** Model slugs favorited for this provider instance. */ | ||
| 62 | readonly favoriteModels: ReadonlyArray<string>; | ||
| 63 | /** Explicit user-authored model ordering for this provider instance. */ | ||
| 64 | readonly modelOrder: ReadonlyArray<string>; | ||
| 65 | /** | ||
| 66 | * Commit the new custom-model list. Caller is responsible for routing the | ||
| 67 | * write to the correct storage (legacy `settings.providers[kind]` vs. | ||
| 68 | * `providerInstances[id].config`). | ||
| 69 | */ | ||
| 70 | readonly onChange: (next: ReadonlyArray<string>) => void; | ||
| 71 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; | ||
| 72 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; | ||
| 73 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; | ||
| 74 | } | ||
| 75 | |||
| 76 | /** | ||
| 77 | * Shared "Models" section rendered on both the built-in default and custom | ||
| 78 | * provider-instance cards. Owns its own input + error local state so two | ||
| 79 | * cards on screen don't fight over the input value. | ||
| 80 | * | ||
| 81 | * Validation mirrors the pre-consolidation logic in `SettingsPanels`: | ||
| 82 | * - empty / whitespace → "Enter a model slug." | ||
| 83 | * - duplicate of a non-custom (probe-reported) slug → "already built in" | ||
| 84 | * - exceeds `MAX_CUSTOM_MODEL_LENGTH` → length error | ||
| 85 | * - duplicate of an already-saved custom slug → already-saved error | ||
| 86 | */ | ||
| 87 | export function ProviderModelsSection({ | ||
| 88 | instanceId, | ||
| 89 | driverKind, | ||
| 90 | models, | ||
| 91 | customModels, | ||
| 92 | hiddenModels, | ||
| 93 | favoriteModels, | ||
| 94 | modelOrder, | ||
| 95 | onChange, | ||
| 96 | onHiddenModelsChange, | ||
| 97 | onFavoriteModelsChange, | ||
| 98 | onModelOrderChange, | ||
| 99 | }: ProviderModelsSectionProps) { | ||
| 100 | const [input, setInput] = useState(""); | ||
| 101 | const [error, setError] = useState<string | null>(null); | ||
| 102 | const listRef = useRef<HTMLDivElement | null>(null); | ||
| 103 | const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); | ||
| 104 | const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); | ||
| 105 | const orderedModels = useMemo(() => { | ||
| 106 | return sortModelsForProviderInstance(models, { | ||
| 107 | favoriteModels: favoriteModelSet, | ||
| 108 | groupFavorites: true, | ||
| 109 | modelOrder, | ||
| 110 | }); | ||
| 111 | }, [favoriteModelSet, modelOrder, models]); | ||
| 112 | |||
| 113 | const handleAdd = () => { | ||
| 114 | const normalized = driverKind ? normalizeModelSlug(input, driverKind) : input.trim() || null; | ||
| 115 | if (!normalized) { | ||
| 116 | setError("Enter a model slug."); | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | if (models.some((model) => !model.isCustom && model.slug === normalized)) { | ||
| 120 | setError("That model is already built in."); | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { | ||
| 124 | setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); | ||
| 125 | return; | ||
| 126 | } | ||
| 127 | if (customModels.includes(normalized)) { | ||
| 128 | setError("That custom model is already saved."); | ||
| 129 | return; | ||
| 130 | } | ||
| 131 | |||
| 132 | onChange([...customModels, normalized]); | ||
| 133 | setInput(""); | ||
| 134 | setError(null); | ||
| 135 | |||
| 136 | // Scroll the new row into view once the DOM reflects the commit. | ||
| 137 | // `MutationObserver` handles the one-frame gap between `onChange` and | ||
| 138 | // the `models` prop update; the `requestAnimationFrame` covers the | ||
| 139 | // common case where the parent updates synchronously. | ||
| 140 | const el = listRef.current; | ||
| 141 | if (!el) return; | ||
| 142 | const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); | ||
| 143 | requestAnimationFrame(scrollToEnd); | ||
| 144 | const observer = new MutationObserver(() => { | ||
| 145 | scrollToEnd(); | ||
| 146 | observer.disconnect(); | ||
| 147 | }); | ||
| 148 | observer.observe(el, { childList: true, subtree: true }); | ||
| 149 | setTimeout(() => observer.disconnect(), 2_000); | ||
| 150 | }; | ||
| 151 | |||
| 152 | const handleRemove = (slug: string) => { | ||
| 153 | onChange(customModels.filter((model) => model !== slug)); | ||
| 154 | onModelOrderChange(modelOrder.filter((model) => model !== slug)); | ||
| 155 | onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); | ||
| 156 | setError(null); | ||
| 157 | }; | ||
| 158 | |||
| 159 | const handleToggleHidden = (slug: string) => { | ||
| 160 | if (hiddenModelSet.has(slug)) { | ||
| 161 | onHiddenModelsChange(hiddenModels.filter((model) => model !== slug)); | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | onHiddenModelsChange([...hiddenModels, slug]); | ||
| 165 | }; | ||
| 166 | |||
| 167 | const handleToggleFavorite = (slug: string) => { | ||
| 168 | if (favoriteModelSet.has(slug)) { | ||
| 169 | onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); | ||
| 170 | return; | ||
| 171 | } | ||
| 172 | onFavoriteModelsChange([...favoriteModels, slug]); | ||
| 173 | }; | ||
| 174 | |||
| 175 | const handleMove = (slug: string, direction: -1 | 1) => { | ||
| 176 | const slugs = orderedModels.map((model) => model.slug); | ||
| 177 | const index = slugs.indexOf(slug); | ||
| 178 | const nextIndex = index + direction; | ||
| 179 | if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) { | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | const next = [...slugs]; | ||
| 183 | [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; | ||
| 184 | onModelOrderChange(next); | ||
| 185 | }; | ||
| 186 | |||
| 187 | return ( | ||
| 188 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 189 | <div className="text-xs font-medium text-foreground">Models</div> | ||
| 190 | <div className="mt-1 text-xs text-muted-foreground"> | ||
| 191 | {models.length} model{models.length === 1 ? "" : "s"} available. | ||
| 192 | </div> | ||
| 193 | <div ref={listRef} className="mt-2 max-h-40 overflow-y-auto pb-1"> | ||
| 194 | {orderedModels.map((model, index) => { | ||
| 195 | const caps = model.capabilities; | ||
| 196 | const capLabels: string[] = []; | ||
| 197 | const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); | ||
| 198 | const isFavorite = favoriteModelSet.has(model.slug); | ||
| 199 | const previousModel = orderedModels[index - 1]; | ||
| 200 | const nextModel = orderedModels[index + 1]; | ||
| 201 | const canMoveUp = | ||
| 202 | previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; | ||
| 203 | const canMoveDown = | ||
| 204 | nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite; | ||
| 205 | const descriptors = caps?.optionDescriptors ?? []; | ||
| 206 | if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { | ||
| 207 | capLabels.push("Fast mode"); | ||
| 208 | } | ||
| 209 | if (descriptors.some((descriptor) => descriptor.id === "thinking")) { | ||
| 210 | capLabels.push("Thinking"); | ||
| 211 | } | ||
| 212 | if ( | ||
| 213 | descriptors.some( | ||
| 214 | (descriptor) => | ||
| 215 | descriptor.type === "select" && | ||
| 216 | (descriptor.id === "reasoningEffort" || | ||
| 217 | descriptor.id === "effort" || | ||
| 218 | descriptor.id === "reasoning" || | ||
| 219 | descriptor.id === "variant"), | ||
| 220 | ) | ||
| 221 | ) { | ||
| 222 | capLabels.push("Reasoning"); | ||
| 223 | } | ||
| 224 | const hasDetails = capLabels.length > 0 || model.name !== model.slug; | ||
| 225 | |||
| 226 | return ( | ||
| 227 | <div | ||
| 228 | key={`${instanceId}:${model.slug}`} | ||
| 229 | className={cn( | ||
| 230 | "grid min-h-7 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-1", | ||
| 231 | isHidden && "text-muted-foreground", | ||
| 232 | )} | ||
| 233 | > | ||
| 234 | <div className="flex min-w-0 items-center gap-1"> | ||
| 235 | <span | ||
| 236 | className={cn( | ||
| 237 | "min-w-0 truncate text-xs", | ||
| 238 | isHidden ? "text-muted-foreground line-through" : "text-foreground/90", | ||
| 239 | )} | ||
| 240 | > | ||
| 241 | {model.name} | ||
| 242 | </span> | ||
| 243 | {hasDetails ? ( | ||
| 244 | <Tooltip> | ||
| 245 | <TooltipTrigger | ||
| 246 | render={ | ||
| 247 | <Button | ||
| 248 | size="icon-xs" | ||
| 249 | variant="ghost" | ||
| 250 | className="size-5 rounded-sm p-0 text-muted-foreground/60 hover:text-muted-foreground" | ||
| 251 | aria-label={`Details for ${model.name}`} | ||
| 252 | /> | ||
| 253 | } | ||
| 254 | > | ||
| 255 | <InfoIcon className="size-3" /> | ||
| 256 | </TooltipTrigger> | ||
| 257 | <TooltipPopup side="top" className="max-w-56"> | ||
| 258 | <div className="space-y-1"> | ||
| 259 | <code className="block text-[11px] text-foreground">{model.slug}</code> | ||
| 260 | {capLabels.length > 0 ? ( | ||
| 261 | <div className="flex flex-wrap gap-x-2 gap-y-0.5"> | ||
| 262 | {capLabels.map((label) => ( | ||
| 263 | <span key={label} className="text-[10px] text-muted-foreground"> | ||
| 264 | {label} | ||
| 265 | </span> | ||
| 266 | ))} | ||
| 267 | </div> | ||
| 268 | ) : null} | ||
| 269 | </div> | ||
| 270 | </TooltipPopup> | ||
| 271 | </Tooltip> | ||
| 272 | ) : null} | ||
| 273 | {isHidden ? ( | ||
| 274 | <span className="text-[10px] text-muted-foreground">hidden</span> | ||
| 275 | ) : null} | ||
| 276 | {model.isCustom ? ( | ||
| 277 | <span className="text-[10px] text-muted-foreground">custom</span> | ||
| 278 | ) : null} | ||
| 279 | </div> | ||
| 280 | <div className="flex shrink-0 items-center gap-0.5"> | ||
| 281 | <Tooltip> | ||
| 282 | <TooltipTrigger | ||
| 283 | render={ | ||
| 284 | <Button | ||
| 285 | size="icon-xs" | ||
| 286 | variant="ghost" | ||
| 287 | className={cn( | ||
| 288 | "size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground", | ||
| 289 | isFavorite && "text-yellow-500 hover:text-yellow-600", | ||
| 290 | )} | ||
| 291 | onClick={() => handleToggleFavorite(model.slug)} | ||
| 292 | aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ | ||
| 293 | isFavorite ? "from" : "to" | ||
| 294 | } favorites`} | ||
| 295 | /> | ||
| 296 | } | ||
| 297 | > | ||
| 298 | <StarIcon className={cn("size-3", isFavorite && "fill-current")} /> | ||
| 299 | </TooltipTrigger> | ||
| 300 | <TooltipPopup side="top"> | ||
| 301 | {isFavorite ? "Remove from favorites" : "Add to favorites"} | ||
| 302 | </TooltipPopup> | ||
| 303 | </Tooltip> | ||
| 304 | <Tooltip> | ||
| 305 | <TooltipTrigger | ||
| 306 | render={ | ||
| 307 | <Button | ||
| 308 | size="icon-xs" | ||
| 309 | variant="ghost" | ||
| 310 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 311 | disabled={!canMoveUp} | ||
| 312 | onClick={() => handleMove(model.slug, -1)} | ||
| 313 | aria-label={`Move ${model.name} up`} | ||
| 314 | /> | ||
| 315 | } | ||
| 316 | > | ||
| 317 | <ArrowUpIcon className="size-3" /> | ||
| 318 | </TooltipTrigger> | ||
| 319 | <TooltipPopup side="top">Move up</TooltipPopup> | ||
| 320 | </Tooltip> | ||
| 321 | <Tooltip> | ||
| 322 | <TooltipTrigger | ||
| 323 | render={ | ||
| 324 | <Button | ||
| 325 | size="icon-xs" | ||
| 326 | variant="ghost" | ||
| 327 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 328 | disabled={!canMoveDown} | ||
| 329 | onClick={() => handleMove(model.slug, 1)} | ||
| 330 | aria-label={`Move ${model.name} down`} | ||
| 331 | /> | ||
| 332 | } | ||
| 333 | > | ||
| 334 | <ArrowDownIcon className="size-3" /> | ||
| 335 | </TooltipTrigger> | ||
| 336 | <TooltipPopup side="top">Move down</TooltipPopup> | ||
| 337 | </Tooltip> | ||
| 338 | {!model.isCustom ? ( | ||
| 339 | <Tooltip> | ||
| 340 | <TooltipTrigger | ||
| 341 | render={ | ||
| 342 | <Button | ||
| 343 | size="icon-xs" | ||
| 344 | variant="ghost" | ||
| 345 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 346 | onClick={() => handleToggleHidden(model.slug)} | ||
| 347 | aria-label={`${isHidden ? "Show" : "Hide"} ${model.name}`} | ||
| 348 | /> | ||
| 349 | } | ||
| 350 | > | ||
| 351 | {isHidden ? ( | ||
| 352 | <EyeIcon className="size-3" /> | ||
| 353 | ) : ( | ||
| 354 | <EyeOffIcon className="size-3" /> | ||
| 355 | )} | ||
| 356 | </TooltipTrigger> | ||
| 357 | <TooltipPopup side="top"> | ||
| 358 | {isHidden ? "Show in picker" : "Hide from picker"} | ||
| 359 | </TooltipPopup> | ||
| 360 | </Tooltip> | ||
| 361 | ) : null} | ||
| 362 | {model.isCustom ? ( | ||
| 363 | <Tooltip> | ||
| 364 | <TooltipTrigger | ||
| 365 | render={ | ||
| 366 | <Button | ||
| 367 | size="icon-xs" | ||
| 368 | variant="ghost" | ||
| 369 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 370 | aria-label={`Remove ${model.slug}`} | ||
| 371 | onClick={() => handleRemove(model.slug)} | ||
| 372 | /> | ||
| 373 | } | ||
| 374 | > | ||
| 375 | <XIcon className="size-3" /> | ||
| 376 | </TooltipTrigger> | ||
| 377 | <TooltipPopup side="top">Remove custom model</TooltipPopup> | ||
| 378 | </Tooltip> | ||
| 379 | ) : null} | ||
| 380 | </div> | ||
| 381 | </div> | ||
| 382 | ); | ||
| 383 | })} | ||
| 384 | </div> | ||
| 385 | |||
| 386 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> | ||
| 387 | <Input | ||
| 388 | id={`provider-instance-${instanceId}-custom-model`} | ||
| 389 | value={input} | ||
| 390 | onChange={(event) => { | ||
| 391 | setInput(event.target.value); | ||
| 392 | if (error) setError(null); | ||
| 393 | }} | ||
| 394 | onKeyDown={(event) => { | ||
| 395 | if (event.key !== "Enter") return; | ||
| 396 | event.preventDefault(); | ||
| 397 | handleAdd(); | ||
| 398 | }} | ||
| 399 | placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} | ||
| 400 | spellCheck={false} | ||
| 401 | /> | ||
| 402 | <Button className="shrink-0" variant="outline" onClick={handleAdd}> | ||
| 403 | <PlusIcon className="size-3.5" /> | ||
| 404 | Add | ||
| 405 | </Button> | ||
| 406 | </div> | ||
| 407 | |||
| 408 | {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null} | ||
| 409 | </div> | ||
| 410 | ); | ||
| 411 | } | ||
apps/web/src/components/settings/SettingsPanels.browser.tsx+7-3| ... | @@ -713,9 +713,13 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -713,9 +713,13 @@ describe("GeneralSettingsPanel observability", () => { |
| 713 | 713 | ||
| 714 | await page.getByLabelText("Toggle OpenCode details").click(); | 714 | await page.getByLabelText("Toggle OpenCode details").click(); |
| 715 | 715 | ||
| 716 | await expect.element(page.getByText("OpenCode server URL")).toBeInTheDocument(); | 716 | // The unified provider-instance card renders field labels without a |
| 717 | // driver-name prefix (the driver name is already shown in the card | ||
| 718 | // header), so the labels read "Server URL" / "Server password" | ||
| 719 | // rather than the old "OpenCode server URL" / "OpenCode server password". | ||
| 720 | await expect.element(page.getByText("Server URL")).toBeInTheDocument(); | ||
| 717 | await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument(); | 721 | await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument(); |
| 718 | await expect.element(page.getByText("OpenCode server password")).toBeInTheDocument(); | 722 | await expect.element(page.getByText("Server password")).toBeInTheDocument(); |
| 719 | await expect.element(page.getByPlaceholder("Server password")).toBeInTheDocument(); | 723 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); |
| 720 | }); | 724 | }); |
| 721 | }); | 725 | }); |
apps/web/src/components/settings/SettingsPanels.logic.test.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type ProviderInstanceConfig, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "vitest"; | ||
| 8 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; | ||
| 9 | |||
| 10 | describe("buildProviderInstanceUpdatePatch", () => { | ||
| 11 | it("promotes an edited default provider into providerInstances and resets the legacy provider", () => { | ||
| 12 | const instanceId = ProviderInstanceId.make("codex"); | ||
| 13 | const nextInstance = { | ||
| 14 | driver: ProviderDriverKind.make("codex"), | ||
| 15 | enabled: true, | ||
| 16 | config: { | ||
| 17 | binaryPath: "/opt/t3/codex", | ||
| 18 | }, | ||
| 19 | } satisfies ProviderInstanceConfig; | ||
| 20 | |||
| 21 | const patch = buildProviderInstanceUpdatePatch({ | ||
| 22 | settings: { | ||
| 23 | ...DEFAULT_SERVER_SETTINGS, | ||
| 24 | providers: { | ||
| 25 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 26 | codex: { | ||
| 27 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | ||
| 28 | binaryPath: "/legacy/codex", | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | }, | ||
| 32 | instanceId, | ||
| 33 | instance: nextInstance, | ||
| 34 | driver: ProviderDriverKind.make("codex"), | ||
| 35 | isDefault: true, | ||
| 36 | }); | ||
| 37 | |||
| 38 | expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); | ||
| 39 | expect(patch.providers?.codex).toEqual(DEFAULT_SERVER_SETTINGS.providers.codex); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("updates custom instances without touching legacy provider settings", () => { | ||
| 43 | const instanceId = ProviderInstanceId.make("codex_personal"); | ||
| 44 | const nextInstance = { | ||
| 45 | driver: ProviderDriverKind.make("codex"), | ||
| 46 | enabled: true, | ||
| 47 | config: { | ||
| 48 | homePath: "/Users/example/.codex-personal", | ||
| 49 | }, | ||
| 50 | } satisfies ProviderInstanceConfig; | ||
| 51 | |||
| 52 | const patch = buildProviderInstanceUpdatePatch({ | ||
| 53 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 54 | instanceId, | ||
| 55 | instance: nextInstance, | ||
| 56 | driver: ProviderDriverKind.make("codex"), | ||
| 57 | isDefault: false, | ||
| 58 | }); | ||
| 59 | |||
| 60 | expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); | ||
| 61 | expect(patch.providers).toBeUndefined(); | ||
| 62 | }); | ||
| 63 | }); | ||
apps/web/src/components/settings/SettingsPanels.logic.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import type { | ||
| 2 | ProviderDriverKind, | ||
| 3 | ProviderInstanceConfig, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ServerSettings, | ||
| 6 | UnifiedSettings, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 9 | |||
| 10 | export function buildProviderInstanceUpdatePatch(input: { | ||
| 11 | readonly settings: Pick<ServerSettings, "providers" | "providerInstances">; | ||
| 12 | readonly instanceId: ProviderInstanceId; | ||
| 13 | readonly instance: ProviderInstanceConfig; | ||
| 14 | readonly driver: ProviderDriverKind; | ||
| 15 | readonly isDefault: boolean; | ||
| 16 | readonly textGenerationModelSelection?: | ||
| 17 | | ServerSettings["textGenerationModelSelection"] | ||
| 18 | | undefined; | ||
| 19 | }): Partial<UnifiedSettings> { | ||
| 20 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; | ||
| 21 | const legacyProviderDefaults = DEFAULT_UNIFIED_SETTINGS.providers as Record< | ||
| 22 | string, | ||
| 23 | LegacyProviderSettings | undefined | ||
| 24 | >; | ||
| 25 | const legacyProviderDefault = input.isDefault ? legacyProviderDefaults[input.driver] : undefined; | ||
| 26 | return { | ||
| 27 | ...(legacyProviderDefault !== undefined | ||
| 28 | ? { | ||
| 29 | providers: { | ||
| 30 | ...input.settings.providers, | ||
| 31 | [input.driver]: legacyProviderDefault, | ||
| 32 | } as ServerSettings["providers"], | ||
| 33 | } | ||
| 34 | : {}), | ||
| 35 | providerInstances: { | ||
| 36 | ...input.settings.providerInstances, | ||
| 37 | [input.instanceId]: input.instance, | ||
| 38 | }, | ||
| 39 | ...(input.textGenerationModelSelection !== undefined | ||
| 40 | ? { textGenerationModelSelection: input.textGenerationModelSelection } | ||
| 41 | : {}), | ||
| 42 | }; | ||
| 43 | } | ||
apps/web/src/components/settings/SettingsPanels.tsx+402-726| ... | @@ -1,25 +1,17 @@ | ... | @@ -1,25 +1,17 @@ |
| 1 | import { | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | ArchiveIcon, | ||
| 3 | ArchiveX, | ||
| 4 | ChevronDownIcon, | ||
| 5 | InfoIcon, | ||
| 6 | LoaderIcon, | ||
| 7 | PlusIcon, | ||
| 8 | RefreshCwIcon, | ||
| 9 | XIcon, | ||
| 10 | } from "lucide-react"; | ||
| 11 | import { useQueryClient } from "@tanstack/react-query"; | 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 12 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; | 3 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; |
| 13 | import { | 4 | import { |
| 5 | defaultInstanceIdForDriver, | ||
| 14 | type DesktopUpdateChannel, | 6 | type DesktopUpdateChannel, |
| 7 | ProviderDriverKind, | ||
| 8 | type ProviderInstanceConfig, | ||
| 9 | type ProviderInstanceId, | ||
| 15 | type ScopedThreadRef, | 10 | type ScopedThreadRef, |
| 16 | type ProviderKind, | ||
| 17 | type ServerProvider, | ||
| 18 | type ServerProviderModel, | ||
| 19 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 20 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 12 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 21 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 13 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 22 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 14 | import { createModelSelection } from "@t3tools/shared/model"; |
| 23 | import { Equal } from "effect"; | 15 | import { Equal } from "effect"; |
| 24 | import { APP_VERSION } from "../../branding"; | 16 | import { APP_VERSION } from "../../branding"; |
| 25 | import { | 17 | import { |
| ... | @@ -41,10 +33,13 @@ import { | ... | @@ -41,10 +33,13 @@ import { |
| 41 | useDesktopUpdateState, | 33 | useDesktopUpdateState, |
| 42 | } from "../../lib/desktopUpdateReactQuery"; | 34 | } from "../../lib/desktopUpdateReactQuery"; |
| 43 | import { | 35 | import { |
| 44 | MAX_CUSTOM_MODEL_LENGTH, | 36 | getCustomModelOptionsByInstance, |
| 45 | getCustomModelOptionsByProvider, | ||
| 46 | resolveAppModelSelectionState, | 37 | resolveAppModelSelectionState, |
| 47 | } from "../../modelSelection"; | 38 | } from "../../modelSelection"; |
| 39 | import { | ||
| 40 | deriveProviderInstanceEntries, | ||
| 41 | sortProviderInstanceEntries, | ||
| 42 | } from "../../providerInstances"; | ||
| 48 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 43 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 49 | import { useShallow } from "zustand/react/shallow"; | 44 | import { useShallow } from "zustand/react/shallow"; |
| 50 | import { | 45 | import { |
| ... | @@ -53,16 +48,17 @@ import { | ... | @@ -53,16 +48,17 @@ import { |
| 53 | useStore, | 48 | useStore, |
| 54 | } from "../../store"; | 49 | } from "../../store"; |
| 55 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | 50 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| 56 | import { cn } from "../../lib/utils"; | ||
| 57 | import { Badge } from "../ui/badge"; | ||
| 58 | import { Button } from "../ui/button"; | 51 | import { Button } from "../ui/button"; |
| 59 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | ||
| 60 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | 52 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| 61 | import { Input } from "../ui/input"; | 53 | import { DraftInput } from "../ui/draft-input"; |
| 62 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 54 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 63 | import { Switch } from "../ui/switch"; | 55 | import { Switch } from "../ui/switch"; |
| 64 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 56 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 65 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 57 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 58 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | ||
| 59 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | ||
| 60 | import { getDriverOption } from "./providerDriverMeta"; | ||
| 61 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; | ||
| 66 | import { | 62 | import { |
| 67 | SettingResetButton, | 63 | SettingResetButton, |
| 68 | SettingsPageContainer, | 64 | SettingsPageContainer, |
| ... | @@ -77,7 +73,6 @@ import { | ... | @@ -77,7 +73,6 @@ import { |
| 77 | useServerObservability, | 73 | useServerObservability, |
| 78 | useServerProviders, | 74 | useServerProviders, |
| 79 | } from "../../rpc/serverState"; | 75 | } from "../../rpc/serverState"; |
| 80 | import { formatProviderKindLabel } from "../../providerModels"; | ||
| 81 | 76 | ||
| 82 | const THEME_OPTIONS = [ | 77 | const THEME_OPTIONS = [ |
| 83 | { | 78 | { |
| ... | @@ -100,8 +95,26 @@ const TIMESTAMP_FORMAT_LABELS = { | ... | @@ -100,8 +95,26 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 100 | "24-hour": "24-hour", | 95 | "24-hour": "24-hour", |
| 101 | } as const; | 96 | } as const; |
| 102 | 97 | ||
| 98 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | ||
| 99 | |||
| 100 | function withoutProviderInstanceKey<V>( | ||
| 101 | record: Readonly<Record<ProviderInstanceId, V>> | undefined, | ||
| 102 | key: ProviderInstanceId, | ||
| 103 | ): Record<ProviderInstanceId, V> { | ||
| 104 | const next = { ...record } as Record<ProviderInstanceId, V>; | ||
| 105 | delete next[key]; | ||
| 106 | return next; | ||
| 107 | } | ||
| 108 | |||
| 109 | function withoutProviderInstanceFavorites( | ||
| 110 | favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, | ||
| 111 | instanceId: ProviderInstanceId, | ||
| 112 | ) { | ||
| 113 | return favorites.filter((favorite) => favorite.provider !== instanceId); | ||
| 114 | } | ||
| 115 | |||
| 103 | type InstallProviderSettings = { | 116 | type InstallProviderSettings = { |
| 104 | provider: ProviderKind; | 117 | provider: ProviderDriverKind; |
| 105 | title: string; | 118 | title: string; |
| 106 | badgeLabel?: string; | 119 | badgeLabel?: string; |
| 107 | binaryPlaceholder: string; | 120 | binaryPlaceholder: string; |
| ... | @@ -117,7 +130,7 @@ type InstallProviderSettings = { | ... | @@ -117,7 +130,7 @@ type InstallProviderSettings = { |
| 117 | 130 | ||
| 118 | const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | 131 | const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 119 | { | 132 | { |
| 120 | provider: "codex", | 133 | provider: ProviderDriverKind.make("codex"), |
| 121 | title: "Codex", | 134 | title: "Codex", |
| 122 | binaryPlaceholder: "Codex binary path", | 135 | binaryPlaceholder: "Codex binary path", |
| 123 | binaryDescription: "Path to the Codex binary", | 136 | binaryDescription: "Path to the Codex binary", |
| ... | @@ -126,20 +139,20 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | ... | @@ -126,20 +139,20 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 126 | homeDescription: "Optional custom Codex home and config directory.", | 139 | homeDescription: "Optional custom Codex home and config directory.", |
| 127 | }, | 140 | }, |
| 128 | { | 141 | { |
| 129 | provider: "claudeAgent", | 142 | provider: ProviderDriverKind.make("claudeAgent"), |
| 130 | title: "Claude", | 143 | title: "Claude", |
| 131 | binaryPlaceholder: "Claude binary path", | 144 | binaryPlaceholder: "Claude binary path", |
| 132 | binaryDescription: "Path to the Claude binary", | 145 | binaryDescription: "Path to the Claude binary", |
| 133 | }, | 146 | }, |
| 134 | { | 147 | { |
| 135 | provider: "cursor", | 148 | provider: ProviderDriverKind.make("cursor"), |
| 136 | title: "Cursor", | 149 | title: "Cursor", |
| 137 | badgeLabel: "Early Access", | 150 | badgeLabel: "Early Access", |
| 138 | binaryPlaceholder: "Cursor agent binary path", | 151 | binaryPlaceholder: "Cursor agent binary path", |
| 139 | binaryDescription: "Path to the Cursor agent binary", | 152 | binaryDescription: "Path to the Cursor agent binary", |
| 140 | }, | 153 | }, |
| 141 | { | 154 | { |
| 142 | provider: "opencode", | 155 | provider: ProviderDriverKind.make("opencode"), |
| 143 | title: "OpenCode", | 156 | title: "OpenCode", |
| 144 | binaryPlaceholder: "OpenCode binary path", | 157 | binaryPlaceholder: "OpenCode binary path", |
| 145 | binaryDescription: "Path to the OpenCode binary", | 158 | binaryDescription: "Path to the OpenCode binary", |
| ... | @@ -151,78 +164,6 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | ... | @@ -151,78 +164,6 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 151 | }, | 164 | }, |
| 152 | ] as const; | 165 | ] as const; |
| 153 | 166 | ||
| 154 | const PROVIDER_STATUS_STYLES = { | ||
| 155 | disabled: { | ||
| 156 | dot: "bg-amber-400", | ||
| 157 | }, | ||
| 158 | error: { | ||
| 159 | dot: "bg-destructive", | ||
| 160 | }, | ||
| 161 | ready: { | ||
| 162 | dot: "bg-success", | ||
| 163 | }, | ||
| 164 | warning: { | ||
| 165 | dot: "bg-warning", | ||
| 166 | }, | ||
| 167 | } as const; | ||
| 168 | |||
| 169 | function getProviderSummary(provider: ServerProvider | undefined) { | ||
| 170 | if (!provider) { | ||
| 171 | return { | ||
| 172 | headline: "Checking provider status", | ||
| 173 | detail: "Waiting for the server to report installation and authentication details.", | ||
| 174 | }; | ||
| 175 | } | ||
| 176 | if (!provider.enabled) { | ||
| 177 | return { | ||
| 178 | headline: "Disabled", | ||
| 179 | detail: | ||
| 180 | provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | if (!provider.installed) { | ||
| 184 | return { | ||
| 185 | headline: "Not found", | ||
| 186 | detail: provider.message ?? "CLI not detected on PATH.", | ||
| 187 | }; | ||
| 188 | } | ||
| 189 | if (provider.auth.status === "authenticated") { | ||
| 190 | const authLabel = provider.auth.label ?? provider.auth.type; | ||
| 191 | return { | ||
| 192 | headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", | ||
| 193 | detail: provider.message ?? null, | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | if (provider.auth.status === "unauthenticated") { | ||
| 197 | return { | ||
| 198 | headline: "Not authenticated", | ||
| 199 | detail: provider.message ?? null, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | if (provider.status === "warning") { | ||
| 203 | return { | ||
| 204 | headline: "Needs attention", | ||
| 205 | detail: | ||
| 206 | provider.message ?? "The provider is installed, but the server could not fully verify it.", | ||
| 207 | }; | ||
| 208 | } | ||
| 209 | if (provider.status === "error") { | ||
| 210 | return { | ||
| 211 | headline: "Unavailable", | ||
| 212 | detail: provider.message ?? "The provider failed its startup checks.", | ||
| 213 | }; | ||
| 214 | } | ||
| 215 | return { | ||
| 216 | headline: "Available", | ||
| 217 | detail: provider.message ?? "Installed and ready, but authentication could not be verified.", | ||
| 218 | }; | ||
| 219 | } | ||
| 220 | |||
| 221 | function getProviderVersionLabel(version: string | null | undefined) { | ||
| 222 | if (!version) return null; | ||
| 223 | return version.startsWith("v") ? version : `v${version}`; | ||
| 224 | } | ||
| 225 | |||
| 226 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 167 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 227 | useRelativeTimeTick(); | 168 | useRelativeTimeTick(); |
| 228 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; | 169 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; |
| ... | @@ -457,11 +398,31 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -457,11 +398,31 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 457 | settings.textGenerationModelSelection ?? null, | 398 | settings.textGenerationModelSelection ?? null, |
| 458 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, | 399 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 459 | ); | 400 | ); |
| 460 | const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { | 401 | // A provider surface is "dirty" if either the legacy per-kind |
| 461 | const currentSettings = settings.providers[providerSettings.provider]; | 402 | // `settings.providers[kind]` struct differs from defaults (for users |
| 462 | const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; | 403 | // on pre-migration data) or the new `settings.providerInstances` map |
| 463 | return !Equal.equals(currentSettings, defaultSettings); | 404 | // has any entries (every edit to a default slot promotes it into an |
| 464 | }); | 405 | // explicit entry, so any key in that map represents user intent to |
| 406 | // diverge from factory defaults). Checking both keeps the Restore | ||
| 407 | // Defaults chip accurate throughout the legacy→instance migration. | ||
| 408 | const areProviderSettingsDirty = | ||
| 409 | PROVIDER_SETTINGS.some((providerSettings) => { | ||
| 410 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | ||
| 411 | const currentProviders = settings.providers as Record< | ||
| 412 | string, | ||
| 413 | LegacyProviderSettings | undefined | ||
| 414 | >; | ||
| 415 | const defaultProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | ||
| 416 | string, | ||
| 417 | LegacyProviderSettings | undefined | ||
| 418 | >; | ||
| 419 | const currentSettings = currentProviders[providerSettings.provider]; | ||
| 420 | const defaultSettings = defaultProviders[providerSettings.provider]; | ||
| 421 | return !Equal.equals(currentSettings, defaultSettings); | ||
| 422 | }) || | ||
| 423 | Object.keys(settings.providerInstances ?? {}).length > 0 || | ||
| 424 | Object.keys(settings.providerModelPreferences ?? {}).length > 0 || | ||
| 425 | (settings.favorites ?? []).length > 0; | ||
| 465 | 426 | ||
| 466 | const changedSettingLabels = useMemo( | 427 | const changedSettingLabels = useMemo( |
| 467 | () => [ | 428 | () => [ |
| ... | @@ -540,47 +501,17 @@ export function GeneralSettingsPanel() { | ... | @@ -540,47 +501,17 @@ export function GeneralSettingsPanel() { |
| 540 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< | 501 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< |
| 541 | Partial<Record<"keybindings" | "logsDirectory", string | null>> | 502 | Partial<Record<"keybindings" | "logsDirectory", string | null>> |
| 542 | >({}); | 503 | >({}); |
| 543 | const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({ | ||
| 544 | codex: Boolean( | ||
| 545 | settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || | ||
| 546 | settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || | ||
| 547 | settings.providers.codex.customModels.length > 0, | ||
| 548 | ), | ||
| 549 | claudeAgent: Boolean( | ||
| 550 | settings.providers.claudeAgent.binaryPath !== | ||
| 551 | DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || | ||
| 552 | settings.providers.claudeAgent.customModels.length > 0 || | ||
| 553 | settings.providers.claudeAgent.launchArgs !== "", | ||
| 554 | ), | ||
| 555 | cursor: Boolean( | ||
| 556 | settings.providers.cursor.binaryPath !== | ||
| 557 | DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath || | ||
| 558 | settings.providers.cursor.customModels.length > 0, | ||
| 559 | ), | ||
| 560 | opencode: Boolean( | ||
| 561 | settings.providers.opencode.binaryPath !== | ||
| 562 | DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath || | ||
| 563 | settings.providers.opencode.serverUrl !== | ||
| 564 | DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverUrl || | ||
| 565 | settings.providers.opencode.serverPassword !== | ||
| 566 | DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverPassword || | ||
| 567 | settings.providers.opencode.customModels.length > 0, | ||
| 568 | ), | ||
| 569 | }); | ||
| 570 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< | ||
| 571 | Record<ProviderKind, string> | ||
| 572 | >({ | ||
| 573 | codex: "", | ||
| 574 | claudeAgent: "", | ||
| 575 | cursor: "", | ||
| 576 | opencode: "", | ||
| 577 | }); | ||
| 578 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< | ||
| 579 | Partial<Record<ProviderKind, string | null>> | ||
| 580 | >({}); | ||
| 581 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | 504 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); |
| 505 | const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); | ||
| 506 | // Collapsible state per provider-instance card, keyed by the instance id. | ||
| 507 | // `Record<string, boolean>` so we don't need to preseed an entry for every | ||
| 508 | // configured instance — an absent key reads as collapsed. Default-slot | ||
| 509 | // rows share this state: their id is the driver slug | ||
| 510 | // (`defaultInstanceIdForDriver(driver)`), which is also `ProviderDriverKind` at | ||
| 511 | // runtime, so a pre-existing open key for e.g. "codex" persists across | ||
| 512 | // the legacy/unified render swap. | ||
| 513 | const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({}); | ||
| 582 | const refreshingRef = useRef(false); | 514 | const refreshingRef = useRef(false); |
| 583 | const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({}); | ||
| 584 | const refreshProviders = useCallback(() => { | 515 | const refreshProviders = useCallback(() => { |
| 585 | if (refreshingRef.current) return; | 516 | if (refreshingRef.current) return; |
| 586 | refreshingRef.current = true; | 517 | refreshingRef.current = true; |
| ... | @@ -603,9 +534,11 @@ export function GeneralSettingsPanel() { | ... | @@ -603,9 +534,11 @@ export function GeneralSettingsPanel() { |
| 603 | const visibleProviderSettings = PROVIDER_SETTINGS.filter( | 534 | const visibleProviderSettings = PROVIDER_SETTINGS.filter( |
| 604 | (providerSettings) => | 535 | (providerSettings) => |
| 605 | providerSettings.provider !== "cursor" || | 536 | providerSettings.provider !== "cursor" || |
| 606 | serverProviders.some((provider) => provider.provider === "cursor"), | 537 | serverProviders.some( |
| 538 | (provider) => | ||
| 539 | provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), | ||
| 540 | ), | ||
| 607 | ); | 541 | ); |
| 608 | const codexHomePath = settings.providers.codex.homePath; | ||
| 609 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; | 542 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; |
| 610 | const diagnosticsDescription = (() => { | 543 | const diagnosticsDescription = (() => { |
| 611 | const exports: string[] = []; | 544 | const exports: string[] = []; |
| ... | @@ -620,13 +553,21 @@ export function GeneralSettingsPanel() { | ... | @@ -620,13 +553,21 @@ export function GeneralSettingsPanel() { |
| 620 | })(); | 553 | })(); |
| 621 | 554 | ||
| 622 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); | 555 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); |
| 623 | const textGenProvider = textGenerationModelSelection.provider; | 556 | const textGenInstanceId = textGenerationModelSelection.instanceId; |
| 624 | const textGenModel = textGenerationModelSelection.model; | 557 | const textGenModel = textGenerationModelSelection.model; |
| 625 | const textGenModelOptions = textGenerationModelSelection.options; | 558 | const textGenModelOptions = textGenerationModelSelection.options; |
| 626 | const gitModelOptionsByProvider = getCustomModelOptionsByProvider( | 559 | const gitModelInstanceEntries = sortProviderInstanceEntries( |
| 560 | deriveProviderInstanceEntries(serverProviders), | ||
| 561 | ); | ||
| 562 | const textGenInstanceEntry = gitModelInstanceEntries.find( | ||
| 563 | (entry) => entry.instanceId === textGenInstanceId, | ||
| 564 | ); | ||
| 565 | const textGenProvider: ProviderDriverKind = | ||
| 566 | textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; | ||
| 567 | const gitModelOptionsByInstance = getCustomModelOptionsByInstance( | ||
| 627 | settings, | 568 | settings, |
| 628 | serverProviders, | 569 | serverProviders, |
| 629 | textGenProvider, | 570 | textGenInstanceId, |
| 630 | textGenModel, | 571 | textGenModel, |
| 631 | ); | 572 | ); |
| 632 | const isGitWritingModelDirty = !Equal.equals( | 573 | const isGitWritingModelDirty = !Equal.equals( |
| ... | @@ -678,140 +619,6 @@ export function GeneralSettingsPanel() { | ... | @@ -678,140 +619,6 @@ export function GeneralSettingsPanel() { |
| 678 | const isOpeningKeybindings = openingPathByTarget.keybindings; | 619 | const isOpeningKeybindings = openingPathByTarget.keybindings; |
| 679 | const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; | 620 | const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; |
| 680 | 621 | ||
| 681 | const addCustomModel = useCallback( | ||
| 682 | (provider: ProviderKind) => { | ||
| 683 | const customModelInput = customModelInputByProvider[provider]; | ||
| 684 | const customModels = settings.providers[provider].customModels; | ||
| 685 | const normalized = normalizeModelSlug(customModelInput, provider); | ||
| 686 | if (!normalized) { | ||
| 687 | setCustomModelErrorByProvider((existing) => ({ | ||
| 688 | ...existing, | ||
| 689 | [provider]: "Enter a model slug.", | ||
| 690 | })); | ||
| 691 | return; | ||
| 692 | } | ||
| 693 | if ( | ||
| 694 | serverProviders | ||
| 695 | .find((candidate) => candidate.provider === provider) | ||
| 696 | ?.models.some((option) => !option.isCustom && option.slug === normalized) | ||
| 697 | ) { | ||
| 698 | setCustomModelErrorByProvider((existing) => ({ | ||
| 699 | ...existing, | ||
| 700 | [provider]: "That model is already built in.", | ||
| 701 | })); | ||
| 702 | return; | ||
| 703 | } | ||
| 704 | if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { | ||
| 705 | setCustomModelErrorByProvider((existing) => ({ | ||
| 706 | ...existing, | ||
| 707 | [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, | ||
| 708 | })); | ||
| 709 | return; | ||
| 710 | } | ||
| 711 | if (customModels.includes(normalized)) { | ||
| 712 | setCustomModelErrorByProvider((existing) => ({ | ||
| 713 | ...existing, | ||
| 714 | [provider]: "That custom model is already saved.", | ||
| 715 | })); | ||
| 716 | return; | ||
| 717 | } | ||
| 718 | |||
| 719 | updateSettings({ | ||
| 720 | providers: { | ||
| 721 | ...settings.providers, | ||
| 722 | [provider]: { | ||
| 723 | ...settings.providers[provider], | ||
| 724 | customModels: [...customModels, normalized], | ||
| 725 | }, | ||
| 726 | }, | ||
| 727 | }); | ||
| 728 | setCustomModelInputByProvider((existing) => ({ | ||
| 729 | ...existing, | ||
| 730 | [provider]: "", | ||
| 731 | })); | ||
| 732 | setCustomModelErrorByProvider((existing) => ({ | ||
| 733 | ...existing, | ||
| 734 | [provider]: null, | ||
| 735 | })); | ||
| 736 | |||
| 737 | const el = modelListRefs.current[provider]; | ||
| 738 | if (!el) return; | ||
| 739 | const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); | ||
| 740 | requestAnimationFrame(scrollToEnd); | ||
| 741 | const observer = new MutationObserver(() => { | ||
| 742 | scrollToEnd(); | ||
| 743 | observer.disconnect(); | ||
| 744 | }); | ||
| 745 | observer.observe(el, { childList: true, subtree: true }); | ||
| 746 | setTimeout(() => observer.disconnect(), 2_000); | ||
| 747 | }, | ||
| 748 | [customModelInputByProvider, serverProviders, settings, updateSettings], | ||
| 749 | ); | ||
| 750 | |||
| 751 | const removeCustomModel = useCallback( | ||
| 752 | (provider: ProviderKind, slug: string) => { | ||
| 753 | updateSettings({ | ||
| 754 | providers: { | ||
| 755 | ...settings.providers, | ||
| 756 | [provider]: { | ||
| 757 | ...settings.providers[provider], | ||
| 758 | customModels: settings.providers[provider].customModels.filter( | ||
| 759 | (model) => model !== slug, | ||
| 760 | ), | ||
| 761 | }, | ||
| 762 | }, | ||
| 763 | }); | ||
| 764 | setCustomModelErrorByProvider((existing) => ({ | ||
| 765 | ...existing, | ||
| 766 | [provider]: null, | ||
| 767 | })); | ||
| 768 | }, | ||
| 769 | [settings, updateSettings], | ||
| 770 | ); | ||
| 771 | |||
| 772 | const providerCards = visibleProviderSettings.map((providerSettings) => { | ||
| 773 | const liveProvider = serverProviders.find( | ||
| 774 | (candidate) => candidate.provider === providerSettings.provider, | ||
| 775 | ); | ||
| 776 | const providerConfig = settings.providers[providerSettings.provider]; | ||
| 777 | const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; | ||
| 778 | const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); | ||
| 779 | const summary = getProviderSummary(liveProvider); | ||
| 780 | const models: ReadonlyArray<ServerProviderModel> = | ||
| 781 | liveProvider?.models ?? | ||
| 782 | providerConfig.customModels.map((slug) => ({ | ||
| 783 | slug, | ||
| 784 | name: slug, | ||
| 785 | isCustom: true, | ||
| 786 | capabilities: null, | ||
| 787 | })); | ||
| 788 | |||
| 789 | return { | ||
| 790 | provider: providerSettings.provider, | ||
| 791 | title: providerSettings.title, | ||
| 792 | badgeLabel: providerSettings.badgeLabel, | ||
| 793 | binaryPlaceholder: providerSettings.binaryPlaceholder, | ||
| 794 | binaryDescription: providerSettings.binaryDescription, | ||
| 795 | serverUrlPlaceholder: providerSettings.serverUrlPlaceholder, | ||
| 796 | serverUrlDescription: providerSettings.serverUrlDescription, | ||
| 797 | serverPasswordPlaceholder: providerSettings.serverPasswordPlaceholder, | ||
| 798 | serverPasswordDescription: providerSettings.serverPasswordDescription, | ||
| 799 | homePathKey: providerSettings.homePathKey, | ||
| 800 | homePlaceholder: providerSettings.homePlaceholder, | ||
| 801 | homeDescription: providerSettings.homeDescription, | ||
| 802 | binaryPathValue: providerConfig.binaryPath, | ||
| 803 | serverUrlValue: "serverUrl" in providerConfig ? providerConfig.serverUrl : "", | ||
| 804 | serverPasswordValue: "serverPassword" in providerConfig ? providerConfig.serverPassword : "", | ||
| 805 | isDirty: !Equal.equals(providerConfig, defaultProviderConfig), | ||
| 806 | liveProvider, | ||
| 807 | models, | ||
| 808 | providerConfig, | ||
| 809 | statusStyle: PROVIDER_STATUS_STYLES[statusKey], | ||
| 810 | summary, | ||
| 811 | versionLabel: getProviderVersionLabel(liveProvider?.version), | ||
| 812 | }; | ||
| 813 | }); | ||
| 814 | |||
| 815 | const lastCheckedAt = | 622 | const lastCheckedAt = |
| 816 | serverProviders.length > 0 | 623 | serverProviders.length > 0 |
| 817 | ? serverProviders.reduce( | 624 | ? serverProviders.reduce( |
| ... | @@ -820,6 +627,213 @@ export function GeneralSettingsPanel() { | ... | @@ -820,6 +627,213 @@ export function GeneralSettingsPanel() { |
| 820 | ) | 627 | ) |
| 821 | : null; | 628 | : null; |
| 822 | 629 | ||
| 630 | /** | ||
| 631 | * Build the list of rows to render, one per configured instance. Each | ||
| 632 | * row carries enough context to drive `ProviderInstanceCard` without | ||
| 633 | * threading storage concerns: whether it's a built-in default slot (in | ||
| 634 | * which case `isDefault` is true, deletion is gated off, and the | ||
| 635 | * effective envelope may have been synthesized from legacy just for | ||
| 636 | * this render), the driver kind narrow for the in-card model-slug | ||
| 637 | * normalization, and whether a reset-to-factory action is warranted. | ||
| 638 | * | ||
| 639 | * Ordering mirrors the prior split: visible built-in default slots | ||
| 640 | * first (one per visible kind), then user-authored custom instances | ||
| 641 | * grouped by driver after their default sibling, then orphan instances | ||
| 642 | * whose driver isn't in the visible-defaults set. | ||
| 643 | */ | ||
| 644 | interface InstanceRow { | ||
| 645 | readonly instanceId: ProviderInstanceId; | ||
| 646 | readonly instance: ProviderInstanceConfig; | ||
| 647 | readonly driver: ProviderDriverKind; | ||
| 648 | /** True for the slot whose id is `defaultInstanceIdForDriver(driver)`. */ | ||
| 649 | readonly isDefault: boolean; | ||
| 650 | /** | ||
| 651 | * True when this default slot differs from the factory defaults — | ||
| 652 | * either through an explicit `providerInstances[defaultId]` entry, | ||
| 653 | * or through a non-default legacy `settings.providers[kind]` struct | ||
| 654 | * that we're still bridging. Used to show the reset-to-factory | ||
| 655 | * affordance. Undefined for custom rows (they have a delete button | ||
| 656 | * instead; "factory defaults" isn't meaningful). | ||
| 657 | */ | ||
| 658 | readonly isDirty?: boolean; | ||
| 659 | } | ||
| 660 | |||
| 661 | const instancesByDriver = new Map< | ||
| 662 | ProviderDriverKind, | ||
| 663 | Array<[ProviderInstanceId, ProviderInstanceConfig]> | ||
| 664 | >(); | ||
| 665 | for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { | ||
| 666 | const driver = instance.driver; | ||
| 667 | const list = instancesByDriver.get(driver) ?? []; | ||
| 668 | list.push([rawId as ProviderInstanceId, instance]); | ||
| 669 | instancesByDriver.set(driver, list); | ||
| 670 | } | ||
| 671 | |||
| 672 | const defaultSlotIdsBySource = new Set<string>( | ||
| 673 | visibleProviderSettings.map((providerSettings) => | ||
| 674 | String(defaultInstanceIdForDriver(providerSettings.provider)), | ||
| 675 | ), | ||
| 676 | ); | ||
| 677 | |||
| 678 | const rows: InstanceRow[] = []; | ||
| 679 | const visibleDriverKinds = new Set<ProviderDriverKind>( | ||
| 680 | visibleProviderSettings.map((providerSettings) => providerSettings.provider), | ||
| 681 | ); | ||
| 682 | |||
| 683 | for (const providerSettings of visibleProviderSettings) { | ||
| 684 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | ||
| 685 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | ||
| 686 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | ||
| 687 | string, | ||
| 688 | LegacyProviderSettings | ||
| 689 | >; | ||
| 690 | const driver = providerSettings.provider; | ||
| 691 | const defaultInstanceId = defaultInstanceIdForDriver(driver); | ||
| 692 | // Prefer an explicit `providerInstances[defaultId]` entry when one | ||
| 693 | // exists (every edit via this UI promotes the default slot into | ||
| 694 | // that map); fall back to synthesizing one from the legacy | ||
| 695 | // `settings.providers[kind]` struct so first-time viewers still see | ||
| 696 | // their persisted config. | ||
| 697 | const explicitInstance = settings.providerInstances?.[defaultInstanceId]; | ||
| 698 | const legacyConfig = legacyProviders[providerSettings.provider]!; | ||
| 699 | const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; | ||
| 700 | const effectiveInstance: ProviderInstanceConfig = | ||
| 701 | explicitInstance ?? | ||
| 702 | ({ | ||
| 703 | driver, | ||
| 704 | enabled: legacyConfig.enabled, | ||
| 705 | config: legacyConfig, | ||
| 706 | } satisfies ProviderInstanceConfig); | ||
| 707 | const isDirty = | ||
| 708 | explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); | ||
| 709 | rows.push({ | ||
| 710 | instanceId: defaultInstanceId, | ||
| 711 | instance: effectiveInstance, | ||
| 712 | driver, | ||
| 713 | isDefault: true, | ||
| 714 | isDirty, | ||
| 715 | }); | ||
| 716 | // Non-default customs for this driver kind follow their default. | ||
| 717 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | ||
| 718 | if (id === defaultInstanceId) continue; | ||
| 719 | rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); | ||
| 720 | } | ||
| 721 | } | ||
| 722 | // Orphan instances: drivers the visible-defaults list doesn't cover | ||
| 723 | // (e.g. Cursor when the server hasn't reported it but the user has | ||
| 724 | // authored a Cursor instance anyway, or fork drivers not shipped by | ||
| 725 | // this build). Preserve insertion order within each driver. | ||
| 726 | for (const [driver, list] of instancesByDriver) { | ||
| 727 | if (visibleDriverKinds.has(driver)) continue; | ||
| 728 | for (const [id, instance] of list) { | ||
| 729 | const isDefaultSlot = defaultSlotIdsBySource.has(String(id)); | ||
| 730 | rows.push({ | ||
| 731 | instanceId: id, | ||
| 732 | instance, | ||
| 733 | driver: instance.driver, | ||
| 734 | isDefault: isDefaultSlot, | ||
| 735 | }); | ||
| 736 | } | ||
| 737 | } | ||
| 738 | |||
| 739 | const updateProviderInstance = ( | ||
| 740 | row: InstanceRow, | ||
| 741 | next: ProviderInstanceConfig, | ||
| 742 | options?: { | ||
| 743 | readonly textGenerationModelSelection?: Parameters< | ||
| 744 | typeof buildProviderInstanceUpdatePatch | ||
| 745 | >[0]["textGenerationModelSelection"]; | ||
| 746 | }, | ||
| 747 | ) => { | ||
| 748 | updateSettings( | ||
| 749 | buildProviderInstanceUpdatePatch({ | ||
| 750 | settings, | ||
| 751 | instanceId: row.instanceId, | ||
| 752 | instance: next, | ||
| 753 | driver: row.driver, | ||
| 754 | isDefault: row.isDefault, | ||
| 755 | textGenerationModelSelection: options?.textGenerationModelSelection, | ||
| 756 | }), | ||
| 757 | ); | ||
| 758 | }; | ||
| 759 | |||
| 760 | const deleteProviderInstance = (id: ProviderInstanceId) => { | ||
| 761 | updateSettings({ | ||
| 762 | providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), | ||
| 763 | providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), | ||
| 764 | favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), | ||
| 765 | }); | ||
| 766 | }; | ||
| 767 | |||
| 768 | const updateProviderModelPreferences = ( | ||
| 769 | instanceId: ProviderInstanceId, | ||
| 770 | next: { | ||
| 771 | readonly hiddenModels: ReadonlyArray<string>; | ||
| 772 | readonly modelOrder: ReadonlyArray<string>; | ||
| 773 | }, | ||
| 774 | ) => { | ||
| 775 | const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; | ||
| 776 | const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; | ||
| 777 | const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); | ||
| 778 | updateSettings({ | ||
| 779 | providerModelPreferences: | ||
| 780 | hiddenModels.length === 0 && modelOrder.length === 0 | ||
| 781 | ? rest | ||
| 782 | : { | ||
| 783 | ...rest, | ||
| 784 | [instanceId]: { | ||
| 785 | hiddenModels, | ||
| 786 | modelOrder, | ||
| 787 | }, | ||
| 788 | }, | ||
| 789 | }); | ||
| 790 | }; | ||
| 791 | |||
| 792 | const updateProviderFavoriteModels = ( | ||
| 793 | instanceId: ProviderInstanceId, | ||
| 794 | nextFavoriteModels: ReadonlyArray<string>, | ||
| 795 | ) => { | ||
| 796 | const favoriteModels = [ | ||
| 797 | ...new Set(nextFavoriteModels.map((slug) => slug.trim()).filter((slug) => slug.length > 0)), | ||
| 798 | ]; | ||
| 799 | updateSettings({ | ||
| 800 | favorites: [ | ||
| 801 | ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), | ||
| 802 | ...favoriteModels.map((model) => ({ provider: instanceId, model })), | ||
| 803 | ], | ||
| 804 | }); | ||
| 805 | }; | ||
| 806 | |||
| 807 | /** | ||
| 808 | * Reset a built-in default slot back to factory defaults. Clears both | ||
| 809 | * the legacy `settings.providers[kind]` struct and any explicit | ||
| 810 | * `providerInstances[defaultId]` entry that has promoted legacy into | ||
| 811 | * the new map, so hydration re-synthesizes a clean envelope on next | ||
| 812 | * load. Safe to call on drivers that have never been edited. | ||
| 813 | */ | ||
| 814 | const resetDefaultInstance = (driverKind: ProviderDriverKind) => { | ||
| 815 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | ||
| 816 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | ||
| 817 | string, | ||
| 818 | LegacyProviderSettings | undefined | ||
| 819 | >; | ||
| 820 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | ||
| 821 | const defaultLegacyProvider = defaultLegacyProviders[driverKind]; | ||
| 822 | if (defaultLegacyProvider === undefined) return; | ||
| 823 | updateSettings({ | ||
| 824 | providers: { | ||
| 825 | ...settings.providers, | ||
| 826 | [driverKind]: defaultLegacyProvider, | ||
| 827 | } as typeof settings.providers, | ||
| 828 | providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), | ||
| 829 | providerModelPreferences: withoutProviderInstanceKey( | ||
| 830 | settings.providerModelPreferences, | ||
| 831 | defaultInstanceId, | ||
| 832 | ), | ||
| 833 | favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), | ||
| 834 | }); | ||
| 835 | }; | ||
| 836 | |||
| 823 | return ( | 837 | return ( |
| 824 | <SettingsPageContainer> | 838 | <SettingsPageContainer> |
| 825 | <SettingsSection title="General"> | 839 | <SettingsSection title="General"> |
| ... | @@ -1033,10 +1047,10 @@ export function GeneralSettingsPanel() { | ... | @@ -1033,10 +1047,10 @@ export function GeneralSettingsPanel() { |
| 1033 | ) : null | 1047 | ) : null |
| 1034 | } | 1048 | } |
| 1035 | control={ | 1049 | control={ |
| 1036 | <Input | 1050 | <DraftInput |
| 1037 | className="w-full sm:w-72" | 1051 | className="w-full sm:w-72" |
| 1038 | value={settings.addProjectBaseDirectory} | 1052 | value={settings.addProjectBaseDirectory} |
| 1039 | onChange={(event) => updateSettings({ addProjectBaseDirectory: event.target.value })} | 1053 | onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })} |
| 1040 | placeholder="~/" | 1054 | placeholder="~/" |
| 1041 | spellCheck={false} | 1055 | spellCheck={false} |
| 1042 | aria-label="Add project base directory" | 1056 | aria-label="Add project base directory" |
| ... | @@ -1115,19 +1129,19 @@ export function GeneralSettingsPanel() { | ... | @@ -1115,19 +1129,19 @@ export function GeneralSettingsPanel() { |
| 1115 | control={ | 1129 | control={ |
| 1116 | <div className="flex flex-wrap items-center justify-end gap-1.5"> | 1130 | <div className="flex flex-wrap items-center justify-end gap-1.5"> |
| 1117 | <ProviderModelPicker | 1131 | <ProviderModelPicker |
| 1118 | provider={textGenProvider} | 1132 | activeInstanceId={textGenInstanceId} |
| 1119 | model={textGenModel} | 1133 | model={textGenModel} |
| 1120 | lockedProvider={null} | 1134 | lockedProvider={null} |
| 1121 | providers={serverProviders} | 1135 | instanceEntries={gitModelInstanceEntries} |
| 1122 | modelOptionsByProvider={gitModelOptionsByProvider} | 1136 | modelOptionsByInstance={gitModelOptionsByInstance} |
| 1123 | triggerVariant="outline" | 1137 | triggerVariant="outline" |
| 1124 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" | 1138 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 1125 | onProviderModelChange={(provider, model) => { | 1139 | onInstanceModelChange={(instanceId, model) => { |
| 1126 | updateSettings({ | 1140 | updateSettings({ |
| 1127 | textGenerationModelSelection: resolveAppModelSelectionState( | 1141 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 1128 | { | 1142 | { |
| 1129 | ...settings, | 1143 | ...settings, |
| 1130 | textGenerationModelSelection: createModelSelection(provider, model), | 1144 | textGenerationModelSelection: createModelSelection(instanceId, model), |
| 1131 | }, | 1145 | }, |
| 1132 | serverProviders, | 1146 | serverProviders, |
| 1133 | ), | 1147 | ), |
| ... | @@ -1137,8 +1151,11 @@ export function GeneralSettingsPanel() { | ... | @@ -1137,8 +1151,11 @@ export function GeneralSettingsPanel() { |
| 1137 | <TraitsPicker | 1151 | <TraitsPicker |
| 1138 | provider={textGenProvider} | 1152 | provider={textGenProvider} |
| 1139 | models={ | 1153 | models={ |
| 1140 | serverProviders.find((provider) => provider.provider === textGenProvider) | 1154 | // Use the exact instance's models (rather than the |
| 1141 | ?.models ?? [] | 1155 | // first-kind-match) so a custom text-gen instance like |
| 1156 | // `codex_personal` gets its own model list, not the | ||
| 1157 | // default Codex one. | ||
| 1158 | textGenInstanceEntry?.models ?? [] | ||
| 1142 | } | 1159 | } |
| 1143 | model={textGenModel} | 1160 | model={textGenModel} |
| 1144 | prompt="" | 1161 | prompt="" |
| ... | @@ -1153,7 +1170,7 @@ export function GeneralSettingsPanel() { | ... | @@ -1153,7 +1170,7 @@ export function GeneralSettingsPanel() { |
| 1153 | { | 1170 | { |
| 1154 | ...settings, | 1171 | ...settings, |
| 1155 | textGenerationModelSelection: createModelSelection( | 1172 | textGenerationModelSelection: createModelSelection( |
| 1156 | textGenProvider, | 1173 | textGenInstanceId, |
| 1157 | textGenModel, | 1174 | textGenModel, |
| 1158 | nextOptions, | 1175 | nextOptions, |
| 1159 | ), | 1176 | ), |
| ... | @@ -1173,6 +1190,22 @@ export function GeneralSettingsPanel() { | ... | @@ -1173,6 +1190,22 @@ export function GeneralSettingsPanel() { |
| 1173 | headerAction={ | 1190 | headerAction={ |
| 1174 | <div className="flex items-center gap-1.5"> | 1191 | <div className="flex items-center gap-1.5"> |
| 1175 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | 1192 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> |
| 1193 | <Tooltip> | ||
| 1194 | <TooltipTrigger | ||
| 1195 | render={ | ||
| 1196 | <Button | ||
| 1197 | size="icon-xs" | ||
| 1198 | variant="ghost" | ||
| 1199 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 1200 | onClick={() => setIsAddInstanceDialogOpen(true)} | ||
| 1201 | aria-label="Add provider instance" | ||
| 1202 | > | ||
| 1203 | <PlusIcon className="size-3" /> | ||
| 1204 | </Button> | ||
| 1205 | } | ||
| 1206 | /> | ||
| 1207 | <TooltipPopup side="top">Add provider instance</TooltipPopup> | ||
| 1208 | </Tooltip> | ||
| 1176 | <Tooltip> | 1209 | <Tooltip> |
| 1177 | <TooltipTrigger | 1210 | <TooltipTrigger |
| 1178 | render={ | 1211 | render={ |
| ... | @@ -1197,447 +1230,90 @@ export function GeneralSettingsPanel() { | ... | @@ -1197,447 +1230,90 @@ export function GeneralSettingsPanel() { |
| 1197 | </div> | 1230 | </div> |
| 1198 | } | 1231 | } |
| 1199 | > | 1232 | > |
| 1200 | {providerCards.map((providerCard) => { | 1233 | {rows.map((row) => { |
| 1201 | const customModelInput = customModelInputByProvider[providerCard.provider]; | 1234 | const driverOption = getDriverOption(row.driver); |
| 1202 | const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; | 1235 | const liveProvider = serverProviders.find( |
| 1203 | const providerDisplayName = | 1236 | (candidate) => candidate.instanceId === row.instanceId, |
| 1204 | providerCard.liveProvider?.displayName?.trim() || | 1237 | ); |
| 1205 | providerCard.title || | 1238 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { |
| 1206 | formatProviderKindLabel(providerCard.provider); | 1239 | hiddenModels: [], |
| 1207 | 1240 | modelOrder: [], | |
| 1241 | }; | ||
| 1242 | const favoriteModels = (settings.favorites ?? []) | ||
| 1243 | .filter((favorite) => favorite.provider === row.instanceId) | ||
| 1244 | .map((favorite) => favorite.model); | ||
| 1245 | const resetLabel = driverOption?.label ?? String(row.driver); | ||
| 1246 | const headerAction = | ||
| 1247 | row.isDefault && row.isDirty ? ( | ||
| 1248 | <SettingResetButton | ||
| 1249 | label={`${resetLabel} provider settings`} | ||
| 1250 | onClick={() => resetDefaultInstance(row.driver)} | ||
| 1251 | /> | ||
| 1252 | ) : null; | ||
| 1208 | return ( | 1253 | return ( |
| 1209 | <div key={providerCard.provider} className="border-t border-border first:border-t-0"> | 1254 | <ProviderInstanceCard |
| 1210 | <div className="px-4 py-4 sm:px-5"> | 1255 | key={row.instanceId} |
| 1211 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | 1256 | instanceId={row.instanceId} |
| 1212 | <div className="min-w-0 flex-1 space-y-1"> | 1257 | instance={row.instance} |
| 1213 | <div className="flex min-h-5 items-center gap-1.5"> | 1258 | driverOption={driverOption} |
| 1214 | <span | 1259 | liveProvider={liveProvider} |
| 1215 | className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)} | 1260 | isExpanded={openInstanceDetails[row.instanceId] ?? false} |
| 1216 | /> | 1261 | onExpandedChange={(open) => |
| 1217 | <h3 className="text-sm font-medium text-foreground">{providerDisplayName}</h3> | 1262 | setOpenInstanceDetails((existing) => ({ |
| 1218 | {providerCard.badgeLabel ? ( | 1263 | ...existing, |
| 1219 | <Badge variant="warning" size="sm" className="shrink-0"> | 1264 | [row.instanceId]: open, |
| 1220 | {providerCard.badgeLabel} | 1265 | })) |
| 1221 | </Badge> | 1266 | } |
| 1222 | ) : null} | 1267 | onUpdate={(next) => { |
| 1223 | {providerCard.versionLabel ? ( | 1268 | // When the user disables the exact instance the text-gen |
| 1224 | <code className="text-xs text-muted-foreground"> | 1269 | // selection points at, fall back to the global default so we |
| 1225 | {providerCard.versionLabel} | 1270 | // don't leave the selection dangling on a disabled instance. |
| 1226 | </code> | 1271 | // Prior kind-level behavior cleared on any kind-matching |
| 1227 | ) : null} | 1272 | // disable; instance-level addressing makes this narrower and |
| 1228 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | 1273 | // more accurate (other instances of the same kind stay |
| 1229 | {providerCard.isDirty ? ( | 1274 | // untouched). |
| 1230 | <SettingResetButton | 1275 | const wasEnabled = row.instance.enabled ?? true; |
| 1231 | label={`${providerDisplayName} provider settings`} | 1276 | const isDisabling = next.enabled === false && wasEnabled; |
| 1232 | onClick={() => { | 1277 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; |
| 1233 | updateSettings({ | 1278 | if (shouldClearTextGen) { |
| 1234 | providers: { | 1279 | updateProviderInstance(row, next, { |
| 1235 | ...settings.providers, | 1280 | textGenerationModelSelection: |
| 1236 | [providerCard.provider]: | 1281 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, |
| 1237 | DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], | 1282 | }); |
| 1238 | }, | 1283 | } else { |
| 1239 | }); | 1284 | updateProviderInstance(row, next); |
| 1240 | setCustomModelErrorByProvider((existing) => ({ | ||
| 1241 | ...existing, | ||
| 1242 | [providerCard.provider]: null, | ||
| 1243 | })); | ||
| 1244 | }} | ||
| 1245 | /> | ||
| 1246 | ) : null} | ||
| 1247 | </span> | ||
| 1248 | </div> | ||
| 1249 | <p className="text-xs text-muted-foreground"> | ||
| 1250 | {providerCard.summary.headline} | ||
| 1251 | {providerCard.summary.detail ? ` - ${providerCard.summary.detail}` : null} | ||
| 1252 | </p> | ||
| 1253 | </div> | ||
| 1254 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | ||
| 1255 | <Button | ||
| 1256 | size="sm" | ||
| 1257 | variant="ghost" | ||
| 1258 | className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" | ||
| 1259 | onClick={() => | ||
| 1260 | setOpenProviderDetails((existing) => ({ | ||
| 1261 | ...existing, | ||
| 1262 | [providerCard.provider]: !existing[providerCard.provider], | ||
| 1263 | })) | ||
| 1264 | } | ||
| 1265 | aria-label={`Toggle ${providerDisplayName} details`} | ||
| 1266 | > | ||
| 1267 | <ChevronDownIcon | ||
| 1268 | className={cn( | ||
| 1269 | "size-3.5 transition-transform", | ||
| 1270 | openProviderDetails[providerCard.provider] && "rotate-180", | ||
| 1271 | )} | ||
| 1272 | /> | ||
| 1273 | </Button> | ||
| 1274 | <Switch | ||
| 1275 | checked={providerCard.providerConfig.enabled} | ||
| 1276 | onCheckedChange={(checked) => { | ||
| 1277 | const isDisabling = !checked; | ||
| 1278 | const shouldClearModelSelection = | ||
| 1279 | isDisabling && textGenProvider === providerCard.provider; | ||
| 1280 | updateSettings({ | ||
| 1281 | providers: { | ||
| 1282 | ...settings.providers, | ||
| 1283 | [providerCard.provider]: { | ||
| 1284 | ...settings.providers[providerCard.provider], | ||
| 1285 | enabled: Boolean(checked), | ||
| 1286 | }, | ||
| 1287 | }, | ||
| 1288 | ...(shouldClearModelSelection | ||
| 1289 | ? { | ||
| 1290 | textGenerationModelSelection: | ||
| 1291 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | ||
| 1292 | } | ||
| 1293 | : {}), | ||
| 1294 | }); | ||
| 1295 | }} | ||
| 1296 | aria-label={`Enable ${providerDisplayName}`} | ||
| 1297 | /> | ||
| 1298 | </div> | ||
| 1299 | </div> | ||
| 1300 | </div> | ||
| 1301 | |||
| 1302 | <Collapsible | ||
| 1303 | open={openProviderDetails[providerCard.provider]} | ||
| 1304 | onOpenChange={(open) => | ||
| 1305 | setOpenProviderDetails((existing) => ({ | ||
| 1306 | ...existing, | ||
| 1307 | [providerCard.provider]: open, | ||
| 1308 | })) | ||
| 1309 | } | 1285 | } |
| 1310 | > | 1286 | }} |
| 1311 | <CollapsibleContent> | 1287 | onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} |
| 1312 | <div className="space-y-0"> | 1288 | headerAction={headerAction} |
| 1313 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | 1289 | hiddenModels={modelPreferences.hiddenModels} |
| 1314 | <label | 1290 | favoriteModels={favoriteModels} |
| 1315 | htmlFor={`provider-install-${providerCard.provider}-binary-path`} | 1291 | modelOrder={modelPreferences.modelOrder} |
| 1316 | className="block" | 1292 | onHiddenModelsChange={(hiddenModels) => |
| 1317 | > | 1293 | updateProviderModelPreferences(row.instanceId, { |
| 1318 | <span className="text-xs font-medium text-foreground"> | 1294 | ...modelPreferences, |
| 1319 | {providerDisplayName} binary path | 1295 | hiddenModels, |
| 1320 | </span> | 1296 | }) |
| 1321 | <Input | 1297 | } |
| 1322 | id={`provider-install-${providerCard.provider}-binary-path`} | 1298 | onFavoriteModelsChange={(favoriteModels) => |
| 1323 | className="mt-1.5" | 1299 | updateProviderFavoriteModels(row.instanceId, favoriteModels) |
| 1324 | value={providerCard.binaryPathValue} | 1300 | } |
| 1325 | onChange={(event) => | 1301 | onModelOrderChange={(modelOrder) => |
| 1326 | updateSettings({ | 1302 | updateProviderModelPreferences(row.instanceId, { |
| 1327 | providers: { | 1303 | ...modelPreferences, |
| 1328 | ...settings.providers, | 1304 | modelOrder, |
| 1329 | [providerCard.provider]: { | 1305 | }) |
| 1330 | ...settings.providers[providerCard.provider], | 1306 | } |
| 1331 | binaryPath: event.target.value, | 1307 | /> |
| 1332 | }, | ||
| 1333 | }, | ||
| 1334 | }) | ||
| 1335 | } | ||
| 1336 | placeholder={providerCard.binaryPlaceholder} | ||
| 1337 | spellCheck={false} | ||
| 1338 | /> | ||
| 1339 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 1340 | {providerCard.binaryDescription} | ||
| 1341 | </span> | ||
| 1342 | </label> | ||
| 1343 | </div> | ||
| 1344 | |||
| 1345 | {providerCard.serverUrlPlaceholder ? ( | ||
| 1346 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 1347 | <label | ||
| 1348 | htmlFor={`provider-install-${providerCard.provider}-server-url`} | ||
| 1349 | className="block" | ||
| 1350 | > | ||
| 1351 | <span className="text-xs font-medium text-foreground"> | ||
| 1352 | {providerDisplayName} server URL | ||
| 1353 | </span> | ||
| 1354 | <Input | ||
| 1355 | id={`provider-install-${providerCard.provider}-server-url`} | ||
| 1356 | className="mt-1.5" | ||
| 1357 | value={providerCard.serverUrlValue} | ||
| 1358 | onChange={(event) => | ||
| 1359 | updateSettings({ | ||
| 1360 | providers: { | ||
| 1361 | ...settings.providers, | ||
| 1362 | [providerCard.provider]: { | ||
| 1363 | ...settings.providers[providerCard.provider], | ||
| 1364 | ...(providerCard.provider === "opencode" | ||
| 1365 | ? { serverUrl: event.target.value } | ||
| 1366 | : {}), | ||
| 1367 | }, | ||
| 1368 | }, | ||
| 1369 | }) | ||
| 1370 | } | ||
| 1371 | placeholder={providerCard.serverUrlPlaceholder} | ||
| 1372 | spellCheck={false} | ||
| 1373 | /> | ||
| 1374 | {providerCard.serverUrlDescription ? ( | ||
| 1375 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 1376 | {providerCard.serverUrlDescription} | ||
| 1377 | </span> | ||
| 1378 | ) : null} | ||
| 1379 | </label> | ||
| 1380 | </div> | ||
| 1381 | ) : null} | ||
| 1382 | |||
| 1383 | {providerCard.serverPasswordPlaceholder ? ( | ||
| 1384 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 1385 | <label | ||
| 1386 | htmlFor={`provider-install-${providerCard.provider}-server-password`} | ||
| 1387 | className="block" | ||
| 1388 | > | ||
| 1389 | <span className="text-xs font-medium text-foreground"> | ||
| 1390 | {providerDisplayName} server password | ||
| 1391 | </span> | ||
| 1392 | <Input | ||
| 1393 | id={`provider-install-${providerCard.provider}-server-password`} | ||
| 1394 | className="mt-1.5" | ||
| 1395 | type="password" | ||
| 1396 | autoComplete="off" | ||
| 1397 | value={providerCard.serverPasswordValue} | ||
| 1398 | onChange={(event) => | ||
| 1399 | updateSettings({ | ||
| 1400 | providers: { | ||
| 1401 | ...settings.providers, | ||
| 1402 | [providerCard.provider]: { | ||
| 1403 | ...settings.providers[providerCard.provider], | ||
| 1404 | ...(providerCard.provider === "opencode" | ||
| 1405 | ? { serverPassword: event.target.value } | ||
| 1406 | : {}), | ||
| 1407 | }, | ||
| 1408 | }, | ||
| 1409 | }) | ||
| 1410 | } | ||
| 1411 | placeholder={providerCard.serverPasswordPlaceholder} | ||
| 1412 | spellCheck={false} | ||
| 1413 | /> | ||
| 1414 | {providerCard.serverPasswordDescription ? ( | ||
| 1415 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 1416 | {providerCard.serverPasswordDescription} | ||
| 1417 | </span> | ||
| 1418 | ) : null} | ||
| 1419 | </label> | ||
| 1420 | </div> | ||
| 1421 | ) : null} | ||
| 1422 | |||
| 1423 | {providerCard.homePathKey ? ( | ||
| 1424 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 1425 | <label | ||
| 1426 | htmlFor={`provider-install-${providerCard.homePathKey}`} | ||
| 1427 | className="block" | ||
| 1428 | > | ||
| 1429 | <span className="text-xs font-medium text-foreground"> | ||
| 1430 | CODEX_HOME path | ||
| 1431 | </span> | ||
| 1432 | <Input | ||
| 1433 | id={`provider-install-${providerCard.homePathKey}`} | ||
| 1434 | className="mt-1.5" | ||
| 1435 | value={codexHomePath} | ||
| 1436 | onChange={(event) => | ||
| 1437 | updateSettings({ | ||
| 1438 | providers: { | ||
| 1439 | ...settings.providers, | ||
| 1440 | codex: { | ||
| 1441 | ...settings.providers.codex, | ||
| 1442 | homePath: event.target.value, | ||
| 1443 | }, | ||
| 1444 | }, | ||
| 1445 | }) | ||
| 1446 | } | ||
| 1447 | placeholder={providerCard.homePlaceholder} | ||
| 1448 | spellCheck={false} | ||
| 1449 | /> | ||
| 1450 | {providerCard.homeDescription ? ( | ||
| 1451 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 1452 | {providerCard.homeDescription} | ||
| 1453 | </span> | ||
| 1454 | ) : null} | ||
| 1455 | </label> | ||
| 1456 | </div> | ||
| 1457 | ) : null} | ||
| 1458 | |||
| 1459 | {providerCard.provider === "claudeAgent" ? ( | ||
| 1460 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 1461 | <label htmlFor="provider-install-claudeAgent-launch-args" className="block"> | ||
| 1462 | <span className="text-xs font-medium text-foreground"> | ||
| 1463 | Launch arguments | ||
| 1464 | </span> | ||
| 1465 | <Input | ||
| 1466 | id="provider-install-claudeAgent-launch-args" | ||
| 1467 | className="mt-1.5" | ||
| 1468 | value={settings.providers.claudeAgent.launchArgs} | ||
| 1469 | onChange={(event) => | ||
| 1470 | updateSettings({ | ||
| 1471 | providers: { | ||
| 1472 | ...settings.providers, | ||
| 1473 | claudeAgent: { | ||
| 1474 | ...settings.providers.claudeAgent, | ||
| 1475 | launchArgs: event.target.value, | ||
| 1476 | }, | ||
| 1477 | }, | ||
| 1478 | }) | ||
| 1479 | } | ||
| 1480 | placeholder="e.g. --chrome" | ||
| 1481 | spellCheck={false} | ||
| 1482 | /> | ||
| 1483 | <span className="mt-1 block text-xs text-muted-foreground"> | ||
| 1484 | Additional CLI arguments passed to Claude Code on session start. | ||
| 1485 | </span> | ||
| 1486 | </label> | ||
| 1487 | </div> | ||
| 1488 | ) : null} | ||
| 1489 | |||
| 1490 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | ||
| 1491 | <div className="text-xs font-medium text-foreground">Models</div> | ||
| 1492 | <div className="mt-1 text-xs text-muted-foreground"> | ||
| 1493 | {providerCard.models.length} model | ||
| 1494 | {providerCard.models.length === 1 ? "" : "s"} available. | ||
| 1495 | </div> | ||
| 1496 | <div | ||
| 1497 | ref={(el) => { | ||
| 1498 | modelListRefs.current[providerCard.provider] = el; | ||
| 1499 | }} | ||
| 1500 | className="mt-2 max-h-40 overflow-y-auto pb-1" | ||
| 1501 | > | ||
| 1502 | {providerCard.models.map((model) => { | ||
| 1503 | const caps = model.capabilities; | ||
| 1504 | const capLabels: string[] = []; | ||
| 1505 | const descriptors = caps?.optionDescriptors ?? []; | ||
| 1506 | if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { | ||
| 1507 | capLabels.push("Fast mode"); | ||
| 1508 | } | ||
| 1509 | if (descriptors.some((descriptor) => descriptor.id === "thinking")) { | ||
| 1510 | capLabels.push("Thinking"); | ||
| 1511 | } | ||
| 1512 | if ( | ||
| 1513 | descriptors.some( | ||
| 1514 | (descriptor) => | ||
| 1515 | descriptor.type === "select" && | ||
| 1516 | (descriptor.id === "reasoningEffort" || | ||
| 1517 | descriptor.id === "effort" || | ||
| 1518 | descriptor.id === "reasoning" || | ||
| 1519 | descriptor.id === "variant"), | ||
| 1520 | ) | ||
| 1521 | ) { | ||
| 1522 | capLabels.push("Reasoning"); | ||
| 1523 | } | ||
| 1524 | const hasDetails = capLabels.length > 0 || model.name !== model.slug; | ||
| 1525 | |||
| 1526 | return ( | ||
| 1527 | <div | ||
| 1528 | key={`${providerCard.provider}:${model.slug}`} | ||
| 1529 | className="flex items-center gap-2 py-1" | ||
| 1530 | > | ||
| 1531 | <span className="min-w-0 truncate text-xs text-foreground/90"> | ||
| 1532 | {model.name} | ||
| 1533 | </span> | ||
| 1534 | {hasDetails ? ( | ||
| 1535 | <Tooltip> | ||
| 1536 | <TooltipTrigger | ||
| 1537 | render={ | ||
| 1538 | <button | ||
| 1539 | type="button" | ||
| 1540 | className="shrink-0 text-muted-foreground/40 transition-colors hover:text-muted-foreground" | ||
| 1541 | aria-label={`Details for ${model.name}`} | ||
| 1542 | /> | ||
| 1543 | } | ||
| 1544 | > | ||
| 1545 | <InfoIcon className="size-3" /> | ||
| 1546 | </TooltipTrigger> | ||
| 1547 | <TooltipPopup side="top" className="max-w-56"> | ||
| 1548 | <div className="space-y-1"> | ||
| 1549 | <code className="block text-[11px] text-foreground"> | ||
| 1550 | {model.slug} | ||
| 1551 | </code> | ||
| 1552 | {capLabels.length > 0 ? ( | ||
| 1553 | <div className="flex flex-wrap gap-x-2 gap-y-0.5"> | ||
| 1554 | {capLabels.map((label) => ( | ||
| 1555 | <span | ||
| 1556 | key={label} | ||
| 1557 | className="text-[10px] text-muted-foreground" | ||
| 1558 | > | ||
| 1559 | {label} | ||
| 1560 | </span> | ||
| 1561 | ))} | ||
| 1562 | </div> | ||
| 1563 | ) : null} | ||
| 1564 | </div> | ||
| 1565 | </TooltipPopup> | ||
| 1566 | </Tooltip> | ||
| 1567 | ) : null} | ||
| 1568 | {model.isCustom ? ( | ||
| 1569 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> | ||
| 1570 | <span className="text-[10px] text-muted-foreground">custom</span> | ||
| 1571 | <button | ||
| 1572 | type="button" | ||
| 1573 | className="text-muted-foreground transition-colors hover:text-foreground" | ||
| 1574 | aria-label={`Remove ${model.slug}`} | ||
| 1575 | onClick={() => | ||
| 1576 | removeCustomModel(providerCard.provider, model.slug) | ||
| 1577 | } | ||
| 1578 | > | ||
| 1579 | <XIcon className="size-3" /> | ||
| 1580 | </button> | ||
| 1581 | </div> | ||
| 1582 | ) : null} | ||
| 1583 | </div> | ||
| 1584 | ); | ||
| 1585 | })} | ||
| 1586 | </div> | ||
| 1587 | |||
| 1588 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> | ||
| 1589 | <Input | ||
| 1590 | id={`custom-model-${providerCard.provider}`} | ||
| 1591 | value={customModelInput} | ||
| 1592 | onChange={(event) => { | ||
| 1593 | const value = event.target.value; | ||
| 1594 | setCustomModelInputByProvider((existing) => ({ | ||
| 1595 | ...existing, | ||
| 1596 | [providerCard.provider]: value, | ||
| 1597 | })); | ||
| 1598 | if (customModelError) { | ||
| 1599 | setCustomModelErrorByProvider((existing) => ({ | ||
| 1600 | ...existing, | ||
| 1601 | [providerCard.provider]: null, | ||
| 1602 | })); | ||
| 1603 | } | ||
| 1604 | }} | ||
| 1605 | onKeyDown={(event) => { | ||
| 1606 | if (event.key !== "Enter") return; | ||
| 1607 | event.preventDefault(); | ||
| 1608 | addCustomModel(providerCard.provider); | ||
| 1609 | }} | ||
| 1610 | placeholder={ | ||
| 1611 | providerCard.provider === "codex" | ||
| 1612 | ? "gpt-6.7-codex-ultra-preview" | ||
| 1613 | : providerCard.provider === "opencode" | ||
| 1614 | ? "openai/gpt-5" | ||
| 1615 | : "claude-sonnet-5-0" | ||
| 1616 | } | ||
| 1617 | spellCheck={false} | ||
| 1618 | /> | ||
| 1619 | <Button | ||
| 1620 | className="shrink-0" | ||
| 1621 | variant="outline" | ||
| 1622 | onClick={() => addCustomModel(providerCard.provider)} | ||
| 1623 | > | ||
| 1624 | <PlusIcon className="size-3.5" /> | ||
| 1625 | Add | ||
| 1626 | </Button> | ||
| 1627 | </div> | ||
| 1628 | |||
| 1629 | {customModelError ? ( | ||
| 1630 | <p className="mt-2 text-xs text-destructive">{customModelError}</p> | ||
| 1631 | ) : null} | ||
| 1632 | </div> | ||
| 1633 | </div> | ||
| 1634 | </CollapsibleContent> | ||
| 1635 | </Collapsible> | ||
| 1636 | </div> | ||
| 1637 | ); | 1308 | ); |
| 1638 | })} | 1309 | })} |
| 1639 | </SettingsSection> | 1310 | </SettingsSection> |
| 1640 | 1311 | ||
| 1312 | <AddProviderInstanceDialog | ||
| 1313 | open={isAddInstanceDialogOpen} | ||
| 1314 | onOpenChange={setIsAddInstanceDialogOpen} | ||
| 1315 | /> | ||
| 1316 | |||
| 1641 | <SettingsSection title="Advanced"> | 1317 | <SettingsSection title="Advanced"> |
| 1642 | <SettingsRow | 1318 | <SettingsRow |
| 1643 | title="Keybindings" | 1319 | title="Keybindings" |
apps/web/src/components/settings/providerDriverMeta.ts created+151| ... | @@ -0,0 +1,151 @@ | ||
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * A single editable field exposed on a provider instance. `key` must match | ||
| 6 | * the corresponding driver config schema in | ||
| 7 | * `packages/contracts/src/settings.ts` — values are merged into the saved | ||
| 8 | * `ProviderInstanceConfig.config` blob (or the legacy | ||
| 9 | * `ServerSettings.providers[kind]` struct) under this key verbatim. | ||
| 10 | */ | ||
| 11 | export interface DriverFieldDef { | ||
| 12 | readonly key: string; | ||
| 13 | readonly label: string; | ||
| 14 | readonly placeholder?: string; | ||
| 15 | readonly description?: string; | ||
| 16 | readonly type?: "text" | "password"; | ||
| 17 | } | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Presentation + editable-field metadata for a registered driver. Shared | ||
| 21 | * between the Add-Instance dialog and the per-instance settings card so | ||
| 22 | * both surfaces offer the same keys for the same driver. | ||
| 23 | */ | ||
| 24 | export interface DriverOption { | ||
| 25 | readonly value: ProviderDriverKind; | ||
| 26 | readonly label: string; | ||
| 27 | readonly icon: Icon; | ||
| 28 | readonly fields: readonly DriverFieldDef[]; | ||
| 29 | /** | ||
| 30 | * Optional short label rendered as a `variant="warning"` badge next to | ||
| 31 | * the instance title. Used to flag drivers that still ship under an | ||
| 32 | * early-access or preview gate — the flag is a property of the driver | ||
| 33 | * kind (not a specific instance), so every instance of that driver — | ||
| 34 | * built-in default or custom — advertises the same marker. | ||
| 35 | */ | ||
| 36 | readonly badgeLabel?: string; | ||
| 37 | } | ||
| 38 | |||
| 39 | export const DRIVER_OPTIONS: readonly DriverOption[] = [ | ||
| 40 | { | ||
| 41 | value: ProviderDriverKind.make("codex"), | ||
| 42 | label: "Codex", | ||
| 43 | icon: OpenAI, | ||
| 44 | fields: [ | ||
| 45 | { | ||
| 46 | key: "binaryPath", | ||
| 47 | label: "Binary path", | ||
| 48 | placeholder: "codex", | ||
| 49 | description: "Path to the Codex binary used by this instance.", | ||
| 50 | }, | ||
| 51 | { | ||
| 52 | key: "homePath", | ||
| 53 | label: "CODEX_HOME path", | ||
| 54 | placeholder: "~/.codex", | ||
| 55 | description: "Custom Codex home and config directory.", | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | key: "shadowHomePath", | ||
| 59 | label: "Shadow home path", | ||
| 60 | placeholder: "~/.codex-t3/personal", | ||
| 61 | description: | ||
| 62 | "Account-specific Codex home. Keeps auth.json separate while sharing state from CODEX_HOME.", | ||
| 63 | }, | ||
| 64 | ], | ||
| 65 | }, | ||
| 66 | { | ||
| 67 | value: ProviderDriverKind.make("claudeAgent"), | ||
| 68 | label: "Claude", | ||
| 69 | icon: ClaudeAI, | ||
| 70 | fields: [ | ||
| 71 | { | ||
| 72 | key: "binaryPath", | ||
| 73 | label: "Binary path", | ||
| 74 | placeholder: "claude", | ||
| 75 | description: "Path to the Claude binary used by this instance.", | ||
| 76 | }, | ||
| 77 | { | ||
| 78 | key: "homePath", | ||
| 79 | label: "Claude HOME path", | ||
| 80 | placeholder: "~", | ||
| 81 | description: | ||
| 82 | "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.", | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | key: "launchArgs", | ||
| 86 | label: "Launch arguments", | ||
| 87 | placeholder: "e.g. --chrome", | ||
| 88 | description: "Additional CLI arguments passed on session start.", | ||
| 89 | }, | ||
| 90 | ], | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | value: ProviderDriverKind.make("cursor"), | ||
| 94 | label: "Cursor", | ||
| 95 | icon: CursorIcon, | ||
| 96 | badgeLabel: "Early Access", | ||
| 97 | fields: [ | ||
| 98 | { | ||
| 99 | key: "binaryPath", | ||
| 100 | label: "Binary path", | ||
| 101 | placeholder: "agent", | ||
| 102 | description: "Path to the Cursor agent binary.", | ||
| 103 | }, | ||
| 104 | { | ||
| 105 | key: "apiEndpoint", | ||
| 106 | label: "API endpoint", | ||
| 107 | placeholder: "https://…", | ||
| 108 | description: "Override the Cursor API endpoint for this instance.", | ||
| 109 | }, | ||
| 110 | ], | ||
| 111 | }, | ||
| 112 | { | ||
| 113 | value: ProviderDriverKind.make("opencode"), | ||
| 114 | label: "OpenCode", | ||
| 115 | icon: OpenCodeIcon, | ||
| 116 | fields: [ | ||
| 117 | { | ||
| 118 | key: "binaryPath", | ||
| 119 | label: "Binary path", | ||
| 120 | placeholder: "opencode", | ||
| 121 | description: "Path to the OpenCode binary.", | ||
| 122 | }, | ||
| 123 | { | ||
| 124 | key: "serverUrl", | ||
| 125 | label: "Server URL", | ||
| 126 | placeholder: "http://127.0.0.1:4096", | ||
| 127 | description: "Leave blank to let T3 Code spawn the server when needed.", | ||
| 128 | }, | ||
| 129 | { | ||
| 130 | key: "serverPassword", | ||
| 131 | label: "Server password", | ||
| 132 | placeholder: "Optional", | ||
| 133 | type: "password", | ||
| 134 | description: "Stored in plain text on disk.", | ||
| 135 | }, | ||
| 136 | ], | ||
| 137 | }, | ||
| 138 | ]; | ||
| 139 | |||
| 140 | export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOption>> = | ||
| 141 | Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option])); | ||
| 142 | |||
| 143 | /** | ||
| 144 | * Look up the driver metadata for an instance's `driver` field. Accepts | ||
| 145 | * Returns `undefined` for fork / unknown drivers so callers can decide how | ||
| 146 | * to render them — typically by falling back to a generic card. | ||
| 147 | */ | ||
| 148 | export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined { | ||
| 149 | if (driver === undefined) return undefined; | ||
| 150 | return DRIVER_OPTION_BY_VALUE[driver]; | ||
| 151 | } | ||
apps/web/src/components/settings/providerStatus.ts created+91| ... | @@ -0,0 +1,91 @@ | ||
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Visual treatment for each server-reported provider status. Centralized so | ||
| 5 | * the default-driver card and per-instance cards share the same language. | ||
| 6 | */ | ||
| 7 | export const PROVIDER_STATUS_STYLES = { | ||
| 8 | disabled: { | ||
| 9 | dot: "bg-amber-400", | ||
| 10 | }, | ||
| 11 | error: { | ||
| 12 | dot: "bg-destructive", | ||
| 13 | }, | ||
| 14 | ready: { | ||
| 15 | dot: "bg-success", | ||
| 16 | }, | ||
| 17 | warning: { | ||
| 18 | dot: "bg-warning", | ||
| 19 | }, | ||
| 20 | } as const; | ||
| 21 | |||
| 22 | export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; | ||
| 23 | |||
| 24 | /** | ||
| 25 | * Derive the headline + detail copy shown under a provider's name in the | ||
| 26 | * settings page. Prefers `provider.message` for server-supplied detail and | ||
| 27 | * falls back to generic phrasing when the server has not yet reported any | ||
| 28 | * state — which happens before the first probe or when an instance names a | ||
| 29 | * driver this build does not ship. | ||
| 30 | */ | ||
| 31 | export function getProviderSummary(provider: ServerProvider | undefined) { | ||
| 32 | if (!provider) { | ||
| 33 | return { | ||
| 34 | headline: "Checking provider status", | ||
| 35 | detail: "Waiting for the server to report installation and authentication details.", | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | if (!provider.enabled) { | ||
| 39 | return { | ||
| 40 | headline: "Disabled", | ||
| 41 | detail: | ||
| 42 | provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | if (!provider.installed) { | ||
| 46 | return { | ||
| 47 | headline: "Not found", | ||
| 48 | detail: provider.message ?? "CLI not detected on PATH.", | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | if (provider.auth.status === "authenticated") { | ||
| 52 | const authLabel = provider.auth.label ?? provider.auth.type; | ||
| 53 | return { | ||
| 54 | headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", | ||
| 55 | detail: provider.message ?? null, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | if (provider.auth.status === "unauthenticated") { | ||
| 59 | return { | ||
| 60 | headline: "Not authenticated", | ||
| 61 | detail: provider.message ?? null, | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | if (provider.status === "warning") { | ||
| 65 | return { | ||
| 66 | headline: "Needs attention", | ||
| 67 | detail: | ||
| 68 | provider.message ?? "The provider is installed, but the server could not fully verify it.", | ||
| 69 | }; | ||
| 70 | } | ||
| 71 | if (provider.status === "error") { | ||
| 72 | return { | ||
| 73 | headline: "Unavailable", | ||
| 74 | detail: provider.message ?? "The provider failed its startup checks.", | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | return { | ||
| 78 | headline: "Available", | ||
| 79 | detail: provider.message ?? "Installed and ready, but authentication could not be verified.", | ||
| 80 | }; | ||
| 81 | } | ||
| 82 | |||
| 83 | /** | ||
| 84 | * Normalize a version string for display. Adds the `v` prefix when the | ||
| 85 | * driver reported a bare version (e.g. `1.2.3`) so cards render | ||
| 86 | * consistently regardless of driver. | ||
| 87 | */ | ||
| 88 | export function getProviderVersionLabel(version: string | null | undefined) { | ||
| 89 | if (!version) return null; | ||
| 90 | return version.startsWith("v") ? version : `v${version}`; | ||
| 91 | } | ||
apps/web/src/components/ui/draft-input.tsx created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useCommitOnBlur } from "~/hooks/useCommitOnBlur"; | ||
| 4 | import { Input, type InputProps } from "./input"; | ||
| 5 | |||
| 6 | export type DraftInputProps = Omit<InputProps, "value" | "onChange" | "defaultValue"> & { | ||
| 7 | readonly value: string; | ||
| 8 | readonly onCommit: (next: string) => void; | ||
| 9 | }; | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Text `<Input>` that buffers keystrokes locally and invokes `onCommit` | ||
| 13 | * only when the user finishes editing (blur or Enter). Prevents each | ||
| 14 | * keystroke from triggering a settings-wide re-render or a server RPC | ||
| 15 | * round-trip, which otherwise makes fields backed by a server-hydrated | ||
| 16 | * value feel laggy. | ||
| 17 | */ | ||
| 18 | export function DraftInput({ value, onCommit, ...rest }: DraftInputProps) { | ||
| 19 | const bag = useCommitOnBlur(value, onCommit); | ||
| 20 | return <Input {...rest} {...bag} />; | ||
| 21 | } | ||
apps/web/src/composerDraftStore.test.ts+148-95| ... | @@ -6,17 +6,29 @@ import { | ... | @@ -6,17 +6,29 @@ import { |
| 6 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import { | 8 | import { |
| 9 | defaultInstanceIdForDriver, | ||
| 9 | EnvironmentId, | 10 | EnvironmentId, |
| 10 | ProjectId, | 11 | ProjectId, |
| 12 | ProviderDriverKind, | ||
| 13 | ProviderInstanceId, | ||
| 11 | ThreadId, | 14 | ThreadId, |
| 12 | type ModelSelection, | 15 | type ModelSelection, |
| 13 | type ProviderKind, | ||
| 14 | type ProviderOptionSelection, | 16 | type ProviderOptionSelection, |
| 15 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 16 | import { createModelSelection } from "@t3tools/shared/model"; | 18 | import { createModelSelection } from "@t3tools/shared/model"; |
| 17 | 19 | ||
| 20 | // The composer draft's `modelSelectionByProvider` and | ||
| 21 | // `stickyModelSelectionByProvider` maps are keyed by `ProviderInstanceId` | ||
| 22 | // in production; these aliases keep the legacy-key migration tests concise. | ||
| 23 | const CODEX_INSTANCE = ProviderInstanceId.make("codex"); | ||
| 24 | const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); | ||
| 25 | const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); | ||
| 26 | const CODEX_DRIVER = ProviderDriverKind.make("codex"); | ||
| 27 | const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); | ||
| 28 | const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); | ||
| 29 | |||
| 18 | type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>; | 30 | type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>; |
| 19 | type ProviderOptionSelectionsByProvider = Partial<Record<ProviderKind, ProviderOptionSelectionBag>>; | 31 | type ProviderOptionSelectionsByProvider = Partial<Record<string, ProviderOptionSelectionBag>>; |
| 20 | 32 | ||
| 21 | function toSelections( | 33 | function toSelections( |
| 22 | options: Record<string, string | boolean | undefined> | undefined, | 34 | options: Record<string, string | boolean | undefined> | undefined, |
| ... | @@ -32,11 +44,11 @@ function toSelections( | ... | @@ -32,11 +44,11 @@ function toSelections( |
| 32 | } | 44 | } |
| 33 | 45 | ||
| 34 | function selectionsByProvider( | 46 | function selectionsByProvider( |
| 35 | options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>, | 47 | options: Partial<Record<ProviderDriverKind, Record<string, string | boolean | undefined>>>, |
| 36 | ): ProviderOptionSelectionsByProvider { | 48 | ): ProviderOptionSelectionsByProvider { |
| 37 | const result: ProviderOptionSelectionsByProvider = {}; | 49 | const result: ProviderOptionSelectionsByProvider = {}; |
| 38 | for (const [provider, bag] of Object.entries(options) as Array< | 50 | for (const [provider, bag] of Object.entries(options) as Array< |
| 39 | [ProviderKind, Record<string, string | boolean | undefined>] | 51 | [ProviderDriverKind, Record<string, string | boolean | undefined>] |
| 40 | >) { | 52 | >) { |
| 41 | result[provider] = toSelections(bag); | 53 | result[provider] = toSelections(bag); |
| 42 | } | 54 | } |
| ... | @@ -121,15 +133,15 @@ function resetComposerDraftStore() { | ... | @@ -121,15 +133,15 @@ function resetComposerDraftStore() { |
| 121 | } | 133 | } |
| 122 | 134 | ||
| 123 | function modelSelection( | 135 | function modelSelection( |
| 124 | provider: "codex" | "claudeAgent" | "cursor", | 136 | provider: ProviderDriverKind, |
| 125 | model: string, | 137 | model: string, |
| 126 | options?: Record<string, string | boolean | undefined>, | 138 | options?: Record<string, string | boolean | undefined>, |
| 127 | ): ModelSelection { | 139 | ): ModelSelection { |
| 128 | return createModelSelection(provider, model, toSelections(options)); | 140 | return createModelSelection(defaultInstanceIdForDriver(provider), model, toSelections(options)); |
| 129 | } | 141 | } |
| 130 | 142 | ||
| 131 | function providerModelOptions( | 143 | function providerModelOptions( |
| 132 | options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>, | 144 | options: Partial<Record<string, Record<string, string | boolean | undefined>>>, |
| 133 | ): ProviderOptionSelectionsByProvider { | 145 | ): ProviderOptionSelectionsByProvider { |
| 134 | return selectionsByProvider(options); | 146 | return selectionsByProvider(options); |
| 135 | } | 147 | } |
| ... | @@ -929,14 +941,16 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -929,14 +941,16 @@ describe("composerDraftStore modelSelection", () => { |
| 929 | const store = useComposerDraftStore.getState(); | 941 | const store = useComposerDraftStore.getState(); |
| 930 | store.setModelSelection( | 942 | store.setModelSelection( |
| 931 | threadRef, | 943 | threadRef, |
| 932 | modelSelection("codex", "gpt-5.3-codex", { | 944 | modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { |
| 933 | reasoningEffort: "xhigh", | 945 | reasoningEffort: "xhigh", |
| 934 | fastMode: true, | 946 | fastMode: true, |
| 935 | }), | 947 | }), |
| 936 | ); | 948 | ); |
| 937 | 949 | ||
| 938 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | 950 | expect( |
| 939 | modelSelection("codex", "gpt-5.3-codex", { | 951 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], |
| 952 | ).toEqual( | ||
| 953 | modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { | ||
| 940 | reasoningEffort: "xhigh", | 954 | reasoningEffort: "xhigh", |
| 941 | fastMode: true, | 955 | fastMode: true, |
| 942 | }), | 956 | }), |
| ... | @@ -945,11 +959,11 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -945,11 +959,11 @@ describe("composerDraftStore modelSelection", () => { |
| 945 | 959 | ||
| 946 | it("keeps default-only model selections on the draft", () => { | 960 | it("keeps default-only model selections on the draft", () => { |
| 947 | const store = useComposerDraftStore.getState(); | 961 | const store = useComposerDraftStore.getState(); |
| 948 | store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); | 962 | store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); |
| 949 | 963 | ||
| 950 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | 964 | expect( |
| 951 | modelSelection("codex", "gpt-5.4"), | 965 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], |
| 952 | ); | 966 | ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4")); |
| 953 | }); | 967 | }); |
| 954 | 968 | ||
| 955 | it("replaces only the targeted provider options on the current model selection", () => { | 969 | it("replaces only the targeted provider options on the current model selection", () => { |
| ... | @@ -957,29 +971,38 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -957,29 +971,38 @@ describe("composerDraftStore modelSelection", () => { |
| 957 | 971 | ||
| 958 | store.setModelSelection( | 972 | store.setModelSelection( |
| 959 | threadRef, | 973 | threadRef, |
| 960 | modelSelection("claudeAgent", "claude-opus-4-6", { | 974 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { |
| 961 | effort: "max", | 975 | effort: "max", |
| 962 | fastMode: true, | 976 | fastMode: true, |
| 963 | }), | 977 | }), |
| 964 | ); | 978 | ); |
| 965 | store.setStickyModelSelection( | 979 | store.setStickyModelSelection( |
| 966 | modelSelection("claudeAgent", "claude-opus-4-6", { | 980 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { |
| 967 | effort: "max", | 981 | effort: "max", |
| 968 | fastMode: true, | 982 | fastMode: true, |
| 969 | }), | 983 | }), |
| 970 | ); | 984 | ); |
| 971 | 985 | ||
| 972 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), { | 986 | store.setProviderModelOptions( |
| 973 | persistSticky: true, | 987 | threadRef, |
| 974 | }); | 988 | CLAUDE_AGENT_DRIVER, |
| 989 | toSelections({ thinking: false }), | ||
| 990 | { | ||
| 991 | persistSticky: true, | ||
| 992 | }, | ||
| 993 | ); | ||
| 975 | 994 | ||
| 976 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | 995 | expect( |
| 977 | modelSelection("claudeAgent", "claude-opus-4-6", { | 996 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 997 | ).toEqual( | ||
| 998 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { | ||
| 978 | thinking: false, | 999 | thinking: false, |
| 979 | }), | 1000 | }), |
| 980 | ); | 1001 | ); |
| 981 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( | 1002 | expect( |
| 982 | modelSelection("claudeAgent", "claude-opus-4-6", { | 1003 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 1004 | ).toEqual( | ||
| 1005 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { | ||
| 983 | thinking: false, | 1006 | thinking: false, |
| 984 | }), | 1007 | }), |
| 985 | ); | 1008 | ); |
| ... | @@ -990,15 +1013,17 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -990,15 +1013,17 @@ describe("composerDraftStore modelSelection", () => { |
| 990 | 1013 | ||
| 991 | store.setModelSelection( | 1014 | store.setModelSelection( |
| 992 | threadRef, | 1015 | threadRef, |
| 993 | modelSelection("claudeAgent", "claude-opus-4-6", { | 1016 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { |
| 994 | effort: "max", | 1017 | effort: "max", |
| 995 | }), | 1018 | }), |
| 996 | ); | 1019 | ); |
| 997 | 1020 | ||
| 998 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: true })); | 1021 | store.setProviderModelOptions(threadRef, CLAUDE_AGENT_DRIVER, toSelections({ thinking: true })); |
| 999 | 1022 | ||
| 1000 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | 1023 | expect( |
| 1001 | modelSelection("claudeAgent", "claude-opus-4-6", { | 1024 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 1025 | ).toEqual( | ||
| 1026 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { | ||
| 1002 | thinking: true, | 1027 | thinking: true, |
| 1003 | }), | 1028 | }), |
| 1004 | ); | 1029 | ); |
| ... | @@ -1008,16 +1033,18 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1008,16 +1033,18 @@ describe("composerDraftStore modelSelection", () => { |
| 1008 | it("keeps explicit off/default codex overrides on the selection", () => { | 1033 | it("keeps explicit off/default codex overrides on the selection", () => { |
| 1009 | const store = useComposerDraftStore.getState(); | 1034 | const store = useComposerDraftStore.getState(); |
| 1010 | 1035 | ||
| 1011 | store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); | 1036 | store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4", { fastMode: true })); |
| 1012 | 1037 | ||
| 1013 | store.setProviderModelOptions( | 1038 | store.setProviderModelOptions( |
| 1014 | threadRef, | 1039 | threadRef, |
| 1015 | "codex", | 1040 | CODEX_DRIVER, |
| 1016 | toSelections({ reasoningEffort: "high", fastMode: false }), | 1041 | toSelections({ reasoningEffort: "high", fastMode: false }), |
| 1017 | ); | 1042 | ); |
| 1018 | 1043 | ||
| 1019 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | 1044 | expect( |
| 1020 | modelSelection("codex", "gpt-5.4", { | 1045 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], |
| 1046 | ).toEqual( | ||
| 1047 | modelSelection(CODEX_DRIVER, "gpt-5.4", { | ||
| 1021 | reasoningEffort: "high", | 1048 | reasoningEffort: "high", |
| 1022 | fastMode: false, | 1049 | fastMode: false, |
| 1023 | }), | 1050 | }), |
| ... | @@ -1029,7 +1056,7 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1029,7 +1056,7 @@ describe("composerDraftStore modelSelection", () => { |
| 1029 | 1056 | ||
| 1030 | store.setModelSelection( | 1057 | store.setModelSelection( |
| 1031 | threadRef, | 1058 | threadRef, |
| 1032 | modelSelection("cursor", "claude-opus-4-6", { | 1059 | modelSelection(CURSOR_DRIVER, "claude-opus-4-6", { |
| 1033 | reasoning: "xhigh", | 1060 | reasoning: "xhigh", |
| 1034 | fastMode: true, | 1061 | fastMode: true, |
| 1035 | thinking: false, | 1062 | thinking: false, |
| ... | @@ -1038,12 +1065,14 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1038,12 +1065,14 @@ describe("composerDraftStore modelSelection", () => { |
| 1038 | 1065 | ||
| 1039 | store.setProviderModelOptions( | 1066 | store.setProviderModelOptions( |
| 1040 | threadRef, | 1067 | threadRef, |
| 1041 | "cursor", | 1068 | CURSOR_DRIVER, |
| 1042 | toSelections({ reasoning: "medium", fastMode: false, thinking: true }), | 1069 | toSelections({ reasoning: "medium", fastMode: false, thinking: true }), |
| 1043 | ); | 1070 | ); |
| 1044 | 1071 | ||
| 1045 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( | 1072 | expect( |
| 1046 | modelSelection("cursor", "claude-opus-4-6", { | 1073 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CURSOR_INSTANCE], |
| 1074 | ).toEqual( | ||
| 1075 | modelSelection(CURSOR_DRIVER, "claude-opus-4-6", { | ||
| 1047 | reasoning: "medium", | 1076 | reasoning: "medium", |
| 1048 | fastMode: false, | 1077 | fastMode: false, |
| 1049 | thinking: true, | 1078 | thinking: true, |
| ... | @@ -1054,18 +1083,22 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1054,18 +1083,22 @@ describe("composerDraftStore modelSelection", () => { |
| 1054 | it("preserves the selected Cursor model when only traits change", () => { | 1083 | it("preserves the selected Cursor model when only traits change", () => { |
| 1055 | const store = useComposerDraftStore.getState(); | 1084 | const store = useComposerDraftStore.getState(); |
| 1056 | 1085 | ||
| 1057 | store.setProviderModelOptions(threadRef, "cursor", toSelections({ reasoning: "high" }), { | 1086 | store.setProviderModelOptions(threadRef, CURSOR_DRIVER, toSelections({ reasoning: "high" }), { |
| 1058 | model: "gpt-5.4", | 1087 | model: "gpt-5.4", |
| 1059 | persistSticky: true, | 1088 | persistSticky: true, |
| 1060 | }); | 1089 | }); |
| 1061 | 1090 | ||
| 1062 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( | 1091 | expect( |
| 1063 | modelSelection("cursor", "gpt-5.4", { | 1092 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CURSOR_INSTANCE], |
| 1093 | ).toEqual( | ||
| 1094 | modelSelection(CURSOR_DRIVER, "gpt-5.4", { | ||
| 1064 | reasoning: "high", | 1095 | reasoning: "high", |
| 1065 | }), | 1096 | }), |
| 1066 | ); | 1097 | ); |
| 1067 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual( | 1098 | expect( |
| 1068 | modelSelection("cursor", "gpt-5.4", { | 1099 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE], |
| 1100 | ).toEqual( | ||
| 1101 | modelSelection(CURSOR_DRIVER, "gpt-5.4", { | ||
| 1069 | reasoning: "high", | 1102 | reasoning: "high", |
| 1070 | }), | 1103 | }), |
| 1071 | ); | 1104 | ); |
| ... | @@ -1075,23 +1108,29 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1075,23 +1108,29 @@ describe("composerDraftStore modelSelection", () => { |
| 1075 | const store = useComposerDraftStore.getState(); | 1108 | const store = useComposerDraftStore.getState(); |
| 1076 | 1109 | ||
| 1077 | store.setStickyModelSelection( | 1110 | store.setStickyModelSelection( |
| 1078 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1111 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }), |
| 1079 | ); | 1112 | ); |
| 1080 | store.setModelSelection( | 1113 | store.setModelSelection( |
| 1081 | threadRef, | 1114 | threadRef, |
| 1082 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1115 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }), |
| 1083 | ); | 1116 | ); |
| 1084 | 1117 | ||
| 1085 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false })); | 1118 | store.setProviderModelOptions( |
| 1119 | threadRef, | ||
| 1120 | CLAUDE_AGENT_DRIVER, | ||
| 1121 | toSelections({ thinking: false }), | ||
| 1122 | ); | ||
| 1086 | 1123 | ||
| 1087 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | 1124 | expect( |
| 1088 | modelSelection("claudeAgent", "claude-opus-4-6", { | 1125 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 1126 | ).toEqual( | ||
| 1127 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { | ||
| 1089 | thinking: false, | 1128 | thinking: false, |
| 1090 | }), | 1129 | }), |
| 1091 | ); | 1130 | ); |
| 1092 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( | 1131 | expect( |
| 1093 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1132 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 1094 | ); | 1133 | ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" })); |
| 1095 | }); | 1134 | }); |
| 1096 | 1135 | ||
| 1097 | it("does not clear other provider options when setting options for a single provider", () => { | 1136 | it("does not clear other provider options when setting options for a single provider", () => { |
| ... | @@ -1110,13 +1149,17 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1110,13 +1149,17 @@ describe("composerDraftStore modelSelection", () => { |
| 1110 | store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); | 1149 | store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); |
| 1111 | 1150 | ||
| 1112 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | 1151 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 1113 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual( | 1152 | expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]?.options).toEqual( |
| 1114 | createModelSelection("codex", "gpt-5.4", toSelections({ reasoningEffort: "xhigh" })).options, | 1153 | createModelSelection(CODEX_INSTANCE, "gpt-5.4", toSelections({ reasoningEffort: "xhigh" })) |
| 1115 | ); | ||
| 1116 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( | ||
| 1117 | createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" })) | ||
| 1118 | .options, | 1154 | .options, |
| 1119 | ); | 1155 | ); |
| 1156 | expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]?.options).toEqual( | ||
| 1157 | createModelSelection( | ||
| 1158 | CLAUDE_AGENT_INSTANCE, | ||
| 1159 | "claude-opus-4-6", | ||
| 1160 | toSelections({ effort: "max" }), | ||
| 1161 | ).options, | ||
| 1162 | ); | ||
| 1120 | }); | 1163 | }); |
| 1121 | 1164 | ||
| 1122 | it("preserves other provider options when switching the active model selection", () => { | 1165 | it("preserves other provider options when switching the active model selection", () => { |
| ... | @@ -1130,14 +1173,14 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1130,14 +1173,14 @@ describe("composerDraftStore modelSelection", () => { |
| 1130 | }), | 1173 | }), |
| 1131 | ); | 1174 | ); |
| 1132 | 1175 | ||
| 1133 | store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6")); | 1176 | store.setModelSelection(threadRef, modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6")); |
| 1134 | 1177 | ||
| 1135 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | 1178 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 1136 | expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( | 1179 | expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual( |
| 1137 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1180 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }), |
| 1138 | ); | 1181 | ); |
| 1139 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual( | 1182 | expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]?.options).toEqual( |
| 1140 | createModelSelection("codex", "gpt-5.4", toSelections({ fastMode: true })).options, | 1183 | createModelSelection(CODEX_INSTANCE, "gpt-5.4", toSelections({ fastMode: true })).options, |
| 1141 | ); | 1184 | ); |
| 1142 | expect(draft?.activeProvider).toBe("claudeAgent"); | 1185 | expect(draft?.activeProvider).toBe("claudeAgent"); |
| 1143 | }); | 1186 | }); |
| ... | @@ -1145,14 +1188,14 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1145,14 +1188,14 @@ describe("composerDraftStore modelSelection", () => { |
| 1145 | it("creates the first sticky snapshot from provider option changes", () => { | 1188 | it("creates the first sticky snapshot from provider option changes", () => { |
| 1146 | const store = useComposerDraftStore.getState(); | 1189 | const store = useComposerDraftStore.getState(); |
| 1147 | 1190 | ||
| 1148 | store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); | 1191 | store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); |
| 1149 | 1192 | ||
| 1150 | store.setProviderModelOptions(threadRef, "codex", toSelections({ fastMode: true }), { | 1193 | store.setProviderModelOptions(threadRef, CODEX_DRIVER, toSelections({ fastMode: true }), { |
| 1151 | persistSticky: true, | 1194 | persistSticky: true, |
| 1152 | }); | 1195 | }); |
| 1153 | 1196 | ||
| 1154 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( | 1197 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual( |
| 1155 | modelSelection("codex", "gpt-5.4", { | 1198 | modelSelection(CODEX_DRIVER, "gpt-5.4", { |
| 1156 | fastMode: true, | 1199 | fastMode: true, |
| 1157 | }), | 1200 | }), |
| 1158 | ); | 1201 | ); |
| ... | @@ -1162,25 +1205,32 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1162,25 +1205,32 @@ describe("composerDraftStore modelSelection", () => { |
| 1162 | const store = useComposerDraftStore.getState(); | 1205 | const store = useComposerDraftStore.getState(); |
| 1163 | 1206 | ||
| 1164 | store.setStickyModelSelection( | 1207 | store.setStickyModelSelection( |
| 1165 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1208 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }), |
| 1166 | ); | 1209 | ); |
| 1167 | store.setModelSelection( | 1210 | store.setModelSelection( |
| 1168 | threadRef, | 1211 | threadRef, |
| 1169 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1212 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }), |
| 1170 | ); | 1213 | ); |
| 1171 | 1214 | ||
| 1172 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), { | 1215 | store.setProviderModelOptions( |
| 1173 | persistSticky: false, | 1216 | threadRef, |
| 1174 | }); | 1217 | CLAUDE_AGENT_DRIVER, |
| 1218 | toSelections({ thinking: false }), | ||
| 1219 | { | ||
| 1220 | persistSticky: false, | ||
| 1221 | }, | ||
| 1222 | ); | ||
| 1175 | 1223 | ||
| 1176 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | 1224 | expect( |
| 1177 | modelSelection("claudeAgent", "claude-opus-4-6", { | 1225 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 1226 | ).toEqual( | ||
| 1227 | modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { | ||
| 1178 | thinking: false, | 1228 | thinking: false, |
| 1179 | }), | 1229 | }), |
| 1180 | ); | 1230 | ); |
| 1181 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( | 1231 | expect( |
| 1182 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1232 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE], |
| 1183 | ); | 1233 | ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" })); |
| 1184 | }); | 1234 | }); |
| 1185 | }); | 1235 | }); |
| 1186 | 1236 | ||
| ... | @@ -1195,11 +1245,11 @@ describe("composerDraftStore setModelSelection", () => { | ... | @@ -1195,11 +1245,11 @@ describe("composerDraftStore setModelSelection", () => { |
| 1195 | it("keeps explicit model overrides instead of coercing to null", () => { | 1245 | it("keeps explicit model overrides instead of coercing to null", () => { |
| 1196 | const store = useComposerDraftStore.getState(); | 1246 | const store = useComposerDraftStore.getState(); |
| 1197 | 1247 | ||
| 1198 | store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex")); | 1248 | store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.3-codex")); |
| 1199 | 1249 | ||
| 1200 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | 1250 | expect( |
| 1201 | modelSelection("codex", "gpt-5.3-codex"), | 1251 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], |
| 1202 | ); | 1252 | ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.3-codex")); |
| 1203 | }); | 1253 | }); |
| 1204 | }); | 1254 | }); |
| 1205 | 1255 | ||
| ... | @@ -1212,14 +1262,14 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -1212,14 +1262,14 @@ describe("composerDraftStore sticky composer settings", () => { |
| 1212 | const store = useComposerDraftStore.getState(); | 1262 | const store = useComposerDraftStore.getState(); |
| 1213 | 1263 | ||
| 1214 | store.setStickyModelSelection( | 1264 | store.setStickyModelSelection( |
| 1215 | modelSelection("codex", "gpt-5.3-codex", { | 1265 | modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { |
| 1216 | reasoningEffort: "medium", | 1266 | reasoningEffort: "medium", |
| 1217 | fastMode: true, | 1267 | fastMode: true, |
| 1218 | }), | 1268 | }), |
| 1219 | ); | 1269 | ); |
| 1220 | 1270 | ||
| 1221 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( | 1271 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual( |
| 1222 | modelSelection("codex", "gpt-5.3-codex", { | 1272 | modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { |
| 1223 | reasoningEffort: "medium", | 1273 | reasoningEffort: "medium", |
| 1224 | fastMode: true, | 1274 | fastMode: true, |
| 1225 | }), | 1275 | }), |
| ... | @@ -1230,10 +1280,10 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -1230,10 +1280,10 @@ describe("composerDraftStore sticky composer settings", () => { |
| 1230 | it("normalizes empty sticky model options by dropping selection options", () => { | 1280 | it("normalizes empty sticky model options by dropping selection options", () => { |
| 1231 | const store = useComposerDraftStore.getState(); | 1281 | const store = useComposerDraftStore.getState(); |
| 1232 | 1282 | ||
| 1233 | store.setStickyModelSelection(modelSelection("codex", "gpt-5.4")); | 1283 | store.setStickyModelSelection(modelSelection(CODEX_DRIVER, "gpt-5.4")); |
| 1234 | 1284 | ||
| 1235 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( | 1285 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual( |
| 1236 | modelSelection("codex", "gpt-5.4"), | 1286 | modelSelection(CODEX_DRIVER, "gpt-5.4"), |
| 1237 | ); | 1287 | ); |
| 1238 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); | 1288 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); |
| 1239 | }); | 1289 | }); |
| ... | @@ -1242,7 +1292,7 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -1242,7 +1292,7 @@ describe("composerDraftStore sticky composer settings", () => { |
| 1242 | const store = useComposerDraftStore.getState(); | 1292 | const store = useComposerDraftStore.getState(); |
| 1243 | 1293 | ||
| 1244 | store.setStickyModelSelection( | 1294 | store.setStickyModelSelection( |
| 1245 | modelSelection("cursor", "gpt-5.4", { | 1295 | modelSelection(CURSOR_DRIVER, "gpt-5.4", { |
| 1246 | reasoning: undefined, | 1296 | reasoning: undefined, |
| 1247 | fastMode: undefined, | 1297 | fastMode: undefined, |
| 1248 | thinking: undefined, | 1298 | thinking: undefined, |
| ... | @@ -1250,9 +1300,9 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -1250,9 +1300,9 @@ describe("composerDraftStore sticky composer settings", () => { |
| 1250 | }), | 1300 | }), |
| 1251 | ); | 1301 | ); |
| 1252 | 1302 | ||
| 1253 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual( | 1303 | expect( |
| 1254 | modelSelection("cursor", "gpt-5.4"), | 1304 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE], |
| 1255 | ); | 1305 | ).toEqual(modelSelection(CURSOR_DRIVER, "gpt-5.4")); |
| 1256 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); | 1306 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); |
| 1257 | }); | 1307 | }); |
| 1258 | 1308 | ||
| ... | @@ -1261,12 +1311,12 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -1261,12 +1311,12 @@ describe("composerDraftStore sticky composer settings", () => { |
| 1261 | const threadId = ThreadId.make("thread-sticky-active-provider"); | 1311 | const threadId = ThreadId.make("thread-sticky-active-provider"); |
| 1262 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | 1312 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
| 1263 | 1313 | ||
| 1264 | store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); | 1314 | store.setStickyModelSelection(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6")); |
| 1265 | store.applyStickyState(threadRef); | 1315 | store.applyStickyState(threadRef); |
| 1266 | 1316 | ||
| 1267 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ | 1317 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ |
| 1268 | modelSelectionByProvider: { | 1318 | modelSelectionByProvider: { |
| 1269 | claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), | 1319 | claudeAgent: modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"), |
| 1270 | }, | 1320 | }, |
| 1271 | activeProvider: "claudeAgent", | 1321 | activeProvider: "claudeAgent", |
| 1272 | }); | 1322 | }); |
| ... | @@ -1285,18 +1335,21 @@ describe("composerDraftStore provider-scoped option updates", () => { | ... | @@ -1285,18 +1335,21 @@ describe("composerDraftStore provider-scoped option updates", () => { |
| 1285 | const store = useComposerDraftStore.getState(); | 1335 | const store = useComposerDraftStore.getState(); |
| 1286 | store.setModelSelection( | 1336 | store.setModelSelection( |
| 1287 | threadRef, | 1337 | threadRef, |
| 1288 | modelSelection("codex", "gpt-5.3-codex", { | 1338 | modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { |
| 1289 | reasoningEffort: "medium", | 1339 | reasoningEffort: "medium", |
| 1290 | }), | 1340 | }), |
| 1291 | ); | 1341 | ); |
| 1292 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ effort: "max" })); | 1342 | store.setProviderModelOptions(threadRef, CLAUDE_AGENT_DRIVER, toSelections({ effort: "max" })); |
| 1293 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | 1343 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 1294 | expect(draft?.modelSelectionByProvider.codex).toEqual( | 1344 | expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual( |
| 1295 | modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), | 1345 | modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "medium" }), |
| 1296 | ); | 1346 | ); |
| 1297 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( | 1347 | expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]?.options).toEqual( |
| 1298 | createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" })) | 1348 | createModelSelection( |
| 1299 | .options, | 1349 | CLAUDE_AGENT_INSTANCE, |
| 1350 | "claude-opus-4-6", | ||
| 1351 | toSelections({ effort: "max" }), | ||
| 1352 | ).options, | ||
| 1300 | ); | 1353 | ); |
| 1301 | expect(draft?.activeProvider).toBe("codex"); | 1354 | expect(draft?.activeProvider).toBe("codex"); |
| 1302 | }); | 1355 | }); |
apps/web/src/composerDraftStore.ts+263-119| ... | @@ -1,10 +1,13 @@ | ... | @@ -1,10 +1,13 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_MODEL, | ||
| 2 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | defaultInstanceIdForDriver, | ||
| 3 | type EnvironmentId, | 5 | type EnvironmentId, |
| 4 | ModelSelection, | 6 | ModelSelection, |
| 5 | ProjectId, | 7 | ProjectId, |
| 8 | ProviderInstanceId, | ||
| 6 | ProviderInteractionMode, | 9 | ProviderInteractionMode, |
| 7 | ProviderKind, | 10 | ProviderDriverKind, |
| 8 | ProviderOptionSelection, | 11 | ProviderOptionSelection, |
| 9 | RuntimeMode, | 12 | RuntimeMode, |
| 10 | type ServerProvider, | 13 | type ServerProvider, |
| ... | @@ -26,7 +29,7 @@ import { DeepMutable } from "effect/Types"; | ... | @@ -26,7 +29,7 @@ import { DeepMutable } from "effect/Types"; |
| 26 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 29 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 27 | import { useMemo } from "react"; | 30 | import { useMemo } from "react"; |
| 28 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; | 31 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 29 | import { resolveAppModelSelection } from "./modelSelection"; | 32 | import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; |
| 30 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; | 33 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 31 | import { | 34 | import { |
| 32 | type TerminalContextDraft, | 35 | type TerminalContextDraft, |
| ... | @@ -92,10 +95,19 @@ const PersistedComposerThreadDraftState = Schema.Struct({ | ... | @@ -92,10 +95,19 @@ const PersistedComposerThreadDraftState = Schema.Struct({ |
| 92 | prompt: Schema.String, | 95 | prompt: Schema.String, |
| 93 | attachments: Schema.Array(PersistedComposerImageAttachment), | 96 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 94 | terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), | 97 | terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), |
| 95 | modelSelectionByProvider: Schema.optionalKey( | 98 | // Keyed by `ProviderInstanceId` (open branded slug) so custom provider |
| 96 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), | 99 | // instances (e.g. `codex_personal`) round-trip alongside the built-in |
| 97 | ), | 100 | // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` |
| 98 | activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), | 101 | // literal satisfies the `ProviderInstanceId` slug pattern, so existing |
| 102 | // persisted drafts decode unchanged. | ||
| 103 | // | ||
| 104 | // The record's value schema is NOT wrapped in `Schema.optionalKey`: | ||
| 105 | // that helper is only meaningful on property signatures with a known | ||
| 106 | // key set, and `Schema.Record(<branded string>, …)` produces an index | ||
| 107 | // signature at runtime (Schema rejects the combination). Absence of | ||
| 108 | // an entry already encodes "no selection for this instance". | ||
| 109 | modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), | ||
| 110 | activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), | ||
| 99 | runtimeMode: Schema.optionalKey(RuntimeMode), | 111 | runtimeMode: Schema.optionalKey(RuntimeMode), |
| 100 | interactionMode: Schema.optionalKey(ProviderInteractionMode), | 112 | interactionMode: Schema.optionalKey(ProviderInteractionMode), |
| 101 | }); | 113 | }); |
| ... | @@ -107,7 +119,7 @@ type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftStat | ... | @@ -107,7 +119,7 @@ type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftStat |
| 107 | * deriving per-provider option bundles for downstream consumers. | 119 | * deriving per-provider option bundles for downstream consumers. |
| 108 | */ | 120 | */ |
| 109 | type ProviderOptionSelectionsByProvider = Partial< | 121 | type ProviderOptionSelectionsByProvider = Partial< |
| 110 | Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>> | 122 | Record<string, ReadonlyArray<ProviderOptionSelection>> |
| 111 | >; | 123 | >; |
| 112 | 124 | ||
| 113 | type LegacyCodexFields = { | 125 | type LegacyCodexFields = { |
| ... | @@ -182,9 +194,9 @@ const PersistedComposerDraftStoreState = Schema.Struct({ | ... | @@ -182,9 +194,9 @@ const PersistedComposerDraftStoreState = Schema.Struct({ |
| 182 | draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), | 194 | draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), |
| 183 | logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), | 195 | logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), |
| 184 | stickyModelSelectionByProvider: Schema.optionalKey( | 196 | stickyModelSelectionByProvider: Schema.optionalKey( |
| 185 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), | 197 | Schema.Record(ProviderInstanceId, ModelSelection), |
| 186 | ), | 198 | ), |
| 187 | stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), | 199 | stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), |
| 188 | }); | 200 | }); |
| 189 | type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; | 201 | type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; |
| 190 | 202 | ||
| ... | @@ -203,8 +215,17 @@ export interface ComposerThreadDraftState { | ... | @@ -203,8 +215,17 @@ export interface ComposerThreadDraftState { |
| 203 | nonPersistedImageIds: string[]; | 215 | nonPersistedImageIds: string[]; |
| 204 | persistedAttachments: PersistedComposerImageAttachment[]; | 216 | persistedAttachments: PersistedComposerImageAttachment[]; |
| 205 | terminalContexts: TerminalContextDraft[]; | 217 | terminalContexts: TerminalContextDraft[]; |
| 206 | modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; | 218 | /** |
| 207 | activeProvider: ProviderKind | null; | 219 | * Per-instance model selection. Keyed by `ProviderInstanceId` (open |
| 220 | * branded slug) so a default `codex` instance and a user-authored | ||
| 221 | * `codex_personal` instance each persist their own selected model. Every | ||
| 222 | * historical `ProviderDriverKind` literal (`codex` / `claudeAgent` / `cursor` / | ||
| 223 | * `opencode`) also satisfies the `ProviderInstanceId` slug pattern, so | ||
| 224 | * legacy kind-keyed drafts round-trip unchanged. | ||
| 225 | */ | ||
| 226 | modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; | ||
| 227 | /** Routing key of the last picked instance (see `modelSelectionByProvider`). */ | ||
| 228 | activeProvider: ProviderInstanceId | null; | ||
| 208 | runtimeMode: RuntimeMode | null; | 229 | runtimeMode: RuntimeMode | null; |
| 209 | interactionMode: ProviderInteractionMode | null; | 230 | interactionMode: ProviderInteractionMode | null; |
| 210 | } | 231 | } |
| ... | @@ -259,8 +280,8 @@ interface ComposerDraftStoreState { | ... | @@ -259,8 +280,8 @@ interface ComposerDraftStoreState { |
| 259 | draftsByThreadKey: Record<string, ComposerThreadDraftState>; | 280 | draftsByThreadKey: Record<string, ComposerThreadDraftState>; |
| 260 | draftThreadsByThreadKey: Record<string, DraftThreadState>; | 281 | draftThreadsByThreadKey: Record<string, DraftThreadState>; |
| 261 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; | 282 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; |
| 262 | stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; | 283 | stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; |
| 263 | stickyActiveProvider: ProviderKind | null; | 284 | stickyActiveProvider: ProviderInstanceId | null; |
| 264 | /** Returns the editable composer content for a draft session or server thread. */ | 285 | /** Returns the editable composer content for a draft session or server thread. */ |
| 265 | getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; | 286 | getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; |
| 266 | /** Looks up the active draft session for a logical project identity. */ | 287 | /** Looks up the active draft session for a logical project identity. */ |
| ... | @@ -339,14 +360,14 @@ interface ComposerDraftStoreState { | ... | @@ -339,14 +360,14 @@ interface ComposerDraftStoreState { |
| 339 | setModelOptions: ( | 360 | setModelOptions: ( |
| 340 | threadRef: ComposerThreadTarget, | 361 | threadRef: ComposerThreadTarget, |
| 341 | modelOptions: | 362 | modelOptions: |
| 342 | | Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> | 363 | | Partial<Record<string, ReadonlyArray<ProviderOptionSelection>>> |
| 343 | | null | 364 | | null |
| 344 | | undefined, | 365 | | undefined, |
| 345 | ) => void; | 366 | ) => void; |
| 346 | applyStickyState: (threadRef: ComposerThreadTarget) => void; | 367 | applyStickyState: (threadRef: ComposerThreadTarget) => void; |
| 347 | setProviderModelOptions: ( | 368 | setProviderModelOptions: ( |
| 348 | threadRef: ComposerThreadTarget, | 369 | threadRef: ComposerThreadTarget, |
| 349 | provider: ProviderKind, | 370 | provider: ProviderDriverKind, |
| 350 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | 371 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, |
| 351 | options?: { | 372 | options?: { |
| 352 | model?: string | null | undefined; | 373 | model?: string | null | undefined; |
| ... | @@ -388,8 +409,8 @@ export interface EffectiveComposerModelState { | ... | @@ -388,8 +409,8 @@ export interface EffectiveComposerModelState { |
| 388 | } | 409 | } |
| 389 | 410 | ||
| 390 | interface ComposerDraftModelState { | 411 | interface ComposerDraftModelState { |
| 391 | activeProvider: ProviderKind | null; | 412 | activeProvider: ProviderInstanceId | null; |
| 392 | modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; | 413 | modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; |
| 393 | } | 414 | } |
| 394 | 415 | ||
| 395 | function providerSelectionsFromModelSelection( | 416 | function providerSelectionsFromModelSelection( |
| ... | @@ -402,17 +423,17 @@ function providerSelectionsFromModelSelection( | ... | @@ -402,17 +423,17 @@ function providerSelectionsFromModelSelection( |
| 402 | if (!options || options.length === 0) { | 423 | if (!options || options.length === 0) { |
| 403 | return null; | 424 | return null; |
| 404 | } | 425 | } |
| 405 | return { [modelSelection.provider]: options } as ProviderOptionSelectionsByProvider; | 426 | return { [modelSelection.instanceId]: options }; |
| 406 | } | 427 | } |
| 407 | 428 | ||
| 408 | function modelSelectionByProviderToOptions( | 429 | function modelSelectionByProviderToOptions( |
| 409 | map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined, | 430 | map: Partial<Record<string, ModelSelection>> | null | undefined, |
| 410 | ): ProviderOptionSelectionsByProvider | null { | 431 | ): ProviderOptionSelectionsByProvider | null { |
| 411 | if (!map) return null; | 432 | if (!map) return null; |
| 412 | const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {}; | 433 | const result: ProviderOptionSelectionsByProvider = {}; |
| 413 | for (const [provider, selection] of Object.entries(map)) { | 434 | for (const [provider, selection] of Object.entries(map)) { |
| 414 | if (selection?.options && selection.options.length > 0) { | 435 | if (selection?.options && selection.options.length > 0) { |
| 415 | result[provider as ProviderKind] = selection.options; | 436 | result[provider] = selection.options; |
| 416 | } | 437 | } |
| 417 | } | 438 | } |
| 418 | return Object.keys(result).length > 0 ? result : null; | 439 | return Object.keys(result).length > 0 ? result : null; |
| ... | @@ -425,15 +446,14 @@ function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelect | ... | @@ -425,15 +446,14 @@ function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelect |
| 425 | } as DeepMutable<ModelSelection>; | 446 | } as DeepMutable<ModelSelection>; |
| 426 | } | 447 | } |
| 427 | 448 | ||
| 428 | function cloneModelSelectionByProvider( | 449 | function compactModelSelectionByProvider( |
| 429 | selections: Partial<Record<ProviderKind, ModelSelection>>, | 450 | selections: Partial<Record<ProviderInstanceId, ModelSelection>>, |
| 430 | ): DeepMutable<Partial<Record<ProviderKind, ModelSelection>>> { | 451 | ): DeepMutable<Record<ProviderInstanceId, ModelSelection>> { |
| 431 | return Object.fromEntries( | 452 | return Object.fromEntries( |
| 432 | Object.entries(selections).map(([provider, selection]) => [ | 453 | Object.entries(selections) |
| 433 | provider, | 454 | .filter((entry): entry is [string, ModelSelection] => entry[1] !== undefined) |
| 434 | selection ? cloneModelSelection(selection) : selection, | 455 | .map(([provider, selection]) => [provider, cloneModelSelection(selection)]), |
| 435 | ]), | 456 | ) as DeepMutable<Record<ProviderInstanceId, ModelSelection>>; |
| 436 | ) as DeepMutable<Partial<Record<ProviderKind, ModelSelection>>>; | ||
| 437 | } | 457 | } |
| 438 | 458 | ||
| 439 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ | 459 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ |
| ... | @@ -451,7 +471,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; | ... | @@ -451,7 +471,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; |
| 451 | Object.freeze(EMPTY_IMAGES); | 471 | Object.freeze(EMPTY_IMAGES); |
| 452 | Object.freeze(EMPTY_IDS); | 472 | Object.freeze(EMPTY_IDS); |
| 453 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); | 473 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 454 | const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> = | 474 | const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderDriverKind, ModelSelection>> = |
| 455 | Object.freeze({}); | 475 | Object.freeze({}); |
| 456 | const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({ | 476 | const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({ |
| 457 | activeProvider: null, | 477 | activeProvider: null, |
| ... | @@ -554,10 +574,28 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { | ... | @@ -554,10 +574,28 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 554 | ); | 574 | ); |
| 555 | } | 575 | } |
| 556 | 576 | ||
| 557 | function normalizeProviderKind(value: unknown): ProviderKind | null { | 577 | function normalizeProviderDriverKind(value: unknown): ProviderDriverKind | null { |
| 558 | return value === "codex" || value === "claudeAgent" || value === "cursor" || value === "opencode" | 578 | return Schema.is(ProviderDriverKind)(value) ? value : null; |
| 559 | ? value | 579 | } |
| 560 | : null; | 580 | |
| 581 | /** | ||
| 582 | * Match the `ProviderInstanceId` slug pattern (letter followed by | ||
| 583 | * letters/digits/`-`/`_`, 1..64 chars). Permissive validator — the schema | ||
| 584 | * layer owns authoritative validation; this is used inline to gate typed | ||
| 585 | * writes to the draft's instance-keyed maps without pulling the full | ||
| 586 | * Effect Schema runtime into the hot path. | ||
| 587 | */ | ||
| 588 | const PROVIDER_INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; | ||
| 589 | |||
| 590 | /** | ||
| 591 | * Coerce an arbitrary persisted value into a valid `ProviderInstanceId`. Used | ||
| 592 | * wherever we need to accept both legacy driver-kind keys and custom instance | ||
| 593 | * slugs (e.g. `codex_personal`) as routing keys. | ||
| 594 | */ | ||
| 595 | function normalizeProviderInstanceId(value: unknown): ProviderInstanceId | null { | ||
| 596 | if (typeof value !== "string") return null; | ||
| 597 | if (!PROVIDER_INSTANCE_ID_PATTERN.test(value)) return null; | ||
| 598 | return value as ProviderInstanceId; | ||
| 561 | } | 599 | } |
| 562 | 600 | ||
| 563 | /** | 601 | /** |
| ... | @@ -610,11 +648,11 @@ function coerceProviderOptionSelections( | ... | @@ -610,11 +648,11 @@ function coerceProviderOptionSelections( |
| 610 | */ | 648 | */ |
| 611 | function normalizeProviderModelOptions( | 649 | function normalizeProviderModelOptions( |
| 612 | value: unknown, | 650 | value: unknown, |
| 613 | provider?: ProviderKind | null, | 651 | provider?: ProviderDriverKind | null, |
| 614 | legacy?: LegacyCodexFields, | 652 | legacy?: LegacyCodexFields, |
| 615 | ): ProviderOptionSelectionsByProvider | null { | 653 | ): ProviderOptionSelectionsByProvider | null { |
| 616 | const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; | 654 | const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; |
| 617 | const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {}; | 655 | const result: ProviderOptionSelectionsByProvider = {}; |
| 618 | for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { | 656 | for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { |
| 619 | const selections = coerceProviderOptionSelections(candidate?.[providerKey]); | 657 | const selections = coerceProviderOptionSelections(candidate?.[providerKey]); |
| 620 | if (selections) { | 658 | if (selections) { |
| ... | @@ -648,6 +686,13 @@ function normalizeProviderModelOptions( | ... | @@ -648,6 +686,13 @@ function normalizeProviderModelOptions( |
| 648 | return Object.keys(result).length > 0 ? result : null; | 686 | return Object.keys(result).length > 0 ? result : null; |
| 649 | } | 687 | } |
| 650 | 688 | ||
| 689 | // Returns a model selection whose `instanceId` is a valid | ||
| 690 | // `ProviderInstanceId` slug. Legacy `provider` fields are promoted verbatim | ||
| 691 | // because default instance ids used the same slug as the driver kind. | ||
| 692 | // | ||
| 693 | // Selections whose instance id doesn't match the slug pattern collapse to | ||
| 694 | // `null` — caller is responsible for deciding whether that's a dropped | ||
| 695 | // write or a routed error. | ||
| 651 | function normalizeModelSelection( | 696 | function normalizeModelSelection( |
| 652 | value: unknown, | 697 | value: unknown, |
| 653 | legacy?: { | 698 | legacy?: { |
| ... | @@ -656,63 +701,98 @@ function normalizeModelSelection( | ... | @@ -656,63 +701,98 @@ function normalizeModelSelection( |
| 656 | modelOptions?: unknown; | 701 | modelOptions?: unknown; |
| 657 | legacyCodex?: LegacyCodexFields; | 702 | legacyCodex?: LegacyCodexFields; |
| 658 | }, | 703 | }, |
| 659 | ): ModelSelection | null { | 704 | ): NormalizedModelSelection | null { |
| 660 | const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; | 705 | const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; |
| 661 | const provider = normalizeProviderKind(candidate?.provider ?? legacy?.provider); | 706 | // Post-migration ModelSelection carries `instanceId`; pre-migration (v2 |
| 662 | if (provider === null) { | 707 | // storage, legacy wire shapes) carries `provider`. Accept either so both |
| 708 | // normalized stores and legacy drafts round-trip through this helper. | ||
| 709 | const instanceId = normalizeProviderInstanceId( | ||
| 710 | candidate?.instanceId ?? candidate?.provider ?? legacy?.provider, | ||
| 711 | ); | ||
| 712 | if (instanceId === null) { | ||
| 663 | return null; | 713 | return null; |
| 664 | } | 714 | } |
| 665 | const rawModel = candidate?.model ?? legacy?.model; | 715 | const rawModel = candidate?.model ?? legacy?.model; |
| 666 | if (typeof rawModel !== "string") { | 716 | if (typeof rawModel !== "string") { |
| 667 | return null; | 717 | return null; |
| 668 | } | 718 | } |
| 669 | const model = normalizeModelSlug(rawModel, provider); | 719 | // Slug normalization can use provider-kind-specific rules when a legacy |
| 720 | // driver key is present. Instance-only selections are not reverse-inferred | ||
| 721 | // into a driver kind here; they get generic default normalization. | ||
| 722 | const driverKindHint = | ||
| 723 | normalizeProviderDriverKind(candidate?.provider ?? legacy?.provider) ?? | ||
| 724 | ProviderDriverKind.make("codex"); | ||
| 725 | const model = normalizeModelSlug(rawModel, driverKindHint); | ||
| 670 | if (!model) { | 726 | if (!model) { |
| 671 | return null; | 727 | return null; |
| 672 | } | 728 | } |
| 673 | if (Array.isArray(candidate?.options)) { | 729 | if (Array.isArray(candidate?.options)) { |
| 674 | const selections = coerceProviderOptionSelections(candidate.options); | 730 | const selections = coerceProviderOptionSelections(candidate.options); |
| 675 | return createModelSelection(provider, model, selections); | 731 | return createModelSelection(instanceId, model, selections) as NormalizedModelSelection; |
| 676 | } | 732 | } |
| 677 | const modelOptions = normalizeProviderModelOptions( | 733 | // Per-kind options were a pre-migration concern; only recover them for a |
| 678 | candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions, | 734 | // built-in-kind instance. Custom instances don't have a legacy options |
| 679 | provider, | 735 | // store to thread through here. |
| 680 | provider === "codex" ? legacy?.legacyCodex : undefined, | 736 | const kindForLegacyOptions = normalizeProviderDriverKind(instanceId); |
| 681 | ); | 737 | const modelOptions = kindForLegacyOptions |
| 682 | const options = modelOptions?.[provider]; | 738 | ? normalizeProviderModelOptions( |
| 683 | return createModelSelection(provider, model, options); | 739 | candidate?.options ? { [kindForLegacyOptions]: candidate.options } : legacy?.modelOptions, |
| 740 | kindForLegacyOptions, | ||
| 741 | kindForLegacyOptions === "codex" ? legacy?.legacyCodex : undefined, | ||
| 742 | ) | ||
| 743 | : null; | ||
| 744 | const options = kindForLegacyOptions ? modelOptions?.[kindForLegacyOptions] : undefined; | ||
| 745 | return createModelSelection(instanceId, model, options) as NormalizedModelSelection; | ||
| 684 | } | 746 | } |
| 685 | 747 | ||
| 748 | type NormalizedModelSelection = Omit<ModelSelection, "instanceId"> & { | ||
| 749 | readonly instanceId: ProviderInstanceId; | ||
| 750 | }; | ||
| 751 | |||
| 686 | // ── Legacy sync helpers (used only during migration from v2 storage) ── | 752 | // ── Legacy sync helpers (used only during migration from v2 storage) ── |
| 753 | // | ||
| 754 | // These operate against the legacy kind-keyed `modelOptions` map. The | ||
| 755 | // normalized selection now carries an open `ProviderInstanceId`; legacy | ||
| 756 | // migration only recovers options for keys that existed before custom | ||
| 757 | // provider instances. | ||
| 687 | 758 | ||
| 688 | function legacySyncModelSelectionOptions( | 759 | function legacySyncModelSelectionOptions( |
| 689 | modelSelection: ModelSelection | null, | 760 | modelSelection: NormalizedModelSelection | null, |
| 690 | modelOptions: ProviderOptionSelectionsByProvider | null | undefined, | 761 | modelOptions: ProviderOptionSelectionsByProvider | null | undefined, |
| 691 | ): ModelSelection | null { | 762 | ): NormalizedModelSelection | null { |
| 692 | if (modelSelection === null) { | 763 | if (modelSelection === null) { |
| 693 | return null; | 764 | return null; |
| 694 | } | 765 | } |
| 695 | const options = modelOptions?.[modelSelection.provider]; | 766 | const kind = normalizeProviderDriverKind(modelSelection.instanceId); |
| 696 | return createModelSelection(modelSelection.provider, modelSelection.model, options); | 767 | const options = kind ? modelOptions?.[kind] : undefined; |
| 768 | return createModelSelection( | ||
| 769 | modelSelection.instanceId, | ||
| 770 | modelSelection.model, | ||
| 771 | options, | ||
| 772 | ) as NormalizedModelSelection; | ||
| 697 | } | 773 | } |
| 698 | 774 | ||
| 699 | function legacyMergeModelSelectionIntoProviderModelOptions( | 775 | function legacyMergeModelSelectionIntoProviderModelOptions( |
| 700 | modelSelection: ModelSelection | null, | 776 | modelSelection: NormalizedModelSelection | null, |
| 701 | currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, | 777 | currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, |
| 702 | ): ProviderOptionSelectionsByProvider | null { | 778 | ): ProviderOptionSelectionsByProvider | null { |
| 703 | if (!modelSelection?.options || modelSelection.options.length === 0) { | 779 | if (!modelSelection?.options || modelSelection.options.length === 0) { |
| 704 | return normalizeProviderModelOptions(currentModelOptions); | 780 | return normalizeProviderModelOptions(currentModelOptions); |
| 705 | } | 781 | } |
| 782 | const kind = normalizeProviderDriverKind(modelSelection.instanceId); | ||
| 783 | if (!kind) { | ||
| 784 | return normalizeProviderModelOptions(currentModelOptions); | ||
| 785 | } | ||
| 706 | return legacyReplaceProviderModelOptions( | 786 | return legacyReplaceProviderModelOptions( |
| 707 | normalizeProviderModelOptions(currentModelOptions), | 787 | normalizeProviderModelOptions(currentModelOptions), |
| 708 | modelSelection.provider, | 788 | kind, |
| 709 | modelSelection.options, | 789 | modelSelection.options, |
| 710 | ); | 790 | ); |
| 711 | } | 791 | } |
| 712 | 792 | ||
| 713 | function legacyReplaceProviderModelOptions( | 793 | function legacyReplaceProviderModelOptions( |
| 714 | currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, | 794 | currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, |
| 715 | provider: ProviderKind, | 795 | provider: ProviderDriverKind, |
| 716 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | 796 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, |
| 717 | ): ProviderOptionSelectionsByProvider | null { | 797 | ): ProviderOptionSelectionsByProvider | null { |
| 718 | const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = | 798 | const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = |
| ... | @@ -727,26 +807,28 @@ function legacyReplaceProviderModelOptions( | ... | @@ -727,26 +807,28 @@ function legacyReplaceProviderModelOptions( |
| 727 | // ── New helpers for the consolidated representation ──────────────────── | 807 | // ── New helpers for the consolidated representation ──────────────────── |
| 728 | 808 | ||
| 729 | function legacyToModelSelectionByProvider( | 809 | function legacyToModelSelectionByProvider( |
| 730 | modelSelection: ModelSelection | null, | 810 | modelSelection: NormalizedModelSelection | null, |
| 731 | modelOptions: ProviderOptionSelectionsByProvider | null | undefined, | 811 | modelOptions: ProviderOptionSelectionsByProvider | null | undefined, |
| 732 | ): Partial<Record<ProviderKind, ModelSelection>> { | 812 | ): Partial<Record<ProviderInstanceId, ModelSelection>> { |
| 733 | const result: Partial<Record<ProviderKind, ModelSelection>> = {}; | 813 | const result: Partial<Record<ProviderInstanceId, ModelSelection>> = {}; |
| 734 | if (modelOptions) { | 814 | if (modelOptions) { |
| 735 | for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { | 815 | for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { |
| 736 | const options = modelOptions[provider]; | 816 | const options = modelOptions[provider]; |
| 737 | if (options && options.length > 0) { | 817 | if (options && options.length > 0) { |
| 738 | result[provider] = createModelSelection( | 818 | const driverKind = ProviderDriverKind.make(provider); |
| 739 | provider, | 819 | const instanceKey = defaultInstanceIdForDriver(driverKind); |
| 740 | modelSelection?.provider === provider | 820 | result[instanceKey] = createModelSelection( |
| 821 | instanceKey, | ||
| 822 | modelSelection?.instanceId === instanceKey | ||
| 741 | ? modelSelection.model | 823 | ? modelSelection.model |
| 742 | : DEFAULT_MODEL_BY_PROVIDER[provider], | 824 | : (DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL), |
| 743 | options, | 825 | options, |
| 744 | ); | 826 | ); |
| 745 | } | 827 | } |
| 746 | } | 828 | } |
| 747 | } | 829 | } |
| 748 | if (modelSelection) { | 830 | if (modelSelection) { |
| 749 | result[modelSelection.provider] = modelSelection; | 831 | result[modelSelection.instanceId] = modelSelection as ModelSelection; |
| 750 | } | 832 | } |
| 751 | return result; | 833 | return result; |
| 752 | } | 834 | } |
| ... | @@ -757,24 +839,64 @@ export function deriveEffectiveComposerModelState(input: { | ... | @@ -757,24 +839,64 @@ export function deriveEffectiveComposerModelState(input: { |
| 757 | | null | 839 | | null |
| 758 | | undefined; | 840 | | undefined; |
| 759 | providers: ReadonlyArray<ServerProvider>; | 841 | providers: ReadonlyArray<ServerProvider>; |
| 760 | selectedProvider: ProviderKind; | 842 | selectedProvider: ProviderDriverKind; |
| 843 | /** | ||
| 844 | * Optional routing key of the instance whose selection should override | ||
| 845 | * the driver-level lookup. When present, the draft is queried by | ||
| 846 | * `modelSelectionByProvider[selectedInstanceId]` so a custom Codex | ||
| 847 | * instance (e.g. `codex_personal`) reads its own saved model instead of | ||
| 848 | * collapsing to the default Codex bucket. | ||
| 849 | */ | ||
| 850 | selectedInstanceId?: ProviderInstanceId | null | undefined; | ||
| 761 | threadModelSelection: ModelSelection | null | undefined; | 851 | threadModelSelection: ModelSelection | null | undefined; |
| 762 | projectModelSelection: ModelSelection | null | undefined; | 852 | projectModelSelection: ModelSelection | null | undefined; |
| 763 | settings: UnifiedSettings; | 853 | settings: UnifiedSettings; |
| 764 | }): EffectiveComposerModelState { | 854 | }): EffectiveComposerModelState { |
| 855 | const baseModelCandidate = | ||
| 856 | input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; | ||
| 765 | const baseModel = | 857 | const baseModel = |
| 766 | normalizeModelSlug( | 858 | (input.selectedInstanceId |
| 767 | input.threadModelSelection?.model ?? input.projectModelSelection?.model, | 859 | ? resolveAppModelSelectionForInstance( |
| 860 | input.selectedInstanceId, | ||
| 861 | input.settings, | ||
| 862 | input.providers, | ||
| 863 | baseModelCandidate, | ||
| 864 | ) | ||
| 865 | : null) ?? | ||
| 866 | resolveAppModelSelection( | ||
| 768 | input.selectedProvider, | 867 | input.selectedProvider, |
| 769 | ) ?? getDefaultServerModel(input.providers, input.selectedProvider); | 868 | input.settings, |
| 770 | const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; | 869 | input.providers, |
| 870 | baseModelCandidate, | ||
| 871 | ) ?? | ||
| 872 | normalizeModelSlug(baseModelCandidate, input.selectedProvider) ?? | ||
| 873 | getDefaultServerModel(input.providers, input.selectedProvider); | ||
| 874 | // Look up the instance's saved selection first; fall back to the | ||
| 875 | // driver-kind bucket so legacy kind-keyed drafts still resolve. Every | ||
| 876 | // `ProviderDriverKind` literal is a valid `ProviderInstanceId` slug, so the | ||
| 877 | // cast to the branded type is safe. | ||
| 878 | const instanceSelection = input.selectedInstanceId | ||
| 879 | ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] | ||
| 880 | : undefined; | ||
| 881 | const legacySelection = | ||
| 882 | input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; | ||
| 883 | const activeSelection = instanceSelection ?? legacySelection; | ||
| 884 | const activeSelectionInstanceId = instanceSelection | ||
| 885 | ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider)) | ||
| 886 | : ProviderInstanceId.make(input.selectedProvider); | ||
| 771 | const selectedModel = activeSelection?.model | 887 | const selectedModel = activeSelection?.model |
| 772 | ? resolveAppModelSelection( | 888 | ? (resolveAppModelSelectionForInstance( |
| 889 | activeSelectionInstanceId, | ||
| 890 | input.settings, | ||
| 891 | input.providers, | ||
| 892 | activeSelection.model, | ||
| 893 | ) ?? | ||
| 894 | resolveAppModelSelection( | ||
| 773 | input.selectedProvider, | 895 | input.selectedProvider, |
| 774 | input.settings, | 896 | input.settings, |
| 775 | input.providers, | 897 | input.providers, |
| 776 | activeSelection.model, | 898 | activeSelection.model, |
| 777 | ) | 899 | )) |
| 778 | : baseModel; | 900 | : baseModel; |
| 779 | const modelOptions = | 901 | const modelOptions = |
| 780 | modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? | 902 | modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? |
| ... | @@ -1365,8 +1487,8 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -1365,8 +1487,8 @@ function normalizePersistedDraftsByThreadId( |
| 1365 | ); | 1487 | ); |
| 1366 | // If the draft already has the v3 shape, use it directly | 1488 | // If the draft already has the v3 shape, use it directly |
| 1367 | const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; | 1489 | const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; |
| 1368 | let modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {}; | 1490 | let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {}; |
| 1369 | let activeProvider: ProviderKind | null = null; | 1491 | let activeProvider: ProviderInstanceId | null = null; |
| 1370 | 1492 | ||
| 1371 | if ( | 1493 | if ( |
| 1372 | draftCandidate.modelSelectionByProvider && | 1494 | draftCandidate.modelSelectionByProvider && |
| ... | @@ -1374,9 +1496,9 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -1374,9 +1496,9 @@ function normalizePersistedDraftsByThreadId( |
| 1374 | ) { | 1496 | ) { |
| 1375 | // v3 format | 1497 | // v3 format |
| 1376 | modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial< | 1498 | modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial< |
| 1377 | Record<ProviderKind, ModelSelection> | 1499 | Record<ProviderInstanceId, ModelSelection> |
| 1378 | >; | 1500 | >; |
| 1379 | activeProvider = normalizeProviderKind(draftCandidate.activeProvider); | 1501 | activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider); |
| 1380 | } else { | 1502 | } else { |
| 1381 | // v2 or legacy format: migrate | 1503 | // v2 or legacy format: migrate |
| 1382 | const normalizedModelOptions = | 1504 | const normalizedModelOptions = |
| ... | @@ -1406,7 +1528,7 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -1406,7 +1528,7 @@ function normalizePersistedDraftsByThreadId( |
| 1406 | modelSelection, | 1528 | modelSelection, |
| 1407 | mergedModelOptions, | 1529 | mergedModelOptions, |
| 1408 | ); | 1530 | ); |
| 1409 | activeProvider = modelSelection?.provider ?? null; | 1531 | activeProvider = modelSelection?.instanceId ?? null; |
| 1410 | } | 1532 | } |
| 1411 | 1533 | ||
| 1412 | const hasModelData = | 1534 | const hasModelData = |
| ... | @@ -1439,7 +1561,7 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -1439,7 +1561,7 @@ function normalizePersistedDraftsByThreadId( |
| 1439 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), | 1561 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| 1440 | ...(hasModelData | 1562 | ...(hasModelData |
| 1441 | ? { | 1563 | ? { |
| 1442 | modelSelectionByProvider: cloneModelSelectionByProvider(modelSelectionByProvider), | 1564 | modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), |
| 1443 | activeProvider, | 1565 | activeProvider, |
| 1444 | } | 1566 | } |
| 1445 | : {}), | 1567 | : {}), |
| ... | @@ -1486,7 +1608,7 @@ function migratePersistedComposerDraftStoreState( | ... | @@ -1486,7 +1608,7 @@ function migratePersistedComposerDraftStoreState( |
| 1486 | stickyModelSelection, | 1608 | stickyModelSelection, |
| 1487 | nextStickyModelOptions, | 1609 | nextStickyModelOptions, |
| 1488 | ); | 1610 | ); |
| 1489 | const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; | 1611 | const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null; |
| 1490 | 1612 | ||
| 1491 | const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = | 1613 | const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = |
| 1492 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); | 1614 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); |
| ... | @@ -1498,7 +1620,7 @@ function migratePersistedComposerDraftStoreState( | ... | @@ -1498,7 +1620,7 @@ function migratePersistedComposerDraftStoreState( |
| 1498 | draftsByThreadKey, | 1620 | draftsByThreadKey, |
| 1499 | draftThreadsByThreadKey, | 1621 | draftThreadsByThreadKey, |
| 1500 | logicalProjectDraftThreadKeyByLogicalProjectKey, | 1622 | logicalProjectDraftThreadKeyByLogicalProjectKey, |
| 1501 | stickyModelSelectionByProvider, | 1623 | stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), |
| 1502 | stickyActiveProvider, | 1624 | stickyActiveProvider, |
| 1503 | }; | 1625 | }; |
| 1504 | } | 1626 | } |
| ... | @@ -1543,7 +1665,9 @@ function partializeComposerDraftStoreState( | ... | @@ -1543,7 +1665,9 @@ function partializeComposerDraftStoreState( |
| 1543 | : {}), | 1665 | : {}), |
| 1544 | ...(hasModelData | 1666 | ...(hasModelData |
| 1545 | ? { | 1667 | ? { |
| 1546 | modelSelectionByProvider: cloneModelSelectionByProvider(draft.modelSelectionByProvider), | 1668 | modelSelectionByProvider: compactModelSelectionByProvider( |
| 1669 | draft.modelSelectionByProvider, | ||
| 1670 | ), | ||
| 1547 | activeProvider: draft.activeProvider, | 1671 | activeProvider: draft.activeProvider, |
| 1548 | } | 1672 | } |
| 1549 | : {}), | 1673 | : {}), |
| ... | @@ -1557,7 +1681,9 @@ function partializeComposerDraftStoreState( | ... | @@ -1557,7 +1681,9 @@ function partializeComposerDraftStoreState( |
| 1557 | draftThreadsByThreadKey: state.draftThreadsByThreadKey, | 1681 | draftThreadsByThreadKey: state.draftThreadsByThreadKey, |
| 1558 | logicalProjectDraftThreadKeyByLogicalProjectKey: | 1682 | logicalProjectDraftThreadKeyByLogicalProjectKey: |
| 1559 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, | 1683 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, |
| 1560 | stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, | 1684 | stickyModelSelectionByProvider: compactModelSelectionByProvider( |
| 1685 | state.stickyModelSelectionByProvider, | ||
| 1686 | ), | ||
| 1561 | stickyActiveProvider: state.stickyActiveProvider, | 1687 | stickyActiveProvider: state.stickyActiveProvider, |
| 1562 | }; | 1688 | }; |
| 1563 | } | 1689 | } |
| ... | @@ -1580,17 +1706,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( | ... | @@ -1580,17 +1706,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 1580 | ); | 1706 | ); |
| 1581 | 1707 | ||
| 1582 | // Handle both v3 (modelSelectionByProvider) and v2/legacy formats | 1708 | // Handle both v3 (modelSelectionByProvider) and v2/legacy formats |
| 1583 | let stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {}; | 1709 | let stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {}; |
| 1584 | let stickyActiveProvider: ProviderKind | null = null; | 1710 | let stickyActiveProvider: ProviderInstanceId | null = null; |
| 1585 | if ( | 1711 | if ( |
| 1586 | normalizedPersistedState.stickyModelSelectionByProvider && | 1712 | normalizedPersistedState.stickyModelSelectionByProvider && |
| 1587 | typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" | 1713 | typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" |
| 1588 | ) { | 1714 | ) { |
| 1589 | stickyModelSelectionByProvider = | 1715 | stickyModelSelectionByProvider = |
| 1590 | normalizedPersistedState.stickyModelSelectionByProvider as Partial< | 1716 | normalizedPersistedState.stickyModelSelectionByProvider as Partial< |
| 1591 | Record<ProviderKind, ModelSelection> | 1717 | Record<ProviderInstanceId, ModelSelection> |
| 1592 | >; | 1718 | >; |
| 1593 | stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyActiveProvider); | 1719 | stickyActiveProvider = normalizeProviderInstanceId( |
| 1720 | normalizedPersistedState.stickyActiveProvider, | ||
| 1721 | ); | ||
| 1594 | } else { | 1722 | } else { |
| 1595 | // Legacy migration path | 1723 | // Legacy migration path |
| 1596 | const stickyModelOptions = | 1724 | const stickyModelOptions = |
| ... | @@ -1615,7 +1743,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( | ... | @@ -1615,7 +1743,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 1615 | stickyModelSelection, | 1743 | stickyModelSelection, |
| 1616 | nextStickyModelOptions, | 1744 | nextStickyModelOptions, |
| 1617 | ); | 1745 | ); |
| 1618 | stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider); | 1746 | stickyActiveProvider = normalizeProviderInstanceId(normalizedPersistedState.stickyProvider); |
| 1619 | } | 1747 | } |
| 1620 | 1748 | ||
| 1621 | return { | 1749 | return { |
| ... | @@ -1625,7 +1753,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( | ... | @@ -1625,7 +1753,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 1625 | ), | 1753 | ), |
| 1626 | draftThreadsByThreadKey, | 1754 | draftThreadsByThreadKey, |
| 1627 | logicalProjectDraftThreadKeyByLogicalProjectKey, | 1755 | logicalProjectDraftThreadKeyByLogicalProjectKey, |
| 1628 | stickyModelSelectionByProvider, | 1756 | stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), |
| 1629 | stickyActiveProvider, | 1757 | stickyActiveProvider, |
| 1630 | }; | 1758 | }; |
| 1631 | } | 1759 | } |
| ... | @@ -1754,9 +1882,9 @@ function toHydratedThreadDraft( | ... | @@ -1754,9 +1882,9 @@ function toHydratedThreadDraft( |
| 1754 | persistedDraft: PersistedComposerThreadDraftState, | 1882 | persistedDraft: PersistedComposerThreadDraftState, |
| 1755 | ): ComposerThreadDraftState { | 1883 | ): ComposerThreadDraftState { |
| 1756 | // The persisted draft is already in v3 shape (migration handles older formats) | 1884 | // The persisted draft is already in v3 shape (migration handles older formats) |
| 1757 | const modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = | 1885 | const modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = |
| 1758 | persistedDraft.modelSelectionByProvider ?? {}; | 1886 | persistedDraft.modelSelectionByProvider ?? {}; |
| 1759 | const activeProvider = normalizeProviderKind(persistedDraft.activeProvider) ?? null; | 1887 | const activeProvider = normalizeProviderInstanceId(persistedDraft.activeProvider) ?? null; |
| 1760 | 1888 | ||
| 1761 | return { | 1889 | return { |
| 1762 | prompt: persistedDraft.prompt, | 1890 | prompt: persistedDraft.prompt, |
| ... | @@ -2117,18 +2245,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2117,18 +2245,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2117 | if (!normalized) { | 2245 | if (!normalized) { |
| 2118 | return state; | 2246 | return state; |
| 2119 | } | 2247 | } |
| 2120 | const nextMap: Partial<Record<ProviderKind, ModelSelection>> = { | 2248 | const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = { |
| 2121 | ...state.stickyModelSelectionByProvider, | 2249 | ...state.stickyModelSelectionByProvider, |
| 2122 | [normalized.provider]: normalized, | 2250 | [normalized.instanceId]: normalized, |
| 2123 | }; | 2251 | }; |
| 2124 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { | 2252 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { |
| 2125 | return state.stickyActiveProvider === normalized.provider | 2253 | return state.stickyActiveProvider === normalized.instanceId |
| 2126 | ? state | 2254 | ? state |
| 2127 | : { stickyActiveProvider: normalized.provider }; | 2255 | : { stickyActiveProvider: normalized.instanceId }; |
| 2128 | } | 2256 | } |
| 2129 | return { | 2257 | return { |
| 2130 | stickyModelSelectionByProvider: nextMap, | 2258 | stickyModelSelectionByProvider: nextMap, |
| 2131 | stickyActiveProvider: normalized.provider, | 2259 | stickyActiveProvider: normalized.instanceId, |
| 2132 | }; | 2260 | }; |
| 2133 | }); | 2261 | }); |
| 2134 | }, | 2262 | }, |
| ... | @@ -2148,8 +2276,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2148,8 +2276,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2148 | const nextMap = { ...base.modelSelectionByProvider }; | 2276 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2149 | for (const [provider, selection] of Object.entries(stickyMap)) { | 2277 | for (const [provider, selection] of Object.entries(stickyMap)) { |
| 2150 | if (selection) { | 2278 | if (selection) { |
| 2151 | const current = nextMap[provider as ProviderKind]; | 2279 | // Iteration key comes from the instance-keyed sticky map, |
| 2152 | nextMap[provider as ProviderKind] = { | 2280 | // so coerce the string back to `ProviderInstanceId` for |
| 2281 | // the typed lookup. | ||
| 2282 | const instanceKey = provider as ProviderInstanceId; | ||
| 2283 | const current = nextMap[instanceKey]; | ||
| 2284 | nextMap[instanceKey] = { | ||
| 2153 | ...selection, | 2285 | ...selection, |
| 2154 | model: current?.model ?? selection.model, | 2286 | model: current?.model ?? selection.model, |
| 2155 | }; | 2287 | }; |
| ... | @@ -2235,20 +2367,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2235,20 +2367,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2235 | const base = existing ?? createEmptyThreadDraft(); | 2367 | const base = existing ?? createEmptyThreadDraft(); |
| 2236 | const nextMap = { ...base.modelSelectionByProvider }; | 2368 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2237 | if (normalized) { | 2369 | if (normalized) { |
| 2238 | const current = nextMap[normalized.provider]; | 2370 | const current = nextMap[normalized.instanceId]; |
| 2239 | if (normalized.options !== undefined) { | 2371 | if (normalized.options !== undefined) { |
| 2240 | // Explicit options provided → use them | 2372 | // Explicit options provided → use them |
| 2241 | nextMap[normalized.provider] = normalized; | 2373 | nextMap[normalized.instanceId] = normalized as ModelSelection; |
| 2242 | } else { | 2374 | } else { |
| 2243 | // No options in selection → preserve existing options, update provider+model | 2375 | // No options in selection → preserve existing options, update provider+model |
| 2244 | nextMap[normalized.provider] = createModelSelection( | 2376 | nextMap[normalized.instanceId] = createModelSelection( |
| 2245 | normalized.provider, | 2377 | normalized.instanceId, |
| 2246 | normalized.model, | 2378 | normalized.model, |
| 2247 | current?.options, | 2379 | current?.options, |
| 2248 | ); | 2380 | ); |
| 2249 | } | 2381 | } |
| 2250 | } | 2382 | } |
| 2251 | const nextActiveProvider = normalized?.provider ?? base.activeProvider; | 2383 | const nextActiveProvider = normalized?.instanceId ?? base.activeProvider; |
| 2252 | if ( | 2384 | if ( |
| 2253 | Equal.equals(base.modelSelectionByProvider, nextMap) && | 2385 | Equal.equals(base.modelSelectionByProvider, nextMap) && |
| 2254 | base.activeProvider === nextActiveProvider | 2386 | base.activeProvider === nextActiveProvider |
| ... | @@ -2284,16 +2416,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2284,16 +2416,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2284 | for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { | 2416 | for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { |
| 2285 | if (!modelOptions || !(provider in modelOptions)) continue; | 2417 | if (!modelOptions || !(provider in modelOptions)) continue; |
| 2286 | const opts = modelOptions[provider]; | 2418 | const opts = modelOptions[provider]; |
| 2287 | const current = nextMap[provider]; | 2419 | const driverKind = ProviderDriverKind.make(provider); |
| 2420 | const instanceKey = defaultInstanceIdForDriver(driverKind); | ||
| 2421 | const current = nextMap[instanceKey]; | ||
| 2288 | if (opts && opts.length > 0) { | 2422 | if (opts && opts.length > 0) { |
| 2289 | nextMap[provider] = createModelSelection( | 2423 | nextMap[instanceKey] = createModelSelection( |
| 2290 | provider, | 2424 | instanceKey, |
| 2291 | current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], | 2425 | current?.model ?? DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL, |
| 2292 | opts, | 2426 | opts, |
| 2293 | ); | 2427 | ); |
| 2294 | } else if (current?.options) { | 2428 | } else if (current?.options) { |
| 2295 | const { options: _, ...rest } = current; | 2429 | const { options: _, ...rest } = current; |
| 2296 | nextMap[provider] = rest as ModelSelection; | 2430 | nextMap[instanceKey] = rest as ModelSelection; |
| 2297 | } | 2431 | } |
| 2298 | } | 2432 | } |
| 2299 | if (Equal.equals(base.modelSelectionByProvider, nextMap)) { | 2433 | if (Equal.equals(base.modelSelectionByProvider, nextMap)) { |
| ... | @@ -2317,13 +2451,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2317,13 +2451,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2317 | if (threadKey.length === 0) { | 2451 | if (threadKey.length === 0) { |
| 2318 | return; | 2452 | return; |
| 2319 | } | 2453 | } |
| 2320 | const normalizedProvider = normalizeProviderKind(provider); | 2454 | const normalizedProvider = normalizeProviderDriverKind(provider); |
| 2321 | if (normalizedProvider === null) { | 2455 | if (normalizedProvider === null) { |
| 2322 | return; | 2456 | return; |
| 2323 | } | 2457 | } |
| 2458 | const instanceKey = defaultInstanceIdForDriver(normalizedProvider); | ||
| 2324 | const fallbackModel = | 2459 | const fallbackModel = |
| 2325 | normalizeModelSlug(options?.model, normalizedProvider) ?? | 2460 | normalizeModelSlug(options?.model, normalizedProvider) ?? |
| 2326 | DEFAULT_MODEL_BY_PROVIDER[normalizedProvider]; | 2461 | DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ?? |
| 2462 | DEFAULT_MODEL; | ||
| 2327 | const providerOpts = | 2463 | const providerOpts = |
| 2328 | nextProviderOptions && nextProviderOptions.length > 0 ? nextProviderOptions : undefined; | 2464 | nextProviderOptions && nextProviderOptions.length > 0 ? nextProviderOptions : undefined; |
| 2329 | 2465 | ||
| ... | @@ -2333,16 +2469,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2333,16 +2469,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2333 | 2469 | ||
| 2334 | // Update the map entry for this provider | 2470 | // Update the map entry for this provider |
| 2335 | const nextMap = { ...base.modelSelectionByProvider }; | 2471 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2336 | const currentForProvider = nextMap[normalizedProvider]; | 2472 | const currentForProvider = nextMap[instanceKey]; |
| 2337 | if (providerOpts) { | 2473 | if (providerOpts) { |
| 2338 | nextMap[normalizedProvider] = createModelSelection( | 2474 | nextMap[instanceKey] = createModelSelection( |
| 2339 | normalizedProvider, | 2475 | instanceKey, |
| 2340 | currentForProvider?.model ?? fallbackModel, | 2476 | currentForProvider?.model ?? fallbackModel, |
| 2341 | providerOpts, | 2477 | providerOpts, |
| 2342 | ); | 2478 | ); |
| 2343 | } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) { | 2479 | } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) { |
| 2344 | const { options: _, ...rest } = currentForProvider; | 2480 | const { options: _, ...rest } = currentForProvider; |
| 2345 | nextMap[normalizedProvider] = rest as ModelSelection; | 2481 | nextMap[instanceKey] = rest as ModelSelection; |
| 2346 | } | 2482 | } |
| 2347 | 2483 | ||
| 2348 | // Handle sticky persistence | 2484 | // Handle sticky persistence |
| ... | @@ -2351,20 +2487,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2351,20 +2487,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2351 | if (options?.persistSticky === true) { | 2487 | if (options?.persistSticky === true) { |
| 2352 | nextStickyMap = { ...state.stickyModelSelectionByProvider }; | 2488 | nextStickyMap = { ...state.stickyModelSelectionByProvider }; |
| 2353 | const stickyBase = | 2489 | const stickyBase = |
| 2354 | nextStickyMap[normalizedProvider] ?? | 2490 | nextStickyMap[instanceKey] ?? |
| 2355 | base.modelSelectionByProvider[normalizedProvider] ?? | 2491 | base.modelSelectionByProvider[instanceKey] ?? |
| 2356 | createModelSelection(normalizedProvider, fallbackModel); | 2492 | createModelSelection(instanceKey, fallbackModel); |
| 2357 | if (providerOpts) { | 2493 | if (providerOpts) { |
| 2358 | nextStickyMap[normalizedProvider] = createModelSelection( | 2494 | nextStickyMap[instanceKey] = createModelSelection( |
| 2359 | normalizedProvider, | 2495 | instanceKey, |
| 2360 | stickyBase.model, | 2496 | stickyBase.model, |
| 2361 | providerOpts, | 2497 | providerOpts, |
| 2362 | ); | 2498 | ); |
| 2363 | } else if ((stickyBase.options?.length ?? 0) > 0) { | 2499 | } else if ((stickyBase.options?.length ?? 0) > 0) { |
| 2364 | const { options: _, ...rest } = stickyBase; | 2500 | const { options: _, ...rest } = stickyBase; |
| 2365 | nextStickyMap[normalizedProvider] = rest as ModelSelection; | 2501 | nextStickyMap[instanceKey] = rest as ModelSelection; |
| 2366 | } | 2502 | } |
| 2367 | nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; | 2503 | nextStickyActiveProvider = base.activeProvider ?? instanceKey; |
| 2368 | } | 2504 | } |
| 2369 | 2505 | ||
| 2370 | if ( | 2506 | if ( |
| ... | @@ -2815,7 +2951,13 @@ export function useEffectiveComposerModelState(input: { | ... | @@ -2815,7 +2951,13 @@ export function useEffectiveComposerModelState(input: { |
| 2815 | threadRef?: ComposerThreadTarget; | 2951 | threadRef?: ComposerThreadTarget; |
| 2816 | draftId?: DraftId; | 2952 | draftId?: DraftId; |
| 2817 | providers: ReadonlyArray<ServerProvider>; | 2953 | providers: ReadonlyArray<ServerProvider>; |
| 2818 | selectedProvider: ProviderKind; | 2954 | selectedProvider: ProviderDriverKind; |
| 2955 | /** | ||
| 2956 | * When supplied, the draft's saved selection for this instance takes | ||
| 2957 | * precedence over the driver-kind bucket — so a custom `codex_personal` | ||
| 2958 | * instance reads its own model, not the default Codex's. | ||
| 2959 | */ | ||
| 2960 | selectedInstanceId?: ProviderInstanceId | null | undefined; | ||
| 2819 | threadModelSelection: ModelSelection | null | undefined; | 2961 | threadModelSelection: ModelSelection | null | undefined; |
| 2820 | projectModelSelection: ModelSelection | null | undefined; | 2962 | projectModelSelection: ModelSelection | null | undefined; |
| 2821 | settings: UnifiedSettings; | 2963 | settings: UnifiedSettings; |
| ... | @@ -2828,6 +2970,7 @@ export function useEffectiveComposerModelState(input: { | ... | @@ -2828,6 +2970,7 @@ export function useEffectiveComposerModelState(input: { |
| 2828 | draft, | 2970 | draft, |
| 2829 | providers: input.providers, | 2971 | providers: input.providers, |
| 2830 | selectedProvider: input.selectedProvider, | 2972 | selectedProvider: input.selectedProvider, |
| 2973 | selectedInstanceId: input.selectedInstanceId, | ||
| 2831 | threadModelSelection: input.threadModelSelection, | 2974 | threadModelSelection: input.threadModelSelection, |
| 2832 | projectModelSelection: input.projectModelSelection, | 2975 | projectModelSelection: input.projectModelSelection, |
| 2833 | settings: input.settings, | 2976 | settings: input.settings, |
| ... | @@ -2837,6 +2980,7 @@ export function useEffectiveComposerModelState(input: { | ... | @@ -2837,6 +2980,7 @@ export function useEffectiveComposerModelState(input: { |
| 2837 | input.providers, | 2980 | input.providers, |
| 2838 | input.settings, | 2981 | input.settings, |
| 2839 | input.projectModelSelection, | 2982 | input.projectModelSelection, |
| 2983 | input.selectedInstanceId, | ||
| 2840 | input.selectedProvider, | 2984 | input.selectedProvider, |
| 2841 | input.threadModelSelection, | 2985 | input.threadModelSelection, |
| 2842 | ], | 2986 | ], |
apps/web/src/environmentGrouping.test.ts+2-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { scopeProjectRef } from "@t3tools/client-runtime"; | 2 | import { scopeProjectRef } from "@t3tools/client-runtime"; |
| 3 | import { describe, expect, it } from "vitest"; | 3 | import { describe, expect, it } from "vitest"; |
| 4 | 4 | ||
| ... | @@ -48,7 +48,7 @@ function makeProject( | ... | @@ -48,7 +48,7 @@ function makeProject( |
| 48 | ): Project { | 48 | ): Project { |
| 49 | return { | 49 | return { |
| 50 | cwd: `/tmp/${overrides.name}`, | 50 | cwd: `/tmp/${overrides.name}`, |
| 51 | defaultModelSelection: { provider: "codex" as const, model: "gpt-5-codex" }, | 51 | defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, |
| 52 | createdAt: "2026-01-01T00:00:00.000Z", | 52 | createdAt: "2026-01-01T00:00:00.000Z", |
| 53 | updatedAt: "2026-01-01T00:00:00.000Z", | 53 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 54 | scripts: [], | 54 | scripts: [], |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+2-1| ... | @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; | ... | @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; |
| 2 | import { | 2 | import { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ProjectId, | 4 | ProjectId, |
| 5 | ProviderInstanceId, | ||
| 5 | ThreadId, | 6 | ThreadId, |
| 6 | TurnId, | 7 | TurnId, |
| 7 | type OrchestrationShellSnapshot, | 8 | type OrchestrationShellSnapshot, |
| ... | @@ -98,7 +99,7 @@ function makeThreadShellSnapshot(params: { | ... | @@ -98,7 +99,7 @@ function makeThreadShellSnapshot(params: { |
| 98 | projectId, | 99 | projectId, |
| 99 | title: "Thread", | 100 | title: "Thread", |
| 100 | modelSelection: { | 101 | modelSelection: { |
| 101 | provider: "codex", | 102 | instanceId: ProviderInstanceId.make("codex"), |
| 102 | model: "gpt-5-codex", | 103 | model: "gpt-5-codex", |
| 103 | }, | 104 | }, |
| 104 | runtimeMode: "full-access", | 105 | runtimeMode: "full-access", |
apps/web/src/hooks/useCommitOnBlur.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Buffer text input locally so keystrokes don't cause a settings-wide | ||
| 5 | * re-render (and optionally a server RPC round-trip) on every character. | ||
| 6 | * `onCommit` fires on blur and on Enter. | ||
| 7 | * | ||
| 8 | * The draft resynchronizes from the upstream `value` only when the input | ||
| 9 | * is not focused, so an external push (e.g. an optimistic settings | ||
| 10 | * update from the user's own commit, or a reset to defaults) doesn't | ||
| 11 | * clobber an in-progress edit. | ||
| 12 | * | ||
| 13 | * Returns a bag of props that should be spread onto an `<Input>`: | ||
| 14 | * | ||
| 15 | * const bag = useCommitOnBlur(instance.displayName ?? "", (next) => {...}); | ||
| 16 | * <Input {...bag} placeholder="e.g. Work" /> | ||
| 17 | */ | ||
| 18 | export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { | ||
| 19 | const [draft, setDraft] = useState(value); | ||
| 20 | const focusedRef = useRef(false); | ||
| 21 | |||
| 22 | useEffect(() => { | ||
| 23 | if (!focusedRef.current) { | ||
| 24 | setDraft(value); | ||
| 25 | } | ||
| 26 | }, [value]); | ||
| 27 | |||
| 28 | return { | ||
| 29 | value: draft, | ||
| 30 | onChange: (event: ChangeEvent<HTMLInputElement>) => { | ||
| 31 | setDraft(event.target.value); | ||
| 32 | }, | ||
| 33 | onFocus: () => { | ||
| 34 | focusedRef.current = true; | ||
| 35 | }, | ||
| 36 | onBlur: () => { | ||
| 37 | focusedRef.current = false; | ||
| 38 | if (draft !== value) { | ||
| 39 | onCommit(draft); | ||
| 40 | } | ||
| 41 | }, | ||
| 42 | onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => { | ||
| 43 | if (event.key === "Enter") { | ||
| 44 | event.preventDefault(); | ||
| 45 | (event.target as HTMLInputElement).blur(); | ||
| 46 | } | ||
| 47 | }, | ||
| 48 | }; | ||
| 49 | } | ||
apps/web/src/lib/threadSort.test.ts+8-2| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { DEFAULT_RUNTIME_MODE, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | 2 | import { |
| 3 | DEFAULT_RUNTIME_MODE, | ||
| 4 | EnvironmentId, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 3 | import type { Thread } from "../types"; | 9 | import type { Thread } from "../types"; |
| 4 | import { getLatestThreadForProject, sortThreads } from "./threadSort"; | 10 | import { getLatestThreadForProject, sortThreads } from "./threadSort"; |
| 5 | 11 | ||
| ... | @@ -13,7 +19,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -13,7 +19,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 13 | codexThreadId: null, | 19 | codexThreadId: null, |
| 14 | projectId: PROJECT_ID, | 20 | projectId: PROJECT_ID, |
| 15 | title: "Thread", | 21 | title: "Thread", |
| 16 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | 22 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 17 | runtimeMode: DEFAULT_RUNTIME_MODE, | 23 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 18 | interactionMode: "default", | 24 | interactionMode: "default", |
| 19 | session: null, | 25 | session: null, |
apps/web/src/localApi.test.ts+8-3| ... | @@ -6,6 +6,8 @@ import { | ... | @@ -6,6 +6,8 @@ import { |
| 6 | type GitStatusResult, | 6 | type GitStatusResult, |
| 7 | ProjectId, | 7 | ProjectId, |
| 8 | type OrchestrationShellStreamItem, | 8 | type OrchestrationShellStreamItem, |
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 9 | type ServerConfig, | 11 | type ServerConfig, |
| 10 | type ServerProvider, | 12 | type ServerProvider, |
| 11 | type TerminalEvent, | 13 | type TerminalEvent, |
| ... | @@ -207,7 +209,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg | ... | @@ -207,7 +209,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg |
| 207 | 209 | ||
| 208 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | 210 | const defaultProviders: ReadonlyArray<ServerProvider> = [ |
| 209 | { | 211 | { |
| 210 | provider: "codex", | 212 | instanceId: ProviderInstanceId.make("codex"), |
| 213 | driver: ProviderDriverKind.make("codex"), | ||
| 211 | enabled: true, | 214 | enabled: true, |
| 212 | installed: true, | 215 | installed: true, |
| 213 | version: "0.116.0", | 216 | version: "0.116.0", |
| ... | @@ -328,7 +331,7 @@ describe("wsApi", () => { | ... | @@ -328,7 +331,7 @@ describe("wsApi", () => { |
| 328 | title: "Project", | 331 | title: "Project", |
| 329 | workspaceRoot: "/tmp/workspace", | 332 | workspaceRoot: "/tmp/workspace", |
| 330 | defaultModelSelection: { | 333 | defaultModelSelection: { |
| 331 | provider: "codex", | 334 | instanceId: ProviderInstanceId.make("codex"), |
| 332 | model: "gpt-5-codex", | 335 | model: "gpt-5-codex", |
| 333 | }, | 336 | }, |
| 334 | scripts: [], | 337 | scripts: [], |
| ... | @@ -394,7 +397,7 @@ describe("wsApi", () => { | ... | @@ -394,7 +397,7 @@ describe("wsApi", () => { |
| 394 | title: "Project", | 397 | title: "Project", |
| 395 | workspaceRoot: "/tmp/project", | 398 | workspaceRoot: "/tmp/project", |
| 396 | defaultModelSelection: { | 399 | defaultModelSelection: { |
| 397 | provider: "codex", | 400 | instanceId: ProviderInstanceId.make("codex"), |
| 398 | model: "gpt-5-codex", | 401 | model: "gpt-5-codex", |
| 399 | }, | 402 | }, |
| 400 | createdAt: "2026-02-24T00:00:00.000Z", | 403 | createdAt: "2026-02-24T00:00:00.000Z", |
| ... | @@ -534,6 +537,7 @@ describe("wsApi", () => { | ... | @@ -534,6 +537,7 @@ describe("wsApi", () => { |
| 534 | confirmThreadDelete: false, | 537 | confirmThreadDelete: false, |
| 535 | diffWordWrap: true, | 538 | diffWordWrap: true, |
| 536 | favorites: [], | 539 | favorites: [], |
| 540 | providerModelPreferences: {}, | ||
| 537 | sidebarProjectGroupingMode: "repository_path" as const, | 541 | sidebarProjectGroupingMode: "repository_path" as const, |
| 538 | sidebarProjectGroupingOverrides: { | 542 | sidebarProjectGroupingOverrides: { |
| 539 | "environment-local:/tmp/project": "separate" as const, | 543 | "environment-local:/tmp/project": "separate" as const, |
| ... | @@ -593,6 +597,7 @@ describe("wsApi", () => { | ... | @@ -593,6 +597,7 @@ describe("wsApi", () => { |
| 593 | confirmThreadDelete: false, | 597 | confirmThreadDelete: false, |
| 594 | diffWordWrap: true, | 598 | diffWordWrap: true, |
| 595 | favorites: [], | 599 | favorites: [], |
| 600 | providerModelPreferences: {}, | ||
| 596 | sidebarProjectGroupingMode: "repository_path" as const, | 601 | sidebarProjectGroupingMode: "repository_path" as const, |
| 597 | sidebarProjectGroupingOverrides: { | 602 | sidebarProjectGroupingOverrides: { |
| 598 | "environment-local:/tmp/project": "separate" as const, | 603 | "environment-local:/tmp/project": "separate" as const, |
apps/web/src/modelOrdering.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | providerModelKey, | ||
| 6 | sortModelsForProviderInstance, | ||
| 7 | sortProviderModelItems, | ||
| 8 | } from "./modelOrdering"; | ||
| 9 | |||
| 10 | const CODEX_WORK_ID = ProviderInstanceId.make("codex_work"); | ||
| 11 | const CLAUDE_ID = ProviderInstanceId.make("claudeAgent"); | ||
| 12 | |||
| 13 | describe("model ordering", () => { | ||
| 14 | it("groups favorites first while preserving provider model order inside each group", () => { | ||
| 15 | const models = [ | ||
| 16 | { slug: "gpt-5.5" }, | ||
| 17 | { slug: "gpt-5.4-mini" }, | ||
| 18 | { slug: "crest-alpha" }, | ||
| 19 | { slug: "gpt-5.3-codex" }, | ||
| 20 | ]; | ||
| 21 | |||
| 22 | expect( | ||
| 23 | sortModelsForProviderInstance(models, { | ||
| 24 | favoriteModels: ["gpt-5.5", "gpt-5.4-mini", "crest-alpha"], | ||
| 25 | groupFavorites: true, | ||
| 26 | modelOrder: ["gpt-5.4-mini", "gpt-5.5", "crest-alpha", "gpt-5.3-codex"], | ||
| 27 | }).map((model) => model.slug), | ||
| 28 | ).toEqual(["gpt-5.4-mini", "gpt-5.5", "crest-alpha", "gpt-5.3-codex"]); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("sorts the favorites view by provider order, then provider model order", () => { | ||
| 32 | const items = [ | ||
| 33 | { instanceId: CODEX_WORK_ID, slug: "gpt-5.4-mini" }, | ||
| 34 | { instanceId: CODEX_WORK_ID, slug: "gpt-5.5" }, | ||
| 35 | { instanceId: CODEX_WORK_ID, slug: "crest-alpha" }, | ||
| 36 | { instanceId: CLAUDE_ID, slug: "claude-opus-4-6" }, | ||
| 37 | ]; | ||
| 38 | const favoriteKeys = [ | ||
| 39 | providerModelKey(CODEX_WORK_ID, "gpt-5.5"), | ||
| 40 | providerModelKey(CLAUDE_ID, "claude-opus-4-6"), | ||
| 41 | providerModelKey(CODEX_WORK_ID, "gpt-5.4-mini"), | ||
| 42 | providerModelKey(CODEX_WORK_ID, "crest-alpha"), | ||
| 43 | ]; | ||
| 44 | |||
| 45 | expect( | ||
| 46 | sortProviderModelItems(items, { | ||
| 47 | favoriteModelKeys: favoriteKeys, | ||
| 48 | instanceOrder: [CODEX_WORK_ID, CLAUDE_ID], | ||
| 49 | }).map((item) => item.slug), | ||
| 50 | ).toEqual(["gpt-5.4-mini", "gpt-5.5", "crest-alpha", "claude-opus-4-6"]); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/web/src/modelOrdering.ts created+86| ... | @@ -0,0 +1,86 @@ | ||
| 1 | import type { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as Order from "effect/Order"; | ||
| 4 | |||
| 5 | export interface ModelSlugItem { | ||
| 6 | readonly slug: string; | ||
| 7 | } | ||
| 8 | |||
| 9 | export interface ProviderModelItem extends ModelSlugItem { | ||
| 10 | readonly instanceId: ProviderInstanceId; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function providerModelKey(instanceId: ProviderInstanceId, slug: string): string { | ||
| 14 | return `${instanceId}:${slug}`; | ||
| 15 | } | ||
| 16 | |||
| 17 | function rankByValue(values: ReadonlyArray<string>): ReadonlyMap<string, number> { | ||
| 18 | return new Map(Arr.map(values, (value, index) => [value, index] as const)); | ||
| 19 | } | ||
| 20 | |||
| 21 | function toSet( | ||
| 22 | values: ReadonlySet<string> | ReadonlyArray<string> | undefined, | ||
| 23 | ): ReadonlySet<string> { | ||
| 24 | return values instanceof Set ? values : new Set(values ?? []); | ||
| 25 | } | ||
| 26 | |||
| 27 | function byOptionalRank<T>(rank: (item: T) => number | undefined): Order.Order<T> { | ||
| 28 | return Order.mapInput(Order.Number, (item: T) => rank(item) ?? Number.POSITIVE_INFINITY); | ||
| 29 | } | ||
| 30 | |||
| 31 | function byTrueFirst<T>(predicate: (item: T) => boolean): Order.Order<T> { | ||
| 32 | return Order.mapInput(Order.flip(Order.Boolean), predicate); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function sortModelsForProviderInstance<T extends ModelSlugItem>( | ||
| 36 | models: ReadonlyArray<T>, | ||
| 37 | options?: { | ||
| 38 | readonly modelOrder?: ReadonlyArray<string>; | ||
| 39 | readonly favoriteModels?: ReadonlySet<string> | ReadonlyArray<string>; | ||
| 40 | readonly groupFavorites?: boolean; | ||
| 41 | }, | ||
| 42 | ): T[] { | ||
| 43 | const modelOrder = options?.modelOrder ?? []; | ||
| 44 | const favoriteModels = toSet(options?.favoriteModels); | ||
| 45 | const orderBySlug = rankByValue(modelOrder); | ||
| 46 | const originalOrder = rankByValue(Arr.map(models, (model) => model.slug)); | ||
| 47 | const orders: Array<Order.Order<T>> = [ | ||
| 48 | ...(options?.groupFavorites === true | ||
| 49 | ? [byTrueFirst<T>((model) => favoriteModels.has(model.slug))] | ||
| 50 | : []), | ||
| 51 | byOptionalRank((model) => orderBySlug.get(model.slug)), | ||
| 52 | byOptionalRank((model) => originalOrder.get(model.slug)), | ||
| 53 | ]; | ||
| 54 | |||
| 55 | return Arr.sort(models, Order.combineAll(orders)); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function sortProviderModelItems<T extends ProviderModelItem>( | ||
| 59 | items: ReadonlyArray<T>, | ||
| 60 | options?: { | ||
| 61 | readonly favoriteModelKeys?: ReadonlySet<string> | ReadonlyArray<string>; | ||
| 62 | readonly groupFavorites?: boolean; | ||
| 63 | readonly instanceOrder?: ReadonlyArray<ProviderInstanceId>; | ||
| 64 | }, | ||
| 65 | ): T[] { | ||
| 66 | const favoriteModelKeys = toSet(options?.favoriteModelKeys); | ||
| 67 | const instanceOrder = new Map( | ||
| 68 | Arr.map(options?.instanceOrder ?? [], (instanceId, index) => [instanceId, index] as const), | ||
| 69 | ); | ||
| 70 | const originalOrder = rankByValue( | ||
| 71 | Arr.map(items, (item) => providerModelKey(item.instanceId, item.slug)), | ||
| 72 | ); | ||
| 73 | const orders: Array<Order.Order<T>> = [ | ||
| 74 | ...(options?.groupFavorites === true | ||
| 75 | ? [ | ||
| 76 | byTrueFirst<T>((item) => | ||
| 77 | favoriteModelKeys.has(providerModelKey(item.instanceId, item.slug)), | ||
| 78 | ), | ||
| 79 | ] | ||
| 80 | : []), | ||
| 81 | byOptionalRank((item) => instanceOrder.get(item.instanceId)), | ||
| 82 | byOptionalRank((item) => originalOrder.get(providerModelKey(item.instanceId, item.slug))), | ||
| 83 | ]; | ||
| 84 | |||
| 85 | return Arr.sort(items, Order.combineAll(orders)); | ||
| 86 | } | ||
apps/web/src/modelSelection.test.ts created+251| ... | @@ -0,0 +1,251 @@ | ||
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; | ||
| 3 | import { describe, expect, it } from "vitest"; | ||
| 4 | import { deriveProviderInstanceEntries } from "./providerInstances"; | ||
| 5 | import { | ||
| 6 | getAppModelOptionsForInstance, | ||
| 7 | resolveAppModelSelectionForInstance, | ||
| 8 | resolveAppModelSelectionState, | ||
| 9 | } from "./modelSelection"; | ||
| 10 | |||
| 11 | function provider(input: { | ||
| 12 | provider?: ProviderDriverKind; | ||
| 13 | instanceId: string; | ||
| 14 | models?: ReadonlyArray<string>; | ||
| 15 | }): ServerProvider { | ||
| 16 | const driver = | ||
| 17 | input.provider ?? | ||
| 18 | (input.instanceId.startsWith("claude_") | ||
| 19 | ? ProviderDriverKind.make("claudeAgent") | ||
| 20 | : ProviderDriverKind.make("codex")); | ||
| 21 | return { | ||
| 22 | instanceId: ProviderInstanceId.make(input.instanceId), | ||
| 23 | driver, | ||
| 24 | enabled: true, | ||
| 25 | installed: true, | ||
| 26 | version: null, | ||
| 27 | status: "ready", | ||
| 28 | auth: { status: "authenticated" }, | ||
| 29 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 30 | models: (input.models ?? []).map((slug) => ({ | ||
| 31 | slug, | ||
| 32 | name: slug, | ||
| 33 | isCustom: false, | ||
| 34 | capabilities: {}, | ||
| 35 | })), | ||
| 36 | slashCommands: [], | ||
| 37 | skills: [], | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | function settingsWithProviderInstances(): UnifiedSettings { | ||
| 42 | return { | ||
| 43 | ...DEFAULT_UNIFIED_SETTINGS, | ||
| 44 | providerInstances: { | ||
| 45 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 46 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 47 | config: { customModels: [] }, | ||
| 48 | }, | ||
| 49 | [ProviderInstanceId.make("claude_openrouter")]: { | ||
| 50 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 51 | config: { customModels: ["openai/gpt-5.5"] }, | ||
| 52 | }, | ||
| 53 | }, | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 57 | describe("instance-scoped model selection", () => { | ||
| 58 | it("keeps custom models on the provider instance that declared them", () => { | ||
| 59 | const providers = [ | ||
| 60 | provider({ | ||
| 61 | instanceId: "claudeAgent", | ||
| 62 | models: ["claude-sonnet-4-6"], | ||
| 63 | }), | ||
| 64 | provider({ | ||
| 65 | instanceId: "claude_openrouter", | ||
| 66 | models: ["claude-sonnet-4-6"], | ||
| 67 | }), | ||
| 68 | ]; | ||
| 69 | const entries = deriveProviderInstanceEntries(providers); | ||
| 70 | const stock = entries.find((entry) => entry.instanceId === "claudeAgent")!; | ||
| 71 | const openrouter = entries.find((entry) => entry.instanceId === "claude_openrouter")!; | ||
| 72 | |||
| 73 | expect( | ||
| 74 | getAppModelOptionsForInstance(settingsWithProviderInstances(), stock).map( | ||
| 75 | (option) => option.slug, | ||
| 76 | ), | ||
| 77 | ).not.toContain("openai/gpt-5.5"); | ||
| 78 | expect( | ||
| 79 | getAppModelOptionsForInstance(settingsWithProviderInstances(), openrouter).map( | ||
| 80 | (option) => option.slug, | ||
| 81 | ), | ||
| 82 | ).toContain("openai/gpt-5.5"); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("resolves a custom slug against the selected custom instance", () => { | ||
| 86 | const providers = [ | ||
| 87 | provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), | ||
| 88 | provider({ | ||
| 89 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 90 | instanceId: "claude_openrouter", | ||
| 91 | }), | ||
| 92 | ]; | ||
| 93 | |||
| 94 | expect( | ||
| 95 | resolveAppModelSelectionForInstance( | ||
| 96 | ProviderInstanceId.make("claude_openrouter"), | ||
| 97 | settingsWithProviderInstances(), | ||
| 98 | providers, | ||
| 99 | "openai/gpt-5.5", | ||
| 100 | ), | ||
| 101 | ).toBe("openai/gpt-5.5"); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("does not inject an unknown selected slug into the stock instance list", () => { | ||
| 105 | const providers = [ | ||
| 106 | provider({ | ||
| 107 | instanceId: "claudeAgent", | ||
| 108 | models: ["claude-sonnet-4-6"], | ||
| 109 | }), | ||
| 110 | provider({ | ||
| 111 | instanceId: "claude_openrouter", | ||
| 112 | models: ["claude-sonnet-4-6"], | ||
| 113 | }), | ||
| 114 | ]; | ||
| 115 | const stock = deriveProviderInstanceEntries(providers).find( | ||
| 116 | (entry) => entry.instanceId === "claudeAgent", | ||
| 117 | )!; | ||
| 118 | |||
| 119 | expect( | ||
| 120 | getAppModelOptionsForInstance(settingsWithProviderInstances(), stock).map( | ||
| 121 | (option) => option.slug, | ||
| 122 | ), | ||
| 123 | ).not.toContain("openai/gpt-5.5"); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("hides server models from the instance option list", () => { | ||
| 127 | const providers = [ | ||
| 128 | provider({ | ||
| 129 | instanceId: "claudeAgent", | ||
| 130 | models: ["claude-opus-4-6", "claude-sonnet-4-6"], | ||
| 131 | }), | ||
| 132 | ]; | ||
| 133 | const settings: UnifiedSettings = { | ||
| 134 | ...settingsWithProviderInstances(), | ||
| 135 | providerModelPreferences: { | ||
| 136 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 137 | hiddenModels: ["claude-opus-4-6"], | ||
| 138 | modelOrder: [], | ||
| 139 | }, | ||
| 140 | }, | ||
| 141 | }; | ||
| 142 | const stock = deriveProviderInstanceEntries(providers).find( | ||
| 143 | (entry) => entry.instanceId === "claudeAgent", | ||
| 144 | )!; | ||
| 145 | |||
| 146 | expect(getAppModelOptionsForInstance(settings, stock).map((option) => option.slug)).toEqual([ | ||
| 147 | "claude-sonnet-4-6", | ||
| 148 | ]); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it("applies persisted per-instance model ordering", () => { | ||
| 152 | const providers = [ | ||
| 153 | provider({ | ||
| 154 | instanceId: "claudeAgent", | ||
| 155 | models: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"], | ||
| 156 | }), | ||
| 157 | ]; | ||
| 158 | const settings: UnifiedSettings = { | ||
| 159 | ...settingsWithProviderInstances(), | ||
| 160 | providerModelPreferences: { | ||
| 161 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 162 | hiddenModels: [], | ||
| 163 | modelOrder: ["claude-haiku-4-5", "claude-opus-4-6"], | ||
| 164 | }, | ||
| 165 | }, | ||
| 166 | }; | ||
| 167 | const stock = deriveProviderInstanceEntries(providers).find( | ||
| 168 | (entry) => entry.instanceId === "claudeAgent", | ||
| 169 | )!; | ||
| 170 | |||
| 171 | expect(getAppModelOptionsForInstance(settings, stock).map((option) => option.slug)).toEqual([ | ||
| 172 | "claude-haiku-4-5", | ||
| 173 | "claude-opus-4-6", | ||
| 174 | "claude-sonnet-4-6", | ||
| 175 | ]); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("falls back when the selected model is hidden", () => { | ||
| 179 | const providers = [ | ||
| 180 | provider({ | ||
| 181 | instanceId: "claudeAgent", | ||
| 182 | models: ["claude-opus-4-6", "claude-sonnet-4-6"], | ||
| 183 | }), | ||
| 184 | ]; | ||
| 185 | const settings: UnifiedSettings = { | ||
| 186 | ...settingsWithProviderInstances(), | ||
| 187 | providerModelPreferences: { | ||
| 188 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 189 | hiddenModels: ["claude-opus-4-6"], | ||
| 190 | modelOrder: [], | ||
| 191 | }, | ||
| 192 | }, | ||
| 193 | }; | ||
| 194 | |||
| 195 | expect( | ||
| 196 | resolveAppModelSelectionForInstance( | ||
| 197 | ProviderInstanceId.make("claudeAgent"), | ||
| 198 | settings, | ||
| 199 | providers, | ||
| 200 | "claude-opus-4-6", | ||
| 201 | ), | ||
| 202 | ).toBe("claude-sonnet-4-6"); | ||
| 203 | }); | ||
| 204 | |||
| 205 | it("falls back instead of resolving a custom slug against the wrong instance", () => { | ||
| 206 | const providers = [ | ||
| 207 | provider({ | ||
| 208 | instanceId: "claudeAgent", | ||
| 209 | models: ["claude-sonnet-4-6"], | ||
| 210 | }), | ||
| 211 | provider({ | ||
| 212 | instanceId: "claude_openrouter", | ||
| 213 | models: ["claude-sonnet-4-6"], | ||
| 214 | }), | ||
| 215 | ]; | ||
| 216 | |||
| 217 | expect( | ||
| 218 | resolveAppModelSelectionForInstance( | ||
| 219 | ProviderInstanceId.make("claudeAgent"), | ||
| 220 | settingsWithProviderInstances(), | ||
| 221 | providers, | ||
| 222 | "openai/gpt-5.5", | ||
| 223 | ), | ||
| 224 | ).toBe("claude-sonnet-4-6"); | ||
| 225 | }); | ||
| 226 | |||
| 227 | it("preserves custom provider instances in settings model selection", () => { | ||
| 228 | const providers = [ | ||
| 229 | provider({ | ||
| 230 | instanceId: "claudeAgent", | ||
| 231 | models: ["claude-sonnet-4-6"], | ||
| 232 | }), | ||
| 233 | provider({ | ||
| 234 | instanceId: "claude_openrouter", | ||
| 235 | models: ["claude-sonnet-4-6"], | ||
| 236 | }), | ||
| 237 | ]; | ||
| 238 | const settings: UnifiedSettings = { | ||
| 239 | ...settingsWithProviderInstances(), | ||
| 240 | textGenerationModelSelection: { | ||
| 241 | instanceId: ProviderInstanceId.make("claude_openrouter"), | ||
| 242 | model: "openai/gpt-5.5", | ||
| 243 | }, | ||
| 244 | }; | ||
| 245 | |||
| 246 | expect(resolveAppModelSelectionState(settings, providers)).toEqual({ | ||
| 247 | instanceId: ProviderInstanceId.make("claude_openrouter"), | ||
| 248 | model: "openai/gpt-5.5", | ||
| 249 | }); | ||
| 250 | }); | ||
| 251 | }); | ||
apps/web/src/modelSelection.ts+204-67| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | ||
| 2 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 3 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 4 | defaultInstanceIdForDriver, | ||
| 3 | type ModelSelection, | 5 | type ModelSelection, |
| 4 | type ProviderKind, | 6 | ProviderDriverKind, |
| 7 | ProviderInstanceId, | ||
| 5 | type ServerProvider, | 8 | type ServerProvider, |
| 6 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 7 | import { | 10 | import { |
| ... | @@ -17,9 +20,52 @@ import { | ... | @@ -17,9 +20,52 @@ import { |
| 17 | resolveSelectableProvider, | 20 | resolveSelectableProvider, |
| 18 | } from "./providerModels"; | 21 | } from "./providerModels"; |
| 19 | import { ModelEsque } from "./components/chat/providerIconUtils"; | 22 | import { ModelEsque } from "./components/chat/providerIconUtils"; |
| 23 | import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances"; | ||
| 24 | import { sortModelsForProviderInstance } from "./modelOrdering"; | ||
| 20 | 25 | ||
| 21 | const MAX_CUSTOM_MODEL_COUNT = 32; | 26 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 22 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | 27 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 28 | const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Resolve the custom-model list for a given instance, preferring the | ||
| 32 | * instance's own `providerInstances[id].config.customModels` blob when | ||
| 33 | * present and falling back to the legacy per-kind | ||
| 34 | * `settings.providers[kind].customModels` bucket for default instances only. | ||
| 35 | * | ||
| 36 | * The Settings UI promotes the legacy bucket into an explicit | ||
| 37 | * `providerInstances[defaultId]` entry on every edit (the "migrate on | ||
| 38 | * first write" scheme documented in | ||
| 39 | * `ProviderInstanceRegistryHydration`), so this helper exists primarily | ||
| 40 | * so readers pick up that promotion immediately — and so first-time | ||
| 41 | * viewers on pre-migration settings still see their legacy list on | ||
| 42 | * default slots. Custom instances intentionally do not read the legacy | ||
| 43 | * per-driver bucket; otherwise one custom model added to `claude_openrouter` | ||
| 44 | * can appear on the stock `claudeAgent` instance. | ||
| 45 | */ | ||
| 46 | function readInstanceCustomModels( | ||
| 47 | settings: UnifiedSettings, | ||
| 48 | instanceId: ProviderInstanceId, | ||
| 49 | driverKind: ProviderDriverKind, | ||
| 50 | ): ReadonlyArray<string> { | ||
| 51 | const instance = settings.providerInstances?.[instanceId]; | ||
| 52 | const config = instance?.config; | ||
| 53 | if (config !== null && typeof config === "object") { | ||
| 54 | const value = (config as Record<string, unknown>).customModels; | ||
| 55 | if (Array.isArray(value)) { | ||
| 56 | return value.filter((entry): entry is string => typeof entry === "string"); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | ||
| 60 | if (instanceId !== defaultInstanceId) { | ||
| 61 | return []; | ||
| 62 | } | ||
| 63 | const legacyProviders = settings.providers as Record< | ||
| 64 | string, | ||
| 65 | { readonly customModels: ReadonlyArray<string> } | undefined | ||
| 66 | >; | ||
| 67 | return legacyProviders[driverKind]?.customModels ?? []; | ||
| 68 | } | ||
| 23 | 69 | ||
| 24 | export interface AppModelOption { | 70 | export interface AppModelOption { |
| 25 | slug: string; | 71 | slug: string; |
| ... | @@ -29,10 +75,47 @@ export interface AppModelOption { | ... | @@ -29,10 +75,47 @@ export interface AppModelOption { |
| 29 | isCustom: boolean; | 75 | isCustom: boolean; |
| 30 | } | 76 | } |
| 31 | 77 | ||
| 78 | function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { | ||
| 79 | const option: AppModelOption = { | ||
| 80 | slug: model.slug, | ||
| 81 | name: model.name, | ||
| 82 | isCustom: model.isCustom, | ||
| 83 | }; | ||
| 84 | if (model.shortName) option.shortName = model.shortName; | ||
| 85 | if (model.subProvider) option.subProvider = model.subProvider; | ||
| 86 | return option; | ||
| 87 | } | ||
| 88 | |||
| 89 | function readInstanceModelPreferences( | ||
| 90 | settings: UnifiedSettings, | ||
| 91 | instanceId: ProviderInstanceId, | ||
| 92 | ): { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string> } { | ||
| 93 | return ( | ||
| 94 | settings.providerModelPreferences?.[instanceId] ?? { | ||
| 95 | hiddenModels: [], | ||
| 96 | modelOrder: [], | ||
| 97 | } | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | function applyInstanceModelPreferences( | ||
| 102 | options: ReadonlyArray<AppModelOption>, | ||
| 103 | preferences: { | ||
| 104 | readonly hiddenModels: ReadonlyArray<string>; | ||
| 105 | readonly modelOrder: ReadonlyArray<string>; | ||
| 106 | }, | ||
| 107 | ): AppModelOption[] { | ||
| 108 | const hiddenModels = new Set(preferences.hiddenModels); | ||
| 109 | return sortModelsForProviderInstance( | ||
| 110 | options.filter((option) => option.isCustom || !hiddenModels.has(option.slug)), | ||
| 111 | { modelOrder: preferences.modelOrder }, | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | |||
| 32 | export function normalizeCustomModelSlugs( | 115 | export function normalizeCustomModelSlugs( |
| 33 | models: Iterable<string | null | undefined>, | 116 | models: Iterable<string | null | undefined>, |
| 34 | builtInModelSlugs: ReadonlySet<string>, | 117 | builtInModelSlugs: ReadonlySet<string>, |
| 35 | provider: ProviderKind = "codex", | 118 | provider: ProviderDriverKind = ProviderDriverKind.make("codex"), |
| 36 | ): string[] { | 119 | ): string[] { |
| 37 | const normalizedModels: string[] = []; | 120 | const normalizedModels: string[] = []; |
| 38 | const seen = new Set<string>(); | 121 | const seen = new Set<string>(); |
| ... | @@ -61,27 +144,23 @@ export function normalizeCustomModelSlugs( | ... | @@ -61,27 +144,23 @@ export function normalizeCustomModelSlugs( |
| 61 | export function getAppModelOptions( | 144 | export function getAppModelOptions( |
| 62 | settings: UnifiedSettings, | 145 | settings: UnifiedSettings, |
| 63 | providers: ReadonlyArray<ServerProvider>, | 146 | providers: ReadonlyArray<ServerProvider>, |
| 64 | provider: ProviderKind, | 147 | provider: ProviderDriverKind, |
| 65 | selectedModel?: string | null, | 148 | _selectedModel?: string | null, |
| 66 | ): AppModelOption[] { | 149 | ): AppModelOption[] { |
| 67 | const options: AppModelOption[] = getProviderModels(providers, provider).map( | 150 | const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption); |
| 68 | ({ slug, name, shortName, subProvider, isCustom }) => ({ | ||
| 69 | slug, | ||
| 70 | name, | ||
| 71 | ...(shortName ? { shortName } : {}), | ||
| 72 | ...(subProvider ? { subProvider } : {}), | ||
| 73 | isCustom, | ||
| 74 | }), | ||
| 75 | ); | ||
| 76 | const seen = new Set(options.map((option) => option.slug)); | 151 | const seen = new Set(options.map((option) => option.slug)); |
| 77 | const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); | ||
| 78 | const builtInModelSlugs = new Set( | 152 | const builtInModelSlugs = new Set( |
| 79 | getProviderModels(providers, provider) | 153 | getProviderModels(providers, provider) |
| 80 | .filter((model) => !model.isCustom) | 154 | .filter((model) => !model.isCustom) |
| 81 | .map((model) => model.slug), | 155 | .map((model) => model.slug), |
| 82 | ); | 156 | ); |
| 83 | 157 | ||
| 84 | const customModels = settings.providers[provider].customModels; | 158 | // Read from the default instance's config first (that's where edits |
| 159 | // now land), falling back to the legacy per-kind bucket so unmigrated | ||
| 160 | // settings and the initial render before the first write both still | ||
| 161 | // see the user's authored custom models. | ||
| 162 | const defaultInstanceId = defaultInstanceIdForDriver(provider); | ||
| 163 | const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); | ||
| 85 | for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) { | 164 | for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) { |
| 86 | if (seen.has(slug)) { | 165 | if (seen.has(slug)) { |
| 87 | continue; | 166 | continue; |
| ... | @@ -95,27 +174,52 @@ export function getAppModelOptions( | ... | @@ -95,27 +174,52 @@ export function getAppModelOptions( |
| 95 | }); | 174 | }); |
| 96 | } | 175 | } |
| 97 | 176 | ||
| 98 | const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); | 177 | return applyInstanceModelPreferences( |
| 99 | const selectedModelMatchesExistingName = | 178 | options, |
| 100 | typeof trimmedSelectedModel === "string" && | 179 | readInstanceModelPreferences(settings, defaultInstanceId), |
| 101 | options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); | 180 | ); |
| 102 | if ( | 181 | } |
| 103 | normalizedSelectedModel && | 182 | |
| 104 | !seen.has(normalizedSelectedModel) && | 183 | /** |
| 105 | !selectedModelMatchesExistingName | 184 | * Instance-scoped variant of {@link getAppModelOptions}. Built-in models |
| 106 | ) { | 185 | * come from the instance's own `entry.models` snapshot (rather than the |
| 107 | options.push({ | 186 | * first-matching-kind fallback in `getProviderModels`), so each custom |
| 108 | slug: normalizedSelectedModel, | 187 | * instance gets the precise model list its driver reported. Custom model |
| 109 | name: normalizedSelectedModel, | 188 | * slugs come from the instance's own `providerInstances[id].config.customModels` |
| 110 | isCustom: true, | 189 | * when present, falling back to the legacy per-kind |
| 111 | }); | 190 | * `settings.providers[driverKind].customModels` bucket for default |
| 191 | * instances only. This keeps two instances of the same kind from leaking | ||
| 192 | * custom slugs into each other. | ||
| 193 | */ | ||
| 194 | export function getAppModelOptionsForInstance( | ||
| 195 | settings: UnifiedSettings, | ||
| 196 | entry: ProviderInstanceEntry, | ||
| 197 | ): AppModelOption[] { | ||
| 198 | const options: AppModelOption[] = entry.models.map(toAppModelOption); | ||
| 199 | const seen = new Set(options.map((option) => option.slug)); | ||
| 200 | const builtInModelSlugs = new Set( | ||
| 201 | entry.models.filter((model) => !model.isCustom).map((model) => model.slug), | ||
| 202 | ); | ||
| 203 | |||
| 204 | const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); | ||
| 205 | const normalizer = entry.driverKind; | ||
| 206 | for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, normalizer)) { | ||
| 207 | if (seen.has(slug)) { | ||
| 208 | continue; | ||
| 209 | } | ||
| 210 | |||
| 211 | seen.add(slug); | ||
| 212 | options.push({ slug, name: slug, isCustom: true }); | ||
| 112 | } | 213 | } |
| 113 | 214 | ||
| 114 | return options; | 215 | return applyInstanceModelPreferences( |
| 216 | options, | ||
| 217 | readInstanceModelPreferences(settings, entry.instanceId), | ||
| 218 | ); | ||
| 115 | } | 219 | } |
| 116 | 220 | ||
| 117 | export function resolveAppModelSelection( | 221 | export function resolveAppModelSelection( |
| 118 | provider: ProviderKind, | 222 | provider: ProviderDriverKind, |
| 119 | settings: UnifiedSettings, | 223 | settings: UnifiedSettings, |
| 120 | providers: ReadonlyArray<ServerProvider>, | 224 | providers: ReadonlyArray<ServerProvider>, |
| 121 | selectedModel: string | null | undefined, | 225 | selectedModel: string | null | undefined, |
| ... | @@ -128,38 +232,41 @@ export function resolveAppModelSelection( | ... | @@ -128,38 +232,41 @@ export function resolveAppModelSelection( |
| 128 | ); | 232 | ); |
| 129 | } | 233 | } |
| 130 | 234 | ||
| 131 | export function getCustomModelOptionsByProvider( | 235 | export function resolveAppModelSelectionForInstance( |
| 236 | instanceId: ProviderInstanceId, | ||
| 132 | settings: UnifiedSettings, | 237 | settings: UnifiedSettings, |
| 133 | providers: ReadonlyArray<ServerProvider>, | 238 | providers: ReadonlyArray<ServerProvider>, |
| 134 | selectedProvider?: ProviderKind | null, | 239 | selectedModel: string | null | undefined, |
| 135 | selectedModel?: string | null, | 240 | ): string | null { |
| 136 | ): Record<ProviderKind, ReadonlyArray<ModelEsque>> { | 241 | const entry = deriveProviderInstanceEntries(providers).find( |
| 137 | return { | 242 | (candidate) => candidate.instanceId === instanceId, |
| 138 | codex: getAppModelOptions( | 243 | ); |
| 139 | settings, | 244 | if (!entry) return null; |
| 140 | providers, | 245 | const options = getAppModelOptionsForInstance(settings, entry); |
| 141 | "codex", | 246 | return ( |
| 142 | selectedProvider === "codex" ? selectedModel : undefined, | 247 | resolveSelectableModel(entry.driverKind, selectedModel, options) ?? |
| 143 | ), | 248 | options[0]?.slug ?? |
| 144 | claudeAgent: getAppModelOptions( | 249 | entry.models[0]?.slug ?? |
| 145 | settings, | 250 | null |
| 146 | providers, | 251 | ); |
| 147 | "claudeAgent", | 252 | } |
| 148 | selectedProvider === "claudeAgent" ? selectedModel : undefined, | 253 | |
| 149 | ), | 254 | /** |
| 150 | cursor: getAppModelOptions( | 255 | * Instance-keyed model options map. Each configured instance gets its own |
| 151 | settings, | 256 | * option list so the model picker can show the same driver's built-in and |
| 152 | providers, | 257 | * custom instances side by side without collapsing them. |
| 153 | "cursor", | 258 | */ |
| 154 | selectedProvider === "cursor" ? selectedModel : undefined, | 259 | export function getCustomModelOptionsByInstance( |
| 155 | ), | 260 | settings: UnifiedSettings, |
| 156 | opencode: getAppModelOptions( | 261 | providers: ReadonlyArray<ServerProvider>, |
| 157 | settings, | 262 | _selectedInstanceId?: ProviderInstanceId | null, |
| 158 | providers, | 263 | _selectedModel?: string | null, |
| 159 | "opencode", | 264 | ): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> { |
| 160 | selectedProvider === "opencode" ? selectedModel : undefined, | 265 | const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>(); |
| 161 | ), | 266 | for (const entry of deriveProviderInstanceEntries(providers)) { |
| 162 | }; | 267 | out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); |
| 268 | } | ||
| 269 | return out; | ||
| 163 | } | 270 | } |
| 164 | 271 | ||
| 165 | export function resolveAppModelSelectionState( | 272 | export function resolveAppModelSelectionState( |
| ... | @@ -167,22 +274,52 @@ export function resolveAppModelSelectionState( | ... | @@ -167,22 +274,52 @@ export function resolveAppModelSelectionState( |
| 167 | providers: ReadonlyArray<ServerProvider>, | 274 | providers: ReadonlyArray<ServerProvider>, |
| 168 | ): ModelSelection { | 275 | ): ModelSelection { |
| 169 | const selection = settings.textGenerationModelSelection ?? { | 276 | const selection = settings.textGenerationModelSelection ?? { |
| 170 | provider: "codex" as const, | 277 | instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, |
| 171 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | 278 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL, |
| 172 | }; | 279 | }; |
| 173 | const provider = resolveSelectableProvider(providers, selection.provider); | 280 | const entries = deriveProviderInstanceEntries(providers); |
| 281 | const selectedEntry = entries.find( | ||
| 282 | (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, | ||
| 283 | ); | ||
| 284 | const entry = | ||
| 285 | selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); | ||
| 286 | if (entry) { | ||
| 287 | // When the instance changed due to fallback (e.g. selected instance was disabled), | ||
| 288 | // don't carry over the old instance's model — use the fallback instance's default. | ||
| 289 | const selectedModel = selectedEntry ? selection.model : null; | ||
| 290 | const model = | ||
| 291 | resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ?? | ||
| 292 | entry.models[0]?.slug ?? | ||
| 293 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; | ||
| 294 | if (!model) { | ||
| 295 | return createModelSelection(entry.instanceId, "", []); | ||
| 296 | } | ||
| 297 | const provider = entry.driverKind; | ||
| 298 | const { modelOptionsForDispatch } = getComposerProviderState({ | ||
| 299 | provider, | ||
| 300 | model, | ||
| 301 | models: entry.models, | ||
| 302 | prompt: "", | ||
| 303 | modelOptions: selectedEntry ? selection.options : undefined, | ||
| 304 | }); | ||
| 305 | |||
| 306 | return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); | ||
| 307 | } | ||
| 308 | |||
| 309 | const provider = resolveSelectableProvider(providers, null); | ||
| 310 | const keptSelectedProvider = false; | ||
| 174 | 311 | ||
| 175 | // When the provider changed due to fallback (e.g. selected provider was disabled), | 312 | // When the provider changed due to fallback (e.g. selected provider was disabled), |
| 176 | // don't carry over the old provider's model — use the fallback provider's default. | 313 | // don't carry over the old provider's model — use the fallback provider's default. |
| 177 | const selectedModel = provider === selection.provider ? selection.model : null; | 314 | const selectedModel = keptSelectedProvider ? selection.model : null; |
| 178 | const model = resolveAppModelSelection(provider, settings, providers, selectedModel); | 315 | const model = resolveAppModelSelection(provider, settings, providers, selectedModel); |
| 179 | const { modelOptionsForDispatch } = getComposerProviderState({ | 316 | const { modelOptionsForDispatch } = getComposerProviderState({ |
| 180 | provider, | 317 | provider, |
| 181 | model, | 318 | model, |
| 182 | models: getProviderModels(providers, provider), | 319 | models: getProviderModels(providers, provider), |
| 183 | prompt: "", | 320 | prompt: "", |
| 184 | modelOptions: provider === selection.provider ? selection.options : undefined, | 321 | modelOptions: keptSelectedProvider ? selection.options : undefined, |
| 185 | }); | 322 | }); |
| 186 | 323 | ||
| 187 | return createModelSelection(provider, model, modelOptionsForDispatch); | 324 | return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch); |
| 188 | } | 325 | } |
apps/web/src/orchestrationEventEffects.test.ts+3-2| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | EventId, | 3 | EventId, |
| 4 | MessageId, | 4 | MessageId, |
| 5 | ProjectId, | 5 | ProjectId, |
| 6 | ProviderInstanceId, | ||
| 6 | ThreadId, | 7 | ThreadId, |
| 7 | TurnId, | 8 | TurnId, |
| 8 | type OrchestrationEvent, | 9 | type OrchestrationEvent, |
| ... | @@ -49,7 +50,7 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -49,7 +50,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 49 | threadId: createdThreadId, | 50 | threadId: createdThreadId, |
| 50 | projectId: ProjectId.make("project-1"), | 51 | projectId: ProjectId.make("project-1"), |
| 51 | title: "Created thread", | 52 | title: "Created thread", |
| 52 | modelSelection: { provider: "codex", model: "gpt-5-codex" }, | 53 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, |
| 53 | runtimeMode: "full-access", | 54 | runtimeMode: "full-access", |
| 54 | interactionMode: "default", | 55 | interactionMode: "default", |
| 55 | branch: null, | 56 | branch: null, |
| ... | @@ -86,7 +87,7 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -86,7 +87,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 86 | threadId, | 87 | threadId, |
| 87 | projectId: ProjectId.make("project-1"), | 88 | projectId: ProjectId.make("project-1"), |
| 88 | title: "Recreated thread", | 89 | title: "Recreated thread", |
| 89 | modelSelection: { provider: "codex", model: "gpt-5-codex" }, | 90 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, |
| 90 | runtimeMode: "full-access", | 91 | runtimeMode: "full-access", |
| 91 | interactionMode: "default", | 92 | interactionMode: "default", |
| 92 | branch: null, | 93 | branch: null, |
apps/web/src/providerInstances.test.ts created+132| ... | @@ -0,0 +1,132 @@ | ||
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | import { | ||
| 4 | deriveProviderInstanceEntries, | ||
| 5 | resolveSelectableProviderInstance, | ||
| 6 | resolveProviderDriverKindForInstanceSelection, | ||
| 7 | } from "./providerInstances"; | ||
| 8 | |||
| 9 | function provider(input: { | ||
| 10 | provider: ProviderDriverKind; | ||
| 11 | instanceId: string; | ||
| 12 | enabled?: boolean; | ||
| 13 | availability?: ServerProvider["availability"]; | ||
| 14 | displayName?: string; | ||
| 15 | }): ServerProvider { | ||
| 16 | return { | ||
| 17 | instanceId: ProviderInstanceId.make(input.instanceId), | ||
| 18 | driver: input.provider, | ||
| 19 | ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| 20 | enabled: input.enabled ?? true, | ||
| 21 | installed: true, | ||
| 22 | version: null, | ||
| 23 | status: "ready", | ||
| 24 | ...(input.availability ? { availability: input.availability } : {}), | ||
| 25 | auth: { status: "authenticated" }, | ||
| 26 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 27 | models: [], | ||
| 28 | slashCommands: [], | ||
| 29 | skills: [], | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("deriveProviderInstanceEntries", () => { | ||
| 34 | it("uses explicit instance id and driver kind from the snapshot", () => { | ||
| 35 | const snapshot = provider({ | ||
| 36 | provider: ProviderDriverKind.make("codex"), | ||
| 37 | instanceId: "codex_personal", | ||
| 38 | }); | ||
| 39 | const [entry] = deriveProviderInstanceEntries([snapshot]); | ||
| 40 | |||
| 41 | expect(entry?.instanceId).toBe("codex_personal"); | ||
| 42 | expect(entry?.driverKind).toBe("codex"); | ||
| 43 | expect(entry?.isDefault).toBe(false); | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | |||
| 47 | describe("resolveSelectableProviderInstance", () => { | ||
| 48 | it("returns the requested instance when it is enabled and available", () => { | ||
| 49 | const requested = ProviderInstanceId.make("claude_work"); | ||
| 50 | const providers = [ | ||
| 51 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), | ||
| 52 | provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: requested }), | ||
| 53 | ]; | ||
| 54 | |||
| 55 | expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("falls back to the first enabled and available instance", () => { | ||
| 59 | const disabled = ProviderInstanceId.make("codex"); | ||
| 60 | const fallback = ProviderInstanceId.make("claudeAgent"); | ||
| 61 | const providers = [ | ||
| 62 | provider({ | ||
| 63 | provider: ProviderDriverKind.make("codex"), | ||
| 64 | instanceId: disabled, | ||
| 65 | enabled: false, | ||
| 66 | }), | ||
| 67 | provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: fallback }), | ||
| 68 | ]; | ||
| 69 | |||
| 70 | expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { | ||
| 74 | const disabled = ProviderInstanceId.make("codex"); | ||
| 75 | const unavailable = ProviderInstanceId.make("claudeAgent"); | ||
| 76 | const unknown = ProviderInstanceId.make("removed_instance"); | ||
| 77 | const providers = [ | ||
| 78 | provider({ | ||
| 79 | provider: ProviderDriverKind.make("codex"), | ||
| 80 | instanceId: disabled, | ||
| 81 | enabled: false, | ||
| 82 | }), | ||
| 83 | provider({ | ||
| 84 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 85 | instanceId: unavailable, | ||
| 86 | availability: "unavailable", | ||
| 87 | }), | ||
| 88 | ]; | ||
| 89 | |||
| 90 | expect(resolveSelectableProviderInstance(providers, disabled)).toBeUndefined(); | ||
| 91 | expect(resolveSelectableProviderInstance(providers, unavailable)).toBeUndefined(); | ||
| 92 | expect(resolveSelectableProviderInstance(providers, unknown)).toBeUndefined(); | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | describe("resolveProviderDriverKindForInstanceSelection", () => { | ||
| 97 | it("maps custom provider instance ids back to their driver kind", () => { | ||
| 98 | const providers = [ | ||
| 99 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), | ||
| 100 | provider({ | ||
| 101 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 102 | instanceId: "claude_openrouter", | ||
| 103 | displayName: "Claude OpenRouter", | ||
| 104 | }), | ||
| 105 | ]; | ||
| 106 | const entries = deriveProviderInstanceEntries(providers); | ||
| 107 | |||
| 108 | expect( | ||
| 109 | resolveProviderDriverKindForInstanceSelection( | ||
| 110 | entries, | ||
| 111 | providers, | ||
| 112 | ProviderInstanceId.make("claude_openrouter"), | ||
| 113 | ), | ||
| 114 | ).toBe("claudeAgent"); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("does not guess a provider kind when the instance selection is unknown", () => { | ||
| 118 | const providers = [ | ||
| 119 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), | ||
| 120 | provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), | ||
| 121 | ]; | ||
| 122 | const entries = deriveProviderInstanceEntries(providers); | ||
| 123 | |||
| 124 | expect( | ||
| 125 | resolveProviderDriverKindForInstanceSelection( | ||
| 126 | entries, | ||
| 127 | providers, | ||
| 128 | ProviderInstanceId.make("removed_instance"), | ||
| 129 | ), | ||
| 130 | ).toBeUndefined(); | ||
| 131 | }); | ||
| 132 | }); | ||
apps/web/src/providerInstances.ts created+248| ... | @@ -0,0 +1,248 @@ | ||
| 1 | /** | ||
| 2 | * Instance-aware view over the wire `ServerProvider[]`. | ||
| 3 | * | ||
| 4 | * The wire carries one `ServerProvider` per *configured instance* — the | ||
| 5 | * default built-in codex instance, a user-authored `codex_personal`, an | ||
| 6 | * unavailable shadow for a fork driver, etc. Legacy UI code collapsed these | ||
| 7 | * into a single bucket per built-in driver via `.find((p) => p.driver === kind)`, | ||
| 8 | * which silently dropped every custom instance after the first. This module | ||
| 9 | * replaces that pattern with `ProviderInstanceEntry[]`, keyed on | ||
| 10 | * `ProviderInstanceId`, so the model picker, settings list, and composer | ||
| 11 | * can treat built-in and custom instances uniformly. | ||
| 12 | * | ||
| 13 | * @module providerInstances | ||
| 14 | */ | ||
| 15 | import { | ||
| 16 | defaultInstanceIdForDriver, | ||
| 17 | PROVIDER_DISPLAY_NAMES, | ||
| 18 | type ProviderDriverKind, | ||
| 19 | type ProviderInstanceId, | ||
| 20 | type ServerProvider, | ||
| 21 | type ServerProviderModel, | ||
| 22 | type ServerProviderState, | ||
| 23 | } from "@t3tools/contracts"; | ||
| 24 | |||
| 25 | import { formatProviderDriverKindLabel } from "./providerModels"; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * UI-facing projection of one configured provider instance. Carries the | ||
| 29 | * snapshot verbatim for callers that need server-side fields we don't | ||
| 30 | * hoist here, plus the precomputed `instanceId` / `driverKind` / | ||
| 31 | * `displayName` used by every picker and settings view. | ||
| 32 | */ | ||
| 33 | export interface ProviderInstanceEntry { | ||
| 34 | readonly instanceId: ProviderInstanceId; | ||
| 35 | readonly driverKind: ProviderDriverKind; | ||
| 36 | readonly displayName: string; | ||
| 37 | readonly accentColor?: string | undefined; | ||
| 38 | readonly continuationGroupKey?: string | undefined; | ||
| 39 | readonly enabled: boolean; | ||
| 40 | readonly installed: boolean; | ||
| 41 | readonly status: ServerProviderState; | ||
| 42 | /** | ||
| 43 | * True when this entry is the default instance for its driver kind — | ||
| 44 | * i.e. its instance id equals `defaultInstanceIdForDriver(driverKind)`. | ||
| 45 | * The settings panel and picker sort defaults before customs. | ||
| 46 | */ | ||
| 47 | readonly isDefault: boolean; | ||
| 48 | /** True when `availability === "unavailable"` is absent or "available". */ | ||
| 49 | readonly isAvailable: boolean; | ||
| 50 | readonly snapshot: ServerProvider; | ||
| 51 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 52 | } | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Turn an instance id slug into a human-readable label. Splits on `_` / `-` | ||
| 56 | * and camelCase boundaries and title-cases each token, so `codex_personal` | ||
| 57 | * becomes "Codex Personal" and `myCustomInstance` becomes "My Custom | ||
| 58 | * Instance". | ||
| 59 | * | ||
| 60 | * This is a fallback used only when the wire snapshot's `displayName` | ||
| 61 | * doesn't disambiguate a non-default instance from the default one of the | ||
| 62 | * same driver (today every built-in driver hard-codes a single presentation | ||
| 63 | * label per kind, so two instances of the same kind arrive with identical | ||
| 64 | * display names). When a server/driver later plumbs the user's configured | ||
| 65 | * `ProviderInstanceConfig.displayName` through to the snapshot, that value | ||
| 66 | * will take precedence over this fallback. | ||
| 67 | */ | ||
| 68 | function humanizeInstanceId(instanceId: ProviderInstanceId): string { | ||
| 69 | return instanceId | ||
| 70 | .replace(/[_-]+/g, " ") | ||
| 71 | .replace(/([a-z])([A-Z])/g, "$1 $2") | ||
| 72 | .split(" ") | ||
| 73 | .filter((token) => token.length > 0) | ||
| 74 | .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) | ||
| 75 | .join(" "); | ||
| 76 | } | ||
| 77 | |||
| 78 | function driverKindLabel(driverKind: ProviderDriverKind): string { | ||
| 79 | return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); | ||
| 80 | } | ||
| 81 | |||
| 82 | export function normalizeProviderAccentColor(value: string | undefined): string | undefined { | ||
| 83 | const trimmed = value?.trim(); | ||
| 84 | if (!trimmed) return undefined; | ||
| 85 | return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; | ||
| 86 | } | ||
| 87 | |||
| 88 | /** | ||
| 89 | * Resolve an entry's displayName with a tiered priority: | ||
| 90 | * | ||
| 91 | * 1. A snapshot `displayName` that differs from the driver-kind label — | ||
| 92 | * the server has explicitly named this instance, trust it. | ||
| 93 | * 2. For non-default instances, a humanized `instanceId` — the server | ||
| 94 | * fell back to the driver-level presentation constant (which is the | ||
| 95 | * same for every instance of that kind), so we differentiate at the | ||
| 96 | * UI layer by slug. This is what keeps "Codex" + "Codex Personal" | ||
| 97 | * distinguishable in tooltips and list labels today. | ||
| 98 | * 3. The snapshot's `displayName` (if any) — default instance, trust | ||
| 99 | * whatever label the driver stamped. | ||
| 100 | * 4. `driverKindLabel(driverKind)` — nothing else on hand, so use the | ||
| 101 | * canonical brand label from contracts (falling back to a generic | ||
| 102 | * title-case of the kind slug). | ||
| 103 | */ | ||
| 104 | function resolveInstanceDisplayName( | ||
| 105 | snapshot: ServerProvider, | ||
| 106 | instanceId: ProviderInstanceId, | ||
| 107 | driverKind: ProviderDriverKind, | ||
| 108 | isDefault: boolean, | ||
| 109 | ): string { | ||
| 110 | const trimmedSnapshotName = snapshot.displayName?.trim(); | ||
| 111 | const kindLabel = driverKindLabel(driverKind); | ||
| 112 | if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) { | ||
| 113 | return trimmedSnapshotName; | ||
| 114 | } | ||
| 115 | if (!isDefault) { | ||
| 116 | const humanized = humanizeInstanceId(instanceId); | ||
| 117 | if (humanized.length > 0) return humanized; | ||
| 118 | } | ||
| 119 | return trimmedSnapshotName || kindLabel; | ||
| 120 | } | ||
| 121 | |||
| 122 | /** | ||
| 123 | * Project the wire `ServerProvider[]` into instance entries, one per | ||
| 124 | * configured instance. Preserves the server's ordering (which sources | ||
| 125 | * from `deriveProviderInstanceConfigMap` — explicit `providerInstances.*` | ||
| 126 | * first, synthesized defaults after) so callers that want "default first" | ||
| 127 | * should sort with `sortProviderInstanceEntries` below. | ||
| 128 | */ | ||
| 129 | export function deriveProviderInstanceEntries( | ||
| 130 | providers: ReadonlyArray<ServerProvider>, | ||
| 131 | ): ReadonlyArray<ProviderInstanceEntry> { | ||
| 132 | return providers.map((snapshot) => { | ||
| 133 | const instanceId = snapshot.instanceId; | ||
| 134 | const driverKind = snapshot.driver; | ||
| 135 | const defaultId = defaultInstanceIdForDriver(driverKind); | ||
| 136 | const isDefault = instanceId === defaultId; | ||
| 137 | const displayName = resolveInstanceDisplayName(snapshot, instanceId, driverKind, isDefault); | ||
| 138 | return { | ||
| 139 | instanceId, | ||
| 140 | driverKind, | ||
| 141 | displayName, | ||
| 142 | accentColor: normalizeProviderAccentColor(snapshot.accentColor), | ||
| 143 | continuationGroupKey: snapshot.continuation?.groupKey, | ||
| 144 | enabled: snapshot.enabled, | ||
| 145 | installed: snapshot.installed, | ||
| 146 | status: snapshot.status, | ||
| 147 | isDefault, | ||
| 148 | isAvailable: snapshot.availability !== "unavailable", | ||
| 149 | snapshot, | ||
| 150 | models: snapshot.models, | ||
| 151 | } satisfies ProviderInstanceEntry; | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | |||
| 155 | /** | ||
| 156 | * Sort instance entries so the default instance of each driver kind appears | ||
| 157 | * before any custom instances of the same kind. Within a kind, custom | ||
| 158 | * instances keep their settings-author order (which is how the server | ||
| 159 | * emits them). Stable across kinds: entries retain the server's | ||
| 160 | * cross-driver ordering. | ||
| 161 | */ | ||
| 162 | export function sortProviderInstanceEntries( | ||
| 163 | entries: ReadonlyArray<ProviderInstanceEntry>, | ||
| 164 | ): ReadonlyArray<ProviderInstanceEntry> { | ||
| 165 | // Group by driver kind preserving first-appearance order, then emit | ||
| 166 | // default-first within each kind. Using a Map keeps the "first-seen" | ||
| 167 | // semantics for kinds whose default instance is absent (unusual but | ||
| 168 | // possible during the migration). | ||
| 169 | const byKind = new Map<ProviderDriverKind, ProviderInstanceEntry[]>(); | ||
| 170 | for (const entry of entries) { | ||
| 171 | const bucket = byKind.get(entry.driverKind); | ||
| 172 | if (bucket) { | ||
| 173 | bucket.push(entry); | ||
| 174 | } else { | ||
| 175 | byKind.set(entry.driverKind, [entry]); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | const sorted: ProviderInstanceEntry[] = []; | ||
| 179 | for (const bucket of byKind.values()) { | ||
| 180 | const defaults = bucket.filter((entry) => entry.isDefault); | ||
| 181 | const customs = bucket.filter((entry) => !entry.isDefault); | ||
| 182 | sorted.push(...defaults, ...customs); | ||
| 183 | } | ||
| 184 | return sorted; | ||
| 185 | } | ||
| 186 | |||
| 187 | /** | ||
| 188 | * Look up a single instance entry by exact `instanceId`. Missing snapshots | ||
| 189 | * are not inferred from driver kind in UI routing code. | ||
| 190 | */ | ||
| 191 | export function getProviderInstanceEntry( | ||
| 192 | providers: ReadonlyArray<ServerProvider>, | ||
| 193 | instanceId: ProviderInstanceId, | ||
| 194 | ): ProviderInstanceEntry | undefined { | ||
| 195 | return deriveProviderInstanceEntries(providers).find((entry) => entry.instanceId === instanceId); | ||
| 196 | } | ||
| 197 | |||
| 198 | /** | ||
| 199 | * Model list for a specific instance. Returns `[]` when the instance isn't | ||
| 200 | * present so callers don't have to thread optionality through render code. | ||
| 201 | */ | ||
| 202 | export function getProviderInstanceModels( | ||
| 203 | providers: ReadonlyArray<ServerProvider>, | ||
| 204 | instanceId: ProviderInstanceId, | ||
| 205 | ): ReadonlyArray<ServerProviderModel> { | ||
| 206 | return getProviderInstanceEntry(providers, instanceId)?.models ?? []; | ||
| 207 | } | ||
| 208 | |||
| 209 | /** | ||
| 210 | * Resolve the routing key for a selection that may reference an instance | ||
| 211 | * id that no longer exists (e.g. a persisted thread selection after the | ||
| 212 | * user deleted the custom instance). Returns the first enabled instance | ||
| 213 | * as a fallback so downstream code can still send a turn. | ||
| 214 | */ | ||
| 215 | export function resolveSelectableProviderInstance( | ||
| 216 | providers: ReadonlyArray<ServerProvider>, | ||
| 217 | instanceId: ProviderInstanceId | undefined, | ||
| 218 | ): ProviderInstanceId | undefined { | ||
| 219 | if (instanceId === undefined) { | ||
| 220 | return deriveProviderInstanceEntries(providers).find( | ||
| 221 | (entry) => entry.enabled && entry.isAvailable, | ||
| 222 | )?.instanceId; | ||
| 223 | } | ||
| 224 | const entries = deriveProviderInstanceEntries(providers); | ||
| 225 | const requested = entries.find((entry) => entry.instanceId === instanceId); | ||
| 226 | if (requested && requested.enabled && requested.isAvailable) { | ||
| 227 | return instanceId; | ||
| 228 | } | ||
| 229 | return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; | ||
| 230 | } | ||
| 231 | |||
| 232 | /** | ||
| 233 | * Resolve an open model-selection routing key back to a driver kind. | ||
| 234 | * Custom instance ids such as `claude_openrouter` are not themselves | ||
| 235 | * driver-kind slugs, but the composer still needs the owning driver kind | ||
| 236 | * for capabilities, options, icons, and turn dispatch metadata. | ||
| 237 | */ | ||
| 238 | export function resolveProviderDriverKindForInstanceSelection( | ||
| 239 | entries: ReadonlyArray<ProviderInstanceEntry>, | ||
| 240 | providers: ReadonlyArray<ServerProvider>, | ||
| 241 | selection: ProviderInstanceId | ProviderDriverKind | null | undefined, | ||
| 242 | ): ProviderDriverKind | undefined { | ||
| 243 | const matchedEntry = entries.find((entry) => entry.instanceId === selection); | ||
| 244 | if (matchedEntry) { | ||
| 245 | return matchedEntry.driverKind; | ||
| 246 | } | ||
| 247 | return undefined; | ||
| 248 | } | ||
apps/web/src/providerModels.ts+28-19| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_MODEL, | ||
| 2 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | defaultInstanceIdForDriver, | ||
| 5 | ProviderDriverKind, | ||
| 3 | type ModelCapabilities, | 6 | type ModelCapabilities, |
| 4 | type ProviderKind, | 7 | type ProviderInstanceId, |
| 5 | type ServerProvider, | 8 | type ServerProvider, |
| 6 | type ServerProviderModel, | 9 | type ServerProviderModel, |
| 7 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| ... | @@ -10,8 +13,9 @@ import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/mod | ... | @@ -10,8 +13,9 @@ import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/mod |
| 10 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | 13 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 11 | optionDescriptors: [], | 14 | optionDescriptors: [], |
| 12 | }); | 15 | }); |
| 16 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | ||
| 13 | 17 | ||
| 14 | export function formatProviderKindLabel(provider: ProviderKind): string { | 18 | export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string { |
| 15 | return provider | 19 | return provider |
| 16 | .replace(/([a-z])([A-Z])/g, "$1 $2") | 20 | .replace(/([a-z])([A-Z])/g, "$1 $2") |
| 17 | .replace(/[_-]+/g, " ") | 21 | .replace(/[_-]+/g, " ") |
| ... | @@ -21,36 +25,37 @@ export function formatProviderKindLabel(provider: ProviderKind): string { | ... | @@ -21,36 +25,37 @@ export function formatProviderKindLabel(provider: ProviderKind): string { |
| 21 | 25 | ||
| 22 | export function getProviderModels( | 26 | export function getProviderModels( |
| 23 | providers: ReadonlyArray<ServerProvider>, | 27 | providers: ReadonlyArray<ServerProvider>, |
| 24 | provider: ProviderKind, | 28 | provider: ProviderDriverKind, |
| 25 | ): ReadonlyArray<ServerProviderModel> { | 29 | ): ReadonlyArray<ServerProviderModel> { |
| 26 | return providers.find((candidate) => candidate.provider === provider)?.models ?? []; | 30 | return getProviderSnapshot(providers, provider)?.models ?? []; |
| 27 | } | 31 | } |
| 28 | 32 | ||
| 29 | export function getProviderSnapshot( | 33 | export function getProviderSnapshot( |
| 30 | providers: ReadonlyArray<ServerProvider>, | 34 | providers: ReadonlyArray<ServerProvider>, |
| 31 | provider: ProviderKind, | 35 | provider: ProviderDriverKind, |
| 32 | ): ServerProvider | undefined { | 36 | ): ServerProvider | undefined { |
| 33 | return providers.find((candidate) => candidate.provider === provider); | 37 | const defaultInstanceId = defaultInstanceIdForDriver(provider); |
| 38 | return providers.find((candidate) => candidate.instanceId === defaultInstanceId); | ||
| 34 | } | 39 | } |
| 35 | 40 | ||
| 36 | export function getProviderDisplayName( | 41 | export function getProviderDisplayName( |
| 37 | providers: ReadonlyArray<ServerProvider>, | 42 | providers: ReadonlyArray<ServerProvider>, |
| 38 | provider: ProviderKind, | 43 | provider: ProviderDriverKind, |
| 39 | ): string { | 44 | ): string { |
| 40 | const snapshot = getProviderSnapshot(providers, provider); | 45 | const snapshot = getProviderSnapshot(providers, provider); |
| 41 | return snapshot?.displayName?.trim() || formatProviderKindLabel(provider); | 46 | return snapshot?.displayName?.trim() || formatProviderDriverKindLabel(provider); |
| 42 | } | 47 | } |
| 43 | 48 | ||
| 44 | export function getProviderInteractionModeToggle( | 49 | export function getProviderInteractionModeToggle( |
| 45 | providers: ReadonlyArray<ServerProvider>, | 50 | providers: ReadonlyArray<ServerProvider>, |
| 46 | provider: ProviderKind, | 51 | provider: ProviderDriverKind, |
| 47 | ): boolean { | 52 | ): boolean { |
| 48 | return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; | 53 | return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; |
| 49 | } | 54 | } |
| 50 | 55 | ||
| 51 | export function isProviderEnabled( | 56 | export function isProviderEnabled( |
| 52 | providers: ReadonlyArray<ServerProvider>, | 57 | providers: ReadonlyArray<ServerProvider>, |
| 53 | provider: ProviderKind, | 58 | provider: ProviderDriverKind, |
| 54 | ): boolean { | 59 | ): boolean { |
| 55 | if (providers.length === 0) { | 60 | if (providers.length === 0) { |
| 56 | return true; | 61 | return true; |
| ... | @@ -58,21 +63,24 @@ export function isProviderEnabled( | ... | @@ -58,21 +63,24 @@ export function isProviderEnabled( |
| 58 | return getProviderSnapshot(providers, provider)?.enabled ?? false; | 63 | return getProviderSnapshot(providers, provider)?.enabled ?? false; |
| 59 | } | 64 | } |
| 60 | 65 | ||
| 66 | // Resolve an instance selection to the correlated live driver. If the | ||
| 67 | // instance is absent, fall back to a live enabled provider instead of | ||
| 68 | // inferring a driver from the missing instance id. | ||
| 61 | export function resolveSelectableProvider( | 69 | export function resolveSelectableProvider( |
| 62 | providers: ReadonlyArray<ServerProvider>, | 70 | providers: ReadonlyArray<ServerProvider>, |
| 63 | provider: ProviderKind | null | undefined, | 71 | provider: ProviderDriverKind | ProviderInstanceId | null | undefined, |
| 64 | ): ProviderKind { | 72 | ): ProviderDriverKind { |
| 65 | const requested = provider ?? "codex"; | 73 | const requestedEntry = providers.find((candidate) => candidate.instanceId === provider); |
| 66 | if (isProviderEnabled(providers, requested)) { | 74 | if (requestedEntry?.enabled) { |
| 67 | return requested; | 75 | return requestedEntry.driver; |
| 68 | } | 76 | } |
| 69 | return providers.find((candidate) => candidate.enabled)?.provider ?? requested; | 77 | return providers.find((candidate) => candidate.enabled)?.driver ?? DEFAULT_DRIVER_KIND; |
| 70 | } | 78 | } |
| 71 | 79 | ||
| 72 | export function getProviderModelCapabilities( | 80 | export function getProviderModelCapabilities( |
| 73 | models: ReadonlyArray<ServerProviderModel>, | 81 | models: ReadonlyArray<ServerProviderModel>, |
| 74 | model: string | null | undefined, | 82 | model: string | null | undefined, |
| 75 | provider: ProviderKind, | 83 | provider: ProviderDriverKind, |
| 76 | ): ModelCapabilities { | 84 | ): ModelCapabilities { |
| 77 | const slug = normalizeModelSlug(model, provider); | 85 | const slug = normalizeModelSlug(model, provider); |
| 78 | return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; | 86 | return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; |
| ... | @@ -80,12 +88,13 @@ export function getProviderModelCapabilities( | ... | @@ -80,12 +88,13 @@ export function getProviderModelCapabilities( |
| 80 | 88 | ||
| 81 | export function getDefaultServerModel( | 89 | export function getDefaultServerModel( |
| 82 | providers: ReadonlyArray<ServerProvider>, | 90 | providers: ReadonlyArray<ServerProvider>, |
| 83 | provider: ProviderKind, | 91 | provider: ProviderDriverKind, |
| 84 | ): string { | 92 | ): string { |
| 85 | const models = getProviderModels(providers, provider); | 93 | const models = getProviderModels(providers, provider); |
| 86 | return ( | 94 | return ( |
| 87 | models.find((model) => !model.isCustom)?.slug ?? | 95 | models.find((model) => !model.isCustom)?.slug ?? |
| 88 | models[0]?.slug ?? | 96 | models[0]?.slug ?? |
| 89 | DEFAULT_MODEL_BY_PROVIDER[provider] | 97 | DEFAULT_MODEL_BY_PROVIDER[provider] ?? |
| 98 | DEFAULT_MODEL | ||
| 90 | ); | 99 | ); |
| 91 | } | 100 | } |
apps/web/src/rpc/serverState.test.ts+4-1| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 4 | ProjectId, | 6 | ProjectId, |
| 5 | ThreadId, | 7 | ThreadId, |
| 6 | type ServerConfig, | 8 | type ServerConfig, |
| ... | @@ -40,7 +42,8 @@ const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); | ... | @@ -40,7 +42,8 @@ const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); |
| 40 | 42 | ||
| 41 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | 43 | const defaultProviders: ReadonlyArray<ServerProvider> = [ |
| 42 | { | 44 | { |
| 43 | provider: "codex", | 45 | instanceId: ProviderInstanceId.make("codex"), |
| 46 | driver: ProviderDriverKind.make("codex"), | ||
| 44 | enabled: true, | 47 | enabled: true, |
| 45 | installed: true, | 48 | installed: true, |
| 46 | version: "0.116.0", | 49 | version: "0.116.0", |
apps/web/src/rpc/wsRpcClient.ts+9-3| ... | @@ -102,7 +102,13 @@ export interface WsRpcClient { | ... | @@ -102,7 +102,13 @@ export interface WsRpcClient { |
| 102 | }; | 102 | }; |
| 103 | readonly server: { | 103 | readonly server: { |
| 104 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | 104 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; |
| 105 | readonly refreshProviders: RpcUnaryNoArgMethod<typeof WS_METHODS.serverRefreshProviders>; | 105 | /** |
| 106 | * Refresh provider snapshots. Pass `{ instanceId }` to refresh a single | ||
| 107 | * configured instance; pass no argument (or `{}`) to refresh all. | ||
| 108 | */ | ||
| 109 | readonly refreshProviders: ( | ||
| 110 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, | ||
| 111 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; | ||
| 106 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | 112 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; |
| 107 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | 113 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; |
| 108 | readonly updateSettings: ( | 114 | readonly updateSettings: ( |
| ... | @@ -206,8 +212,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -206,8 +212,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 206 | }, | 212 | }, |
| 207 | server: { | 213 | server: { |
| 208 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | 214 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), |
| 209 | refreshProviders: () => | 215 | refreshProviders: (input) => |
| 210 | transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})), | 216 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), |
| 211 | upsertKeybinding: (input) => | 217 | upsertKeybinding: (input) => |
| 212 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | 218 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), |
| 213 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | 219 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), |
apps/web/src/session-logic.ts+16-6| ... | @@ -6,7 +6,7 @@ import { | ... | @@ -6,7 +6,7 @@ import { |
| 6 | type OrchestrationLatestTurn, | 6 | type OrchestrationLatestTurn, |
| 7 | type OrchestrationThreadActivity, | 7 | type OrchestrationThreadActivity, |
| 8 | type OrchestrationProposedPlanId, | 8 | type OrchestrationProposedPlanId, |
| 9 | type ProviderKind, | 9 | ProviderDriverKind, |
| 10 | type ToolLifecycleItemType, | 10 | type ToolLifecycleItemType, |
| 11 | type UserInputQuestion, | 11 | type UserInputQuestion, |
| 12 | type ThreadId, | 12 | type ThreadId, |
| ... | @@ -22,7 +22,7 @@ import type { | ... | @@ -22,7 +22,7 @@ import type { |
| 22 | TurnDiffSummary, | 22 | TurnDiffSummary, |
| 23 | } from "./types"; | 23 | } from "./types"; |
| 24 | 24 | ||
| 25 | export type ProviderPickerKind = ProviderKind; | 25 | export type ProviderPickerKind = ProviderDriverKind; |
| 26 | 26 | ||
| 27 | export const PROVIDER_OPTIONS: Array<{ | 27 | export const PROVIDER_OPTIONS: Array<{ |
| 28 | value: ProviderPickerKind; | 28 | value: ProviderPickerKind; |
| ... | @@ -31,10 +31,20 @@ export const PROVIDER_OPTIONS: Array<{ | ... | @@ -31,10 +31,20 @@ export const PROVIDER_OPTIONS: Array<{ |
| 31 | /** Shown on the model picker sidebar when relevant */ | 31 | /** Shown on the model picker sidebar when relevant */ |
| 32 | pickerSidebarBadge?: "new" | "soon"; | 32 | pickerSidebarBadge?: "new" | "soon"; |
| 33 | }> = [ | 33 | }> = [ |
| 34 | { value: "codex", label: "Codex", available: true }, | 34 | { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, |
| 35 | { value: "claudeAgent", label: "Claude", available: true }, | 35 | { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, |
| 36 | { value: "opencode", label: "OpenCode", available: true, pickerSidebarBadge: "new" }, | 36 | { |
| 37 | { value: "cursor", label: "Cursor", available: true, pickerSidebarBadge: "new" }, | 37 | value: ProviderDriverKind.make("opencode"), |
| 38 | label: "OpenCode", | ||
| 39 | available: true, | ||
| 40 | pickerSidebarBadge: "new", | ||
| 41 | }, | ||
| 42 | { | ||
| 43 | value: ProviderDriverKind.make("cursor"), | ||
| 44 | label: "Cursor", | ||
| 45 | available: true, | ||
| 46 | pickerSidebarBadge: "new", | ||
| 47 | }, | ||
| 38 | ]; | 48 | ]; |
| 39 | 49 | ||
| 40 | export interface WorkLogEntry { | 50 | export interface WorkLogEntry { |
apps/web/src/store.test.ts+14-13| ... | @@ -1,11 +1,12 @@ | ... | @@ -1,11 +1,12 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import { | 2 | import { |
| 3 | CheckpointRef, | 3 | CheckpointRef, |
| 4 | DEFAULT_MODEL_BY_PROVIDER, | 4 | DEFAULT_MODEL, |
| 5 | EnvironmentId, | 5 | EnvironmentId, |
| 6 | EventId, | 6 | EventId, |
| 7 | MessageId, | 7 | MessageId, |
| 8 | ProjectId, | 8 | ProjectId, |
| 9 | ProviderInstanceId, | ||
| 9 | ThreadId, | 10 | ThreadId, |
| 10 | TurnId, | 11 | TurnId, |
| 11 | type OrchestrationEvent, | 12 | type OrchestrationEvent, |
| ... | @@ -65,7 +66,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -65,7 +66,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 65 | projectId: ProjectId.make("project-1"), | 66 | projectId: ProjectId.make("project-1"), |
| 66 | title: "Thread", | 67 | title: "Thread", |
| 67 | modelSelection: { | 68 | modelSelection: { |
| 68 | provider: "codex", | 69 | instanceId: ProviderInstanceId.make("codex"), |
| 69 | model: "gpt-5-codex", | 70 | model: "gpt-5-codex", |
| 70 | }, | 71 | }, |
| 71 | runtimeMode: DEFAULT_RUNTIME_MODE, | 72 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| ... | @@ -93,7 +94,7 @@ function makeState(thread: Thread): AppState { | ... | @@ -93,7 +94,7 @@ function makeState(thread: Thread): AppState { |
| 93 | name: "Project", | 94 | name: "Project", |
| 94 | cwd: "/tmp/project", | 95 | cwd: "/tmp/project", |
| 95 | defaultModelSelection: { | 96 | defaultModelSelection: { |
| 96 | provider: "codex" as const, | 97 | instanceId: ProviderInstanceId.make("codex"), |
| 97 | model: "gpt-5-codex", | 98 | model: "gpt-5-codex", |
| 98 | }, | 99 | }, |
| 99 | createdAt: "2026-02-13T00:00:00.000Z", | 100 | createdAt: "2026-02-13T00:00:00.000Z", |
| ... | @@ -463,8 +464,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -463,8 +464,8 @@ describe("incremental orchestration updates", () => { |
| 463 | name: "Project", | 464 | name: "Project", |
| 464 | cwd: "/tmp/project", | 465 | cwd: "/tmp/project", |
| 465 | defaultModelSelection: { | 466 | defaultModelSelection: { |
| 466 | provider: "codex", | 467 | instanceId: ProviderInstanceId.make("codex"), |
| 467 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 468 | model: DEFAULT_MODEL, |
| 468 | }, | 469 | }, |
| 469 | createdAt: "2026-02-27T00:00:00.000Z", | 470 | createdAt: "2026-02-27T00:00:00.000Z", |
| 470 | updatedAt: "2026-02-27T00:00:00.000Z", | 471 | updatedAt: "2026-02-27T00:00:00.000Z", |
| ... | @@ -480,8 +481,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -480,8 +481,8 @@ describe("incremental orchestration updates", () => { |
| 480 | title: "Project Recreated", | 481 | title: "Project Recreated", |
| 481 | workspaceRoot: "/tmp/project", | 482 | workspaceRoot: "/tmp/project", |
| 482 | defaultModelSelection: { | 483 | defaultModelSelection: { |
| 483 | provider: "codex", | 484 | instanceId: ProviderInstanceId.make("codex"), |
| 484 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 485 | model: DEFAULT_MODEL, |
| 485 | }, | 486 | }, |
| 486 | scripts: [], | 487 | scripts: [], |
| 487 | createdAt: "2026-02-27T00:00:01.000Z", | 488 | createdAt: "2026-02-27T00:00:01.000Z", |
| ... | @@ -518,8 +519,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -518,8 +519,8 @@ describe("incremental orchestration updates", () => { |
| 518 | name: "Project 1", | 519 | name: "Project 1", |
| 519 | cwd: "/tmp/project-1", | 520 | cwd: "/tmp/project-1", |
| 520 | defaultModelSelection: { | 521 | defaultModelSelection: { |
| 521 | provider: "codex", | 522 | instanceId: ProviderInstanceId.make("codex"), |
| 522 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 523 | model: DEFAULT_MODEL, |
| 523 | }, | 524 | }, |
| 524 | createdAt: "2026-02-27T00:00:00.000Z", | 525 | createdAt: "2026-02-27T00:00:00.000Z", |
| 525 | updatedAt: "2026-02-27T00:00:00.000Z", | 526 | updatedAt: "2026-02-27T00:00:00.000Z", |
| ... | @@ -531,8 +532,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -531,8 +532,8 @@ describe("incremental orchestration updates", () => { |
| 531 | name: "Project 2", | 532 | name: "Project 2", |
| 532 | cwd: "/tmp/project-2", | 533 | cwd: "/tmp/project-2", |
| 533 | defaultModelSelection: { | 534 | defaultModelSelection: { |
| 534 | provider: "codex", | 535 | instanceId: ProviderInstanceId.make("codex"), |
| 535 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 536 | model: DEFAULT_MODEL, |
| 536 | }, | 537 | }, |
| 537 | createdAt: "2026-02-27T00:00:00.000Z", | 538 | createdAt: "2026-02-27T00:00:00.000Z", |
| 538 | updatedAt: "2026-02-27T00:00:00.000Z", | 539 | updatedAt: "2026-02-27T00:00:00.000Z", |
| ... | @@ -548,8 +549,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -548,8 +549,8 @@ describe("incremental orchestration updates", () => { |
| 548 | projectId: recreatedProjectId, | 549 | projectId: recreatedProjectId, |
| 549 | title: "Recovered thread", | 550 | title: "Recovered thread", |
| 550 | modelSelection: { | 551 | modelSelection: { |
| 551 | provider: "codex", | 552 | instanceId: ProviderInstanceId.make("codex"), |
| 552 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 553 | model: DEFAULT_MODEL, |
| 553 | }, | 554 | }, |
| 554 | runtimeMode: DEFAULT_RUNTIME_MODE, | 555 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 555 | interactionMode: DEFAULT_INTERACTION_MODE, | 556 | interactionMode: DEFAULT_INTERACTION_MODE, |
apps/web/src/store.ts+13-8| ... | @@ -18,7 +18,7 @@ import type { | ... | @@ -18,7 +18,7 @@ import type { |
| 18 | ScopedProjectRef, | 18 | ScopedProjectRef, |
| 19 | ScopedThreadRef, | 19 | ScopedThreadRef, |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { ProviderKind } from "@t3tools/contracts"; | 21 | import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts"; |
| 22 | import type { ThreadId, TurnId } from "@t3tools/contracts"; | 22 | import type { ThreadId, TurnId } from "@t3tools/contracts"; |
| 23 | import { Schema } from "effect"; | 23 | import { Schema } from "effect"; |
| 24 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; | 24 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; |
| ... | @@ -129,12 +129,16 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { | ... | @@ -129,12 +129,16 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { |
| 129 | return left.length === right.length && left.every((value, index) => value === right[index]); | 129 | return left.length === right.length && left.every((value, index) => value === right[index]); |
| 130 | } | 130 | } |
| 131 | 131 | ||
| 132 | function normalizeModelSelection<T extends { provider: ProviderKind; model: string }>( | 132 | // Accepts the open `instanceId` string carried on `ModelSelection`; malformed |
| 133 | selection: T, | 133 | // values pass through unchanged, while valid slugs use any registered alias |
| 134 | ): T { | 134 | // table for model normalization. |
| 135 | function normalizeModelSelection<T extends { instanceId: string; model: string }>(selection: T): T { | ||
| 136 | if (!isProviderDriverKind(selection.instanceId)) { | ||
| 137 | return selection; | ||
| 138 | } | ||
| 135 | return { | 139 | return { |
| 136 | ...selection, | 140 | ...selection, |
| 137 | model: resolveModelSlugForProvider(selection.provider, selection.model), | 141 | model: resolveModelSlugForProvider(selection.instanceId, selection.model), |
| 138 | }; | 142 | }; |
| 139 | } | 143 | } |
| 140 | 144 | ||
| ... | @@ -145,6 +149,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): | ... | @@ -145,6 +149,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): |
| 145 | function mapSession(session: OrchestrationSession): ThreadSession { | 149 | function mapSession(session: OrchestrationSession): ThreadSession { |
| 146 | return { | 150 | return { |
| 147 | provider: toLegacyProvider(session.providerName), | 151 | provider: toLegacyProvider(session.providerName), |
| 152 | providerInstanceId: session.providerInstanceId ?? undefined, | ||
| 148 | status: toLegacySessionStatus(session.status), | 153 | status: toLegacySessionStatus(session.status), |
| 149 | orchestrationStatus: session.status, | 154 | orchestrationStatus: session.status, |
| 150 | activeTurnId: session.activeTurnId ?? undefined, | 155 | activeTurnId: session.activeTurnId ?? undefined, |
| ... | @@ -1000,11 +1005,11 @@ function toLegacySessionStatus( | ... | @@ -1000,11 +1005,11 @@ function toLegacySessionStatus( |
| 1000 | } | 1005 | } |
| 1001 | } | 1006 | } |
| 1002 | 1007 | ||
| 1003 | function toLegacyProvider(providerName: string | null): ProviderKind { | 1008 | function toLegacyProvider(providerName: string | null): ProviderDriverKind { |
| 1004 | if (Schema.is(ProviderKind)(providerName)) { | 1009 | if (Schema.is(ProviderDriverKind)(providerName)) { |
| 1005 | return providerName; | 1010 | return providerName; |
| 1006 | } | 1011 | } |
| 1007 | return "codex"; | 1012 | return ProviderDriverKind.make("codex"); |
| 1008 | } | 1013 | } |
| 1009 | 1014 | ||
| 1010 | function attachmentPreviewRoutePath(attachmentId: string): string { | 1015 | function attachmentPreviewRoutePath(attachmentId: string): string { |
apps/web/src/types.ts+4-2| ... | @@ -11,7 +11,8 @@ import type { | ... | @@ -11,7 +11,8 @@ import type { |
| 11 | ProjectId, | 11 | ProjectId, |
| 12 | TurnId, | 12 | TurnId, |
| 13 | MessageId, | 13 | MessageId, |
| 14 | ProviderKind, | 14 | ProviderDriverKind, |
| 15 | ProviderInstanceId, | ||
| 15 | CheckpointRef, | 16 | CheckpointRef, |
| 16 | ProviderInteractionMode, | 17 | ProviderInteractionMode, |
| 17 | RuntimeMode, | 18 | RuntimeMode, |
| ... | @@ -158,7 +159,8 @@ export interface SidebarThreadSummary { | ... | @@ -158,7 +159,8 @@ export interface SidebarThreadSummary { |
| 158 | } | 159 | } |
| 159 | 160 | ||
| 160 | export interface ThreadSession { | 161 | export interface ThreadSession { |
| 161 | provider: ProviderKind; | 162 | provider: ProviderDriverKind; |
| 163 | providerInstanceId?: ProviderInstanceId | undefined; | ||
| 162 | status: SessionPhase | "error" | "closed"; | 164 | status: SessionPhase | "error" | "closed"; |
| 163 | activeTurnId?: TurnId | undefined; | 165 | activeTurnId?: TurnId | undefined; |
| 164 | createdAt: string; | 166 | createdAt: string; |
apps/web/src/worktreeCleanup.test.ts+2-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| ... | @@ -14,7 +14,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -14,7 +14,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 14 | projectId: ProjectId.make("project-1"), | 14 | projectId: ProjectId.make("project-1"), |
| 15 | title: "Thread", | 15 | title: "Thread", |
| 16 | modelSelection: { | 16 | modelSelection: { |
| 17 | provider: "codex", | 17 | instanceId: ProviderInstanceId.make("codex"), |
| 18 | model: "gpt-5.3-codex", | 18 | model: "gpt-5.3-codex", |
| 19 | }, | 19 | }, |
| 20 | runtimeMode: DEFAULT_RUNTIME_MODE, | 20 | runtimeMode: DEFAULT_RUNTIME_MODE, |
apps/web/vite.config.ts+7-1| ... | @@ -56,7 +56,13 @@ export default defineConfig({ | ... | @@ -56,7 +56,13 @@ export default defineConfig({ |
| 56 | tailwindcss(), | 56 | tailwindcss(), |
| 57 | ], | 57 | ], |
| 58 | optimizeDeps: { | 58 | optimizeDeps: { |
| 59 | include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], | 59 | include: [ |
| 60 | "@pierre/diffs", | ||
| 61 | "@pierre/diffs/react", | ||
| 62 | "@pierre/diffs/worker/worker.js", | ||
| 63 | "effect/Array", | ||
| 64 | "effect/Order", | ||
| 65 | ], | ||
| 60 | }, | 66 | }, |
| 61 | define: { | 67 | define: { |
| 62 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), | 68 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), |
docs/providers/claude.md created+224| ... | @@ -0,0 +1,224 @@ | ||
| 1 | # Claude | ||
| 2 | |||
| 3 | This guide is for people who want to use more than one Claude setup in T3 Code. | ||
| 4 | |||
| 5 | Common reasons: | ||
| 6 | |||
| 7 | - use separate work and personal Claude accounts | ||
| 8 | - try a different Claude Code configuration without disturbing your main setup | ||
| 9 | - run Claude through a router such as Claude Code Router | ||
| 10 | - use external providers exposed through a Claude-compatible workflow | ||
| 11 | |||
| 12 | ## I Only Use One Claude Account | ||
| 13 | |||
| 14 | Use the default provider. | ||
| 15 | |||
| 16 | Log in with Claude Code normally: | ||
| 17 | |||
| 18 | ```bash | ||
| 19 | claude auth login | ||
| 20 | ``` | ||
| 21 | |||
| 22 | In T3 Code Settings, your Claude provider can stay like this: | ||
| 23 | |||
| 24 | ```text | ||
| 25 | Display name: Claude | ||
| 26 | Binary path: claude | ||
| 27 | Claude HOME path: empty | ||
| 28 | ``` | ||
| 29 | |||
| 30 | An empty `Claude HOME path` means T3 Code uses your normal home directory. | ||
| 31 | |||
| 32 | ## I Want Work And Personal Claude Accounts | ||
| 33 | |||
| 34 | Use a different Claude home for each account. | ||
| 35 | |||
| 36 | Example: | ||
| 37 | |||
| 38 | ```text | ||
| 39 | default home work account | ||
| 40 | ~/.claude_personal_home personal account | ||
| 41 | ``` | ||
| 42 | |||
| 43 | ### Set Up The First Account | ||
| 44 | |||
| 45 | Log in normally: | ||
| 46 | |||
| 47 | ```bash | ||
| 48 | claude auth login | ||
| 49 | ``` | ||
| 50 | |||
| 51 | In T3 Code Settings: | ||
| 52 | |||
| 53 | ```text | ||
| 54 | Display name: Claude Work | ||
| 55 | Binary path: claude | ||
| 56 | Claude HOME path: empty | ||
| 57 | ``` | ||
| 58 | |||
| 59 | ### Set Up The Second Account | ||
| 60 | |||
| 61 | Log in with a separate home: | ||
| 62 | |||
| 63 | ```bash | ||
| 64 | mkdir -p ~/.claude_personal_home | ||
| 65 | HOME=~/.claude_personal_home claude auth login | ||
| 66 | ``` | ||
| 67 | |||
| 68 | Then add another Claude provider in T3 Code: | ||
| 69 | |||
| 70 | ```text | ||
| 71 | Display name: Claude Personal | ||
| 72 | Binary path: claude | ||
| 73 | Claude HOME path: ~/.claude_personal_home | ||
| 74 | ``` | ||
| 75 | |||
| 76 | Use the email shown in Settings to confirm each provider is using the intended account. Emails are | ||
| 77 | blurred by default; click the blurred email to reveal it. | ||
| 78 | |||
| 79 | ## Can I Switch Claude Accounts In An Existing Thread? | ||
| 80 | |||
| 81 | Usually, no. | ||
| 82 | |||
| 83 | T3 Code only offers Claude providers that use the same Claude home for an existing thread. A | ||
| 84 | different Claude home is treated as a different Claude environment. | ||
| 85 | |||
| 86 | This is different from the recommended Codex setup. Claude Code keeps account and local state across | ||
| 87 | multiple files under its home directory, so T3 Code keeps separate Claude homes isolated instead of | ||
| 88 | trying to share part of the state. | ||
| 89 | |||
| 90 | ## I Want To Use OpenRouter | ||
| 91 | |||
| 92 | Use this when you want Claude Code to talk to OpenRouter directly, without running a local router. | ||
| 93 | This is the simplest external-provider setup. | ||
| 94 | |||
| 95 | OpenRouter provides a Claude Code integration through Claude's Anthropic-compatible environment | ||
| 96 | variables. | ||
| 97 | |||
| 98 | ### Configure A Claude OpenRouter Provider | ||
| 99 | |||
| 100 | Add or edit a Claude provider in T3 Code Settings: | ||
| 101 | |||
| 102 | ```text | ||
| 103 | Display name: Claude OpenRouter | ||
| 104 | Binary path: claude | ||
| 105 | Claude HOME path: ~/.claude_openrouter_home | ||
| 106 | ``` | ||
| 107 | |||
| 108 | In that provider's Environment variables section, add: | ||
| 109 | |||
| 110 | ```text | ||
| 111 | ANTHROPIC_BASE_URL https://openrouter.ai/api | ||
| 112 | ANTHROPIC_AUTH_TOKEN sk-or-... Sensitive | ||
| 113 | ANTHROPIC_API_KEY Empty value | ||
| 114 | ``` | ||
| 115 | |||
| 116 | Mark `ANTHROPIC_AUTH_TOKEN` as sensitive. T3 Code stores the value as a server secret and does not | ||
| 117 | send it back to the app after saving. | ||
| 118 | |||
| 119 | If you want this setup isolated from your normal Claude account, create that home first: | ||
| 120 | |||
| 121 | ```bash | ||
| 122 | mkdir -p ~/.claude_openrouter_home | ||
| 123 | ``` | ||
| 124 | |||
| 125 | If you previously used the same Claude home with a normal Anthropic login, run `/logout` in a Claude | ||
| 126 | Code session for that home before using OpenRouter. Otherwise Claude Code may keep using cached | ||
| 127 | Anthropic credentials instead of the OpenRouter token. | ||
| 128 | |||
| 129 | ### Pick OpenRouter Models | ||
| 130 | |||
| 131 | OpenRouter can route Claude Code's default model roles to OpenRouter model IDs. | ||
| 132 | |||
| 133 | Example: | ||
| 134 | |||
| 135 | ```text | ||
| 136 | ANTHROPIC_DEFAULT_OPUS_MODEL anthropic/claude-opus-4.6 | ||
| 137 | ANTHROPIC_DEFAULT_SONNET_MODEL anthropic/claude-sonnet-4.6 | ||
| 138 | ANTHROPIC_DEFAULT_HAIKU_MODEL anthropic/claude-haiku-4.5 | ||
| 139 | CLAUDE_CODE_SUBAGENT_MODEL anthropic/claude-sonnet-4.6 | ||
| 140 | ``` | ||
| 141 | |||
| 142 | Add those to the same provider's Environment variables section if you want stable model choices. | ||
| 143 | |||
| 144 | ### Verify OpenRouter Is Being Used | ||
| 145 | |||
| 146 | Open a Claude session and run: | ||
| 147 | |||
| 148 | ```text | ||
| 149 | /status | ||
| 150 | ``` | ||
| 151 | |||
| 152 | You should see the Anthropic base URL set to: | ||
| 153 | |||
| 154 | ```text | ||
| 155 | https://openrouter.ai/api | ||
| 156 | ``` | ||
| 157 | |||
| 158 | You can also check the OpenRouter activity dashboard for requests from your API key. | ||
| 159 | |||
| 160 | ### Common OpenRouter Mistakes | ||
| 161 | |||
| 162 | - Use `https://openrouter.ai/api`, not `https://openrouter.ai/api/v1`, for Claude Code. | ||
| 163 | - Set `ANTHROPIC_AUTH_TOKEN` to your OpenRouter API key. | ||
| 164 | - Set `ANTHROPIC_API_KEY` to an empty string so Claude Code does not try to use an Anthropic login. | ||
| 165 | - Put these variables on the Claude provider instance, not in global shell startup files. | ||
| 166 | |||
| 167 | OpenRouter's setup can change over time. Use its upstream Claude Code guide for the current details: | ||
| 168 | <https://openrouter.ai/docs/guides/guides/claude-code-integration>. | ||
| 169 | |||
| 170 | ## I Want To Use Claude Code Router | ||
| 171 | |||
| 172 | Claude Code Router is useful when you want a local routing layer with more control than a direct | ||
| 173 | OpenRouter setup. | ||
| 174 | |||
| 175 | T3 Code does not need a special Claude Code Router provider. Treat the router as a Claude | ||
| 176 | environment. | ||
| 177 | |||
| 178 | Use this when you want Claude Code Router to decide which upstream model or provider handles Claude | ||
| 179 | requests. | ||
| 180 | |||
| 181 | High-level flow: | ||
| 182 | |||
| 183 | 1. Start Claude Code Router. | ||
| 184 | 2. Add or configure a Claude provider in T3 Code. | ||
| 185 | 3. Put the router's required variables on that provider instance. | ||
| 186 | |||
| 187 | Configure a Claude provider: | ||
| 188 | |||
| 189 | ```text | ||
| 190 | Display name: Claude Router | ||
| 191 | Binary path: claude | ||
| 192 | Claude HOME path: ~/.claude_router_home | ||
| 193 | ``` | ||
| 194 | |||
| 195 | Then copy the variables that `ccr activate` would export into the provider's Environment variables | ||
| 196 | section. Mark tokens and API keys as sensitive. | ||
| 197 | |||
| 198 | If you want the router-backed setup to stay separate from your normal Claude account, create and log | ||
| 199 | in with a dedicated home first: | ||
| 200 | |||
| 201 | ```bash | ||
| 202 | mkdir -p ~/.claude_router_home | ||
| 203 | ccr start | ||
| 204 | ccr activate | ||
| 205 | HOME=~/.claude_router_home claude auth login | ||
| 206 | ``` | ||
| 207 | |||
| 208 | Claude Code Router's setup can change over time. Use its upstream README for the current install and | ||
| 209 | configuration steps: <https://github.com/musistudio/claude-code-router>. | ||
| 210 | |||
| 211 | ## I Want Different Claude Settings, Not A Different Account | ||
| 212 | |||
| 213 | Create another Claude provider with the same account if you want a named preset. | ||
| 214 | |||
| 215 | Examples: | ||
| 216 | |||
| 217 | - "Claude Default" | ||
| 218 | - "Claude Router" | ||
| 219 | - "Claude Experimental" | ||
| 220 | |||
| 221 | If the preset needs different Claude files, give it a different `Claude HOME path`. If it needs | ||
| 222 | different API keys, base URLs, or router settings, use Environment variables. | ||
| 223 | |||
| 224 | Do not put environment variable assignments in `Launch arguments`. | ||
docs/providers/codex.md created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | # Codex | ||
| 2 | |||
| 3 | This guide is for people who want to use more than one Codex account in T3 Code. | ||
| 4 | |||
| 5 | Common reasons: | ||
| 6 | |||
| 7 | - use a work account for work projects | ||
| 8 | - use a personal account for personal projects | ||
| 9 | - switch to another account when one account hits limits | ||
| 10 | - keep one shared Codex history instead of maintaining two separate Codex setups | ||
| 11 | |||
| 12 | ## I Only Use One Codex Account | ||
| 13 | |||
| 14 | Use the default provider. | ||
| 15 | |||
| 16 | In Settings, your Codex provider can stay like this: | ||
| 17 | |||
| 18 | ```text | ||
| 19 | Display name: Codex | ||
| 20 | CODEX_HOME path: ~/.codex | ||
| 21 | Shadow home path: empty | ||
| 22 | ``` | ||
| 23 | |||
| 24 | Log in with Codex normally: | ||
| 25 | |||
| 26 | ```bash | ||
| 27 | codex login | ||
| 28 | ``` | ||
| 29 | |||
| 30 | ## I Want Work And Personal Codex Accounts | ||
| 31 | |||
| 32 | Use one real Codex home and one shadow home. | ||
| 33 | |||
| 34 | Recommended setup: | ||
| 35 | |||
| 36 | ```text | ||
| 37 | ~/.codex shared Codex home | ||
| 38 | ~/.codex_p second account auth | ||
| 39 | ``` | ||
| 40 | |||
| 41 | The idea is: | ||
| 42 | |||
| 43 | - both accounts can see the same T3/Codex sessions | ||
| 44 | - each account keeps its own login | ||
| 45 | - existing threads can continue with either account | ||
| 46 | |||
| 47 | ### Set Up The First Account | ||
| 48 | |||
| 49 | Log in normally: | ||
| 50 | |||
| 51 | ```bash | ||
| 52 | codex login | ||
| 53 | ``` | ||
| 54 | |||
| 55 | This is the account used by `~/.codex`. | ||
| 56 | |||
| 57 | In T3 Code Settings, name it something obvious: | ||
| 58 | |||
| 59 | ```text | ||
| 60 | Display name: Codex Work | ||
| 61 | CODEX_HOME path: ~/.codex | ||
| 62 | Shadow home path: empty | ||
| 63 | ``` | ||
| 64 | |||
| 65 | ### Set Up The Second Account | ||
| 66 | |||
| 67 | Log in with a separate Codex home: | ||
| 68 | |||
| 69 | ```bash | ||
| 70 | mkdir -p ~/.codex_p | ||
| 71 | CODEX_HOME=~/.codex_p codex login | ||
| 72 | ``` | ||
| 73 | |||
| 74 | In T3 Code Settings, add another Codex provider: | ||
| 75 | |||
| 76 | ```text | ||
| 77 | Display name: Codex Personal | ||
| 78 | CODEX_HOME path: ~/.codex | ||
| 79 | Shadow home path: ~/.codex_p | ||
| 80 | ``` | ||
| 81 | |||
| 82 | The important part is that both providers use the same `CODEX_HOME path`, but only the second one | ||
| 83 | has a `Shadow home path`. | ||
| 84 | |||
| 85 | ## Which Account Am I Using? | ||
| 86 | |||
| 87 | Open Settings and look at the provider row. | ||
| 88 | |||
| 89 | T3 Code shows the authenticated email for providers that report one. Emails are blurred by default; | ||
| 90 | click the blurred email to reveal it. | ||
| 91 | |||
| 92 | Use display names and accent colors to make accounts easy to tell apart in the model picker. | ||
| 93 | |||
| 94 | ## I Need A Different API Key Or Endpoint | ||
| 95 | |||
| 96 | Use the provider's Environment variables section in Settings. | ||
| 97 | |||
| 98 | This is useful when a Codex-compatible setup needs account-specific variables. Add the variables to | ||
| 99 | the provider instance that should receive them, and mark API keys or tokens as sensitive. Sensitive | ||
| 100 | values are stored as server secrets and are not sent back to the app after saving. | ||
| 101 | |||
| 102 | ## Can I Switch Accounts In An Existing Thread? | ||
| 103 | |||
| 104 | Yes, when both Codex providers share the same `CODEX_HOME path`. | ||
| 105 | |||
| 106 | For example: | ||
| 107 | |||
| 108 | ```text | ||
| 109 | Codex Work CODEX_HOME path: ~/.codex | ||
| 110 | Codex Personal CODEX_HOME path: ~/.codex, Shadow home path: ~/.codex_p | ||
| 111 | ``` | ||
| 112 | |||
| 113 | Those two providers are considered compatible for continuation, so the locked model picker can show | ||
| 114 | both. | ||
| 115 | |||
| 116 | If you add a third Codex provider with a completely different `CODEX_HOME path`, T3 Code treats it | ||
| 117 | as a different workspace. It will not be offered for existing threads created under `~/.codex`. | ||
| 118 | |||
| 119 | ## If Both Accounts Look The Same | ||
| 120 | |||
| 121 | If two Codex providers show the same account or the same unexpected model list: | ||
| 122 | |||
| 123 | 1. Check the email in Settings. | ||
| 124 | 2. Refresh provider status. | ||
| 125 | 3. Confirm the second provider has `Shadow home path` set. | ||
| 126 | 4. Confirm the shadow directory has its own `auth.json`. | ||
| 127 | 5. If you copied `~/.codex` into the shadow directory, remove everything except `auth.json`. | ||
| 128 | |||
| 129 | Example cleanup: | ||
| 130 | |||
| 131 | ```bash | ||
| 132 | find ~/.codex_p -mindepth 1 ! -name auth.json -exec rm -rf {} + | ||
| 133 | ``` | ||
| 134 | |||
| 135 | ## When To Use A Separate CODEX_HOME | ||
| 136 | |||
| 137 | Use a totally separate `CODEX_HOME path` only when you want a separate Codex workspace. | ||
| 138 | |||
| 139 | That means separate sessions and less account switching inside old threads. Most dual-account users | ||
| 140 | should use the shared-home plus shadow-home setup instead. | ||
packages/contracts/src/index.ts+1| ... | @@ -4,6 +4,7 @@ export * from "./environment.ts"; | ... | @@ -4,6 +4,7 @@ export * from "./environment.ts"; |
| 4 | export * from "./ipc.ts"; | 4 | export * from "./ipc.ts"; |
| 5 | export * from "./terminal.ts"; | 5 | export * from "./terminal.ts"; |
| 6 | export * from "./provider.ts"; | 6 | export * from "./provider.ts"; |
| 7 | export * from "./providerInstance.ts"; | ||
| 7 | export * from "./providerRuntime.ts"; | 8 | export * from "./providerRuntime.ts"; |
| 8 | export * from "./model.ts"; | 9 | export * from "./model.ts"; |
| 9 | export * from "./keybindings.ts"; | 10 | export * from "./keybindings.ts"; |
packages/contracts/src/ipc.ts+9-1| ... | @@ -25,6 +25,7 @@ import type { | ... | @@ -25,6 +25,7 @@ import type { |
| 25 | ProjectWriteFileInput, | 25 | ProjectWriteFileInput, |
| 26 | ProjectWriteFileResult, | 26 | ProjectWriteFileResult, |
| 27 | } from "./project.ts"; | 27 | } from "./project.ts"; |
| 28 | import type { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 28 | import type { | 29 | import type { |
| 29 | ServerConfig, | 30 | ServerConfig, |
| 30 | ServerProviderUpdatedPayload, | 31 | ServerProviderUpdatedPayload, |
| ... | @@ -215,7 +216,14 @@ export interface LocalApi { | ... | @@ -215,7 +216,14 @@ export interface LocalApi { |
| 215 | }; | 216 | }; |
| 216 | server: { | 217 | server: { |
| 217 | getConfig: () => Promise<ServerConfig>; | 218 | getConfig: () => Promise<ServerConfig>; |
| 218 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; | 219 | /** |
| 220 | * Refresh provider snapshots. When `input.instanceId` is supplied only that | ||
| 221 | * configured instance is probed; otherwise every configured instance is | ||
| 222 | * refreshed (legacy untargeted refresh). | ||
| 223 | */ | ||
| 224 | refreshProviders: (input?: { | ||
| 225 | readonly instanceId?: ProviderInstanceId; | ||
| 226 | }) => Promise<ServerProviderUpdatedPayload>; | ||
| 219 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | 227 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 220 | getSettings: () => Promise<ServerSettings>; | 228 | getSettings: () => Promise<ServerSettings>; |
| 221 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | 229 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; |
packages/contracts/src/model.ts+33-23| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { Effect, Schema, SchemaTransformation } from "effect"; | 1 | import { Effect, Schema, SchemaTransformation } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | import type { ProviderKind } from "./orchestration.ts"; | 3 | import { ProviderDriverKind } from "./providerInstance.ts"; |
| 4 | 4 | ||
| 5 | export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]); | 5 | export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]); |
| 6 | export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type; | 6 | export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type; |
| ... | @@ -125,25 +125,35 @@ export const ModelCapabilities = Schema.Struct({ | ... | @@ -125,25 +125,35 @@ export const ModelCapabilities = Schema.Struct({ |
| 125 | }); | 125 | }); |
| 126 | export type ModelCapabilities = typeof ModelCapabilities.Type; | 126 | export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 127 | 127 | ||
| 128 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { | 128 | const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 129 | codex: "gpt-5.4", | 129 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 130 | claudeAgent: "claude-sonnet-4-6", | 130 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 131 | cursor: "auto", | 131 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 132 | opencode: "openai/gpt-5", | 132 | |
| 133 | }; | 133 | export const DEFAULT_MODEL = "gpt-5.4"; |
| 134 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; | ||
| 134 | 135 | ||
| 135 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; | 136 | export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { |
| 137 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, | ||
| 138 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", | ||
| 139 | [CURSOR_DRIVER_KIND]: "auto", | ||
| 140 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", | ||
| 141 | }; | ||
| 136 | 142 | ||
| 137 | /** Per-provider text generation model defaults. */ | 143 | /** Per-provider text generation model defaults. */ |
| 138 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { | 144 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< |
| 139 | codex: "gpt-5.4-mini", | 145 | Record<ProviderDriverKind, string> |
| 140 | claudeAgent: "claude-haiku-4-5", | 146 | > = { |
| 141 | cursor: "composer-2", | 147 | [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL, |
| 142 | opencode: "openai/gpt-5", | 148 | [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", |
| 149 | [CURSOR_DRIVER_KIND]: "composer-2", | ||
| 150 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", | ||
| 143 | }; | 151 | }; |
| 144 | 152 | ||
| 145 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = { | 153 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< |
| 146 | codex: { | 154 | Record<ProviderDriverKind, Record<string, string>> |
| 155 | > = { | ||
| 156 | [CODEX_DRIVER_KIND]: { | ||
| 147 | "gpt-5-codex": "gpt-5.4", | 157 | "gpt-5-codex": "gpt-5.4", |
| 148 | "5.4": "gpt-5.4", | 158 | "5.4": "gpt-5.4", |
| 149 | "5.3": "gpt-5.3-codex", | 159 | "5.3": "gpt-5.3-codex", |
| ... | @@ -151,7 +161,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, | ... | @@ -151,7 +161,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 151 | "5.3-spark": "gpt-5.3-codex-spark", | 161 | "5.3-spark": "gpt-5.3-codex-spark", |
| 152 | "gpt-5.3-spark": "gpt-5.3-codex-spark", | 162 | "gpt-5.3-spark": "gpt-5.3-codex-spark", |
| 153 | }, | 163 | }, |
| 154 | claudeAgent: { | 164 | [CLAUDE_DRIVER_KIND]: { |
| 155 | opus: "claude-opus-4-7", | 165 | opus: "claude-opus-4-7", |
| 156 | "opus-4.7": "claude-opus-4-7", | 166 | "opus-4.7": "claude-opus-4-7", |
| 157 | "claude-opus-4.7": "claude-opus-4-7", | 167 | "claude-opus-4.7": "claude-opus-4-7", |
| ... | @@ -167,7 +177,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, | ... | @@ -167,7 +177,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 167 | "claude-haiku-4.5": "claude-haiku-4-5", | 177 | "claude-haiku-4.5": "claude-haiku-4-5", |
| 168 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", | 178 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", |
| 169 | }, | 179 | }, |
| 170 | cursor: { | 180 | [CURSOR_DRIVER_KIND]: { |
| 171 | composer: "composer-2", | 181 | composer: "composer-2", |
| 172 | "composer-1.5": "composer-1.5", | 182 | "composer-1.5": "composer-1.5", |
| 173 | "composer-1": "composer-1.5", | 183 | "composer-1": "composer-1.5", |
| ... | @@ -178,14 +188,14 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, | ... | @@ -178,14 +188,14 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 178 | "opus-4.5-thinking": "claude-opus-4-5", | 188 | "opus-4.5-thinking": "claude-opus-4-5", |
| 179 | "opus-4.5": "claude-opus-4-5", | 189 | "opus-4.5": "claude-opus-4-5", |
| 180 | }, | 190 | }, |
| 181 | opencode: {}, | 191 | [OPENCODE_DRIVER_KIND]: {}, |
| 182 | }; | 192 | }; |
| 183 | 193 | ||
| 184 | // ── Provider display names ──────────────────────────────────────────── | 194 | // ── Provider display names ──────────────────────────────────────────── |
| 185 | 195 | ||
| 186 | export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = { | 196 | export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> = { |
| 187 | codex: "Codex", | 197 | [CODEX_DRIVER_KIND]: "Codex", |
| 188 | claudeAgent: "Claude", | 198 | [CLAUDE_DRIVER_KIND]: "Claude", |
| 189 | cursor: "Cursor", | 199 | [CURSOR_DRIVER_KIND]: "Cursor", |
| 190 | opencode: "OpenCode", | 200 | [OPENCODE_DRIVER_KIND]: "OpenCode", |
| 191 | }; | 201 | }; |
packages/contracts/src/orchestration.test.ts+103-8| ... | @@ -5,6 +5,7 @@ import { Effect, Schema } from "effect"; | ... | @@ -5,6 +5,7 @@ import { Effect, Schema } from "effect"; |
| 5 | import { | 5 | import { |
| 6 | DEFAULT_PROVIDER_INTERACTION_MODE, | 6 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 7 | DEFAULT_RUNTIME_MODE, | 7 | DEFAULT_RUNTIME_MODE, |
| 8 | ModelSelection, | ||
| 8 | OrchestrationCommand, | 9 | OrchestrationCommand, |
| 9 | OrchestrationEvent, | 10 | OrchestrationEvent, |
| 10 | OrchestrationGetTurnDiffInput, | 11 | OrchestrationGetTurnDiffInput, |
| ... | @@ -20,6 +21,7 @@ import { | ... | @@ -20,6 +21,7 @@ import { |
| 20 | ThreadTurnDiff, | 21 | ThreadTurnDiff, |
| 21 | ThreadTurnStartRequestedPayload, | 22 | ThreadTurnStartRequestedPayload, |
| 22 | } from "./orchestration.ts"; | 23 | } from "./orchestration.ts"; |
| 24 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 23 | 25 | ||
| 24 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | 26 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 25 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | 27 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); |
| ... | @@ -104,7 +106,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () | ... | @@ -104,7 +106,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () |
| 104 | assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); | 106 | assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); |
| 105 | assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined); | 107 | assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined); |
| 106 | assert.deepStrictEqual(parsed.defaultModelSelection, { | 108 | assert.deepStrictEqual(parsed.defaultModelSelection, { |
| 107 | provider: "codex", | 109 | instanceId: ProviderInstanceId.make("codex"), |
| 108 | model: "gpt-5.2", | 110 | model: "gpt-5.2", |
| 109 | }); | 111 | }); |
| 110 | }), | 112 | }), |
| ... | @@ -140,7 +142,7 @@ it.effect("decodes historical project.created payloads with a default provider", | ... | @@ -140,7 +142,7 @@ it.effect("decodes historical project.created payloads with a default provider", |
| 140 | createdAt: "2026-01-01T00:00:00.000Z", | 142 | createdAt: "2026-01-01T00:00:00.000Z", |
| 141 | updatedAt: "2026-01-01T00:00:00.000Z", | 143 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 142 | }); | 144 | }); |
| 143 | assert.strictEqual(parsed.defaultModelSelection?.provider, "codex"); | 145 | assert.strictEqual(parsed.defaultModelSelection?.instanceId, "codex"); |
| 144 | }), | 146 | }), |
| 145 | ); | 147 | ); |
| 146 | 148 | ||
| ... | @@ -154,7 +156,7 @@ it.effect("decodes project.meta-updated payloads with explicit default provider" | ... | @@ -154,7 +156,7 @@ it.effect("decodes project.meta-updated payloads with explicit default provider" |
| 154 | }, | 156 | }, |
| 155 | updatedAt: "2026-01-01T00:00:00.000Z", | 157 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 156 | }); | 158 | }); |
| 157 | assert.strictEqual(parsed.defaultModelSelection?.provider, "claudeAgent"); | 159 | assert.strictEqual(parsed.defaultModelSelection?.instanceId, "claudeAgent"); |
| 158 | }), | 160 | }), |
| 159 | ); | 161 | ); |
| 160 | 162 | ||
| ... | @@ -213,7 +215,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( | ... | @@ -213,7 +215,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( |
| 213 | runtimeMode: "full-access", | 215 | runtimeMode: "full-access", |
| 214 | createdAt: "2026-01-01T00:00:00.000Z", | 216 | createdAt: "2026-01-01T00:00:00.000Z", |
| 215 | }); | 217 | }); |
| 216 | assert.strictEqual(parsed.modelSelection?.provider, "codex"); | 218 | assert.strictEqual(parsed.modelSelection?.instanceId, "codex"); |
| 217 | assert.strictEqual(parsed.runtimeMode, "full-access"); | 219 | assert.strictEqual(parsed.runtimeMode, "full-access"); |
| 218 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | 220 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); |
| 219 | }), | 221 | }), |
| ... | @@ -278,7 +280,7 @@ it.effect("decodes thread.created runtime mode for historical events", () => | ... | @@ -278,7 +280,7 @@ it.effect("decodes thread.created runtime mode for historical events", () => |
| 278 | }); | 280 | }); |
| 279 | 281 | ||
| 280 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); | 282 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); |
| 281 | assert.strictEqual(parsed.modelSelection.provider, "codex"); | 283 | assert.strictEqual(parsed.modelSelection.instanceId, "codex"); |
| 282 | }), | 284 | }), |
| 283 | ); | 285 | ); |
| 284 | 286 | ||
| ... | @@ -292,7 +294,7 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => | ... | @@ -292,7 +294,7 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => |
| 292 | }, | 294 | }, |
| 293 | updatedAt: "2026-01-01T00:00:00.000Z", | 295 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 294 | }); | 296 | }); |
| 295 | assert.strictEqual(parsed.modelSelection?.provider, "claudeAgent"); | 297 | assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent"); |
| 296 | }), | 298 | }), |
| 297 | ); | 299 | ); |
| 298 | 300 | ||
| ... | @@ -378,7 +380,7 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => | ... | @@ -378,7 +380,7 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => |
| 378 | }, | 380 | }, |
| 379 | createdAt: "2026-01-01T00:00:00.000Z", | 381 | createdAt: "2026-01-01T00:00:00.000Z", |
| 380 | }); | 382 | }); |
| 381 | assert.strictEqual(parsed.modelSelection?.provider, "codex"); | 383 | assert.strictEqual(parsed.modelSelection?.instanceId, "codex"); |
| 382 | assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high"); | 384 | assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high"); |
| 383 | assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true); | 385 | assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true); |
| 384 | }), | 386 | }), |
| ... | @@ -408,7 +410,7 @@ it.effect("normalizes legacy object-shaped modelSelection.options on decode", () | ... | @@ -408,7 +410,7 @@ it.effect("normalizes legacy object-shaped modelSelection.options on decode", () |
| 408 | updatedAt: "2026-01-01T00:00:00.000Z", | 410 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 409 | }); | 411 | }); |
| 410 | 412 | ||
| 411 | assert.strictEqual(parsed.modelSelection.provider, "claudeAgent"); | 413 | assert.strictEqual(parsed.modelSelection.instanceId, ProviderInstanceId.make("claudeAgent")); |
| 412 | assert.deepStrictEqual(parsed.modelSelection.options, [ | 414 | assert.deepStrictEqual(parsed.modelSelection.options, [ |
| 413 | { id: "effort", value: "max" }, | 415 | { id: "effort", value: "max" }, |
| 414 | { id: "fastMode", value: true }, | 416 | { id: "fastMode", value: true }, |
| ... | @@ -618,3 +620,96 @@ it.effect("preserves proposed plan implementation metadata when present", () => | ... | @@ -618,3 +620,96 @@ it.effect("preserves proposed plan implementation metadata when present", () => |
| 618 | assert.strictEqual(parsed.implementationThreadId, "thread-2"); | 620 | assert.strictEqual(parsed.implementationThreadId, "thread-2"); |
| 619 | }), | 621 | }), |
| 620 | ); | 622 | ); |
| 623 | |||
| 624 | // ── ModelSelection: instance-keyed wire shape + legacy decoder ──────── | ||
| 625 | // | ||
| 626 | // `ModelSelection` is routing-keyed on `instanceId` — never a driver kind. | ||
| 627 | // Persisted and in-flight payloads from pre-instance builds carry a | ||
| 628 | // `provider` field whose value was a driver kind; those payloads are migrated | ||
| 629 | // at the wire boundary by | ||
| 630 | // promoting `provider` to the default instance id for that driver | ||
| 631 | // (built-in drivers use the driver kind slug as their default instance id, so | ||
| 632 | // the migration is a 1:1 rename). | ||
| 633 | // | ||
| 634 | // These tests pin the rollback/fork tolerance invariant: legacy payloads | ||
| 635 | // decode cleanly for fork-provided drivers, and the decoded form uses | ||
| 636 | // `instanceId` uniformly regardless of origin. | ||
| 637 | |||
| 638 | const decodeModelSelection = Schema.decodeUnknownEffect(ModelSelection); | ||
| 639 | const encodeModelSelection = Schema.encodeUnknownEffect(ModelSelection); | ||
| 640 | |||
| 641 | it.effect("ModelSelection migrates legacy `provider` field to `instanceId`", () => | ||
| 642 | Effect.gen(function* () { | ||
| 643 | const parsed = yield* decodeModelSelection({ | ||
| 644 | provider: "codex", | ||
| 645 | model: "gpt-5-codex", | ||
| 646 | options: [{ id: "reasoningEffort", value: "high" }], | ||
| 647 | }); | ||
| 648 | assert.strictEqual(parsed.instanceId, ProviderInstanceId.make("codex")); | ||
| 649 | assert.strictEqual(parsed.model, "gpt-5-codex"); | ||
| 650 | assert.deepStrictEqual(parsed.options, [{ id: "reasoningEffort", value: "high" }]); | ||
| 651 | }), | ||
| 652 | ); | ||
| 653 | |||
| 654 | it.effect("ModelSelection accepts an explicit instanceId routing key", () => | ||
| 655 | Effect.gen(function* () { | ||
| 656 | const parsed = yield* decodeModelSelection({ | ||
| 657 | instanceId: "codex_personal", | ||
| 658 | model: "gpt-5-codex", | ||
| 659 | }); | ||
| 660 | assert.strictEqual(parsed.instanceId, ProviderInstanceId.make("codex_personal")); | ||
| 661 | }), | ||
| 662 | ); | ||
| 663 | |||
| 664 | it.effect("ModelSelection prefers explicit instanceId over legacy provider", () => | ||
| 665 | Effect.gen(function* () { | ||
| 666 | const parsed = yield* decodeModelSelection({ | ||
| 667 | provider: "codex", | ||
| 668 | instanceId: "codex_personal", | ||
| 669 | model: "gpt-5-codex", | ||
| 670 | }); | ||
| 671 | assert.strictEqual(parsed.instanceId, ProviderInstanceId.make("codex_personal")); | ||
| 672 | }), | ||
| 673 | ); | ||
| 674 | |||
| 675 | it.effect( | ||
| 676 | "ModelSelection decodes unknown driver kinds via legacy provider (rollback / fork invariant)", | ||
| 677 | () => | ||
| 678 | Effect.gen(function* () { | ||
| 679 | const parsed = yield* decodeModelSelection({ | ||
| 680 | provider: "ollama", | ||
| 681 | model: "llama3:70b", | ||
| 682 | options: [{ id: "temperature", value: "0.4" }], | ||
| 683 | }); | ||
| 684 | assert.strictEqual(parsed.instanceId, ProviderInstanceId.make("ollama")); | ||
| 685 | assert.strictEqual(parsed.model, "llama3:70b"); | ||
| 686 | }), | ||
| 687 | ); | ||
| 688 | |||
| 689 | it.effect("ModelSelection encodes to the canonical instanceId wire form", () => | ||
| 690 | Effect.gen(function* () { | ||
| 691 | const decoded = yield* decodeModelSelection({ | ||
| 692 | provider: "ollama", | ||
| 693 | model: "llama3:70b", | ||
| 694 | options: [{ id: "temperature", value: "0.4" }], | ||
| 695 | }); | ||
| 696 | const encoded = yield* encodeModelSelection(decoded); | ||
| 697 | assert.deepStrictEqual(encoded, { | ||
| 698 | instanceId: "ollama", | ||
| 699 | model: "llama3:70b", | ||
| 700 | options: [{ id: "temperature", value: "0.4" }], | ||
| 701 | }); | ||
| 702 | }), | ||
| 703 | ); | ||
| 704 | |||
| 705 | it.effect("ModelSelection rejects malformed instance ids", () => | ||
| 706 | Effect.gen(function* () { | ||
| 707 | const result = yield* Effect.exit( | ||
| 708 | decodeModelSelection({ | ||
| 709 | instanceId: "1invalid", // must start with a letter | ||
| 710 | model: "x", | ||
| 711 | }), | ||
| 712 | ); | ||
| 713 | assert.strictEqual(result._tag, "Failure"); | ||
| 714 | }), | ||
| 715 | ); |
packages/contracts/src/orchestration.ts+65-34| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; | 1 | import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect"; |
| 2 | import { ProviderOptionSelections } from "./model.ts"; | 2 | import { ProviderOptionSelections } from "./model.ts"; |
| 3 | import { RepositoryIdentity } from "./environment.ts"; | 3 | import { RepositoryIdentity } from "./environment.ts"; |
| 4 | import { | 4 | import { |
| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | TrimmedNonEmptyString, | 15 | TrimmedNonEmptyString, |
| 16 | TurnId, | 16 | TurnId, |
| 17 | } from "./baseSchemas.ts"; | 17 | } from "./baseSchemas.ts"; |
| 18 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 18 | 19 | ||
| 19 | export const ORCHESTRATION_WS_METHODS = { | 20 | export const ORCHESTRATION_WS_METHODS = { |
| 20 | dispatchCommand: "orchestration.dispatchCommand", | 21 | dispatchCommand: "orchestration.dispatchCommand", |
| ... | @@ -25,8 +26,6 @@ export const ORCHESTRATION_WS_METHODS = { | ... | @@ -25,8 +26,6 @@ export const ORCHESTRATION_WS_METHODS = { |
| 25 | subscribeThread: "orchestration.subscribeThread", | 26 | subscribeThread: "orchestration.subscribeThread", |
| 26 | } as const; | 27 | } as const; |
| 27 | 28 | ||
| 28 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]); | ||
| 29 | export type ProviderKind = typeof ProviderKind.Type; | ||
| 30 | export const ProviderApprovalPolicy = Schema.Literals([ | 29 | export const ProviderApprovalPolicy = Schema.Literals([ |
| 31 | "untrusted", | 30 | "untrusted", |
| 32 | "on-failure", | 31 | "on-failure", |
| ... | @@ -41,41 +40,72 @@ export const ProviderSandboxMode = Schema.Literals([ | ... | @@ -41,41 +40,72 @@ export const ProviderSandboxMode = Schema.Literals([ |
| 41 | ]); | 40 | ]); |
| 42 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | 41 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 43 | 42 | ||
| 44 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; | 43 | /** |
| 45 | 44 | * `ModelSelection` — selection of a model on a configured provider instance. | |
| 46 | export const CodexModelSelection = Schema.Struct({ | 45 | * |
| 47 | provider: Schema.Literal("codex"), | 46 | * The routing key is `instanceId` (a user-defined slug identifying one |
| 48 | model: TrimmedNonEmptyString, | 47 | * configured provider instance). Drivers, credentials, working-directory |
| 49 | options: Schema.optionalKey(ProviderOptionSelections), | 48 | * bindings, and any other per-instance state are recovered from the |
| 50 | }); | 49 | * runtime registry via the instance id. |
| 51 | export type CodexModelSelection = typeof CodexModelSelection.Type; | 50 | * |
| 52 | 51 | * Wire legacy: persisted selections produced before the driver/instance | |
| 53 | export const ClaudeModelSelection = Schema.Struct({ | 52 | * split carried a `provider: <driver-id>` field instead. The schema absorbs |
| 54 | provider: Schema.Literal("claudeAgent"), | 53 | * that shape via a pre-decoding transform — `{provider, model}` is promoted |
| 54 | * to `{instanceId: defaultInstanceIdForDriver(provider), model}`. No | ||
| 55 | * post-decode compatibility code lives in the runtime; the transform is the | ||
| 56 | * only compat surface. | ||
| 57 | */ | ||
| 58 | const ModelSelectionWire = Schema.Struct({ | ||
| 59 | instanceId: ProviderInstanceId, | ||
| 55 | model: TrimmedNonEmptyString, | 60 | model: TrimmedNonEmptyString, |
| 56 | options: Schema.optionalKey(ProviderOptionSelections), | 61 | options: Schema.optionalKey(ProviderOptionSelections), |
| 57 | }); | 62 | }); |
| 58 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; | ||
| 59 | 63 | ||
| 60 | export const CursorModelSelection = Schema.Struct({ | 64 | // Source shape for persisted legacy payloads. Fields are typed as |
| 61 | provider: Schema.Literal("cursor"), | 65 | // `Schema.Unknown` so malformed drafts still make it into the transform and |
| 62 | model: TrimmedNonEmptyString, | 66 | // fail validation through the target schema (with proper error messages) |
| 63 | options: Schema.optionalKey(ProviderOptionSelections), | 67 | // rather than at the source-struct layer where the error is less actionable. |
| 64 | }); | 68 | const ModelSelectionSource = Schema.Struct({ |
| 65 | export type CursorModelSelection = typeof CursorModelSelection.Type; | 69 | provider: Schema.optional(Schema.Unknown), |
| 66 | export const OpenCodeModelSelection = Schema.Struct({ | 70 | instanceId: Schema.optional(Schema.Unknown), |
| 67 | provider: Schema.Literal("opencode"), | 71 | model: Schema.Unknown, |
| 68 | model: TrimmedNonEmptyString, | 72 | options: Schema.optional(Schema.Unknown), |
| 69 | options: Schema.optionalKey(ProviderOptionSelections), | 73 | }); |
| 70 | }); | 74 | |
| 71 | export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; | 75 | export const ModelSelection = ModelSelectionSource.pipe( |
| 72 | 76 | Schema.decodeTo( | |
| 73 | export const ModelSelection = Schema.Union([ | 77 | ModelSelectionWire, |
| 74 | CodexModelSelection, | 78 | SchemaTransformation.transformOrFail({ |
| 75 | ClaudeModelSelection, | 79 | decode: (raw) => { |
| 76 | CursorModelSelection, | 80 | // Resolve the routing key: prefer an explicit `instanceId`; fall |
| 77 | OpenCodeModelSelection, | 81 | // back to promoting the legacy `provider` slug (the canonical |
| 78 | ]); | 82 | // `defaultInstanceIdForDriver` mapping) so persisted rollout-era |
| 83 | // payloads decode without data loss. The target schema brands the | ||
| 84 | // string as `ProviderInstanceId`. | ||
| 85 | const instanceIdSource = | ||
| 86 | raw.instanceId !== undefined | ||
| 87 | ? raw.instanceId | ||
| 88 | : typeof raw.provider === "string" | ||
| 89 | ? raw.provider | ||
| 90 | : undefined; | ||
| 91 | const base: Record<string, unknown> = { | ||
| 92 | instanceId: instanceIdSource, | ||
| 93 | model: raw.model, | ||
| 94 | }; | ||
| 95 | if (raw.options !== undefined) base.options = raw.options; | ||
| 96 | return Effect.succeed(base as typeof ModelSelectionWire.Encoded); | ||
| 97 | }, | ||
| 98 | encode: (value) => { | ||
| 99 | const base: Record<string, unknown> = { | ||
| 100 | model: value.model, | ||
| 101 | instanceId: value.instanceId, | ||
| 102 | }; | ||
| 103 | if (value.options !== undefined) base.options = value.options; | ||
| 104 | return Effect.succeed(base as typeof ModelSelectionSource.Encoded); | ||
| 105 | }, | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | ); | ||
| 79 | export type ModelSelection = typeof ModelSelection.Type; | 109 | export type ModelSelection = typeof ModelSelection.Type; |
| 80 | 110 | ||
| 81 | export const RuntimeMode = Schema.Literals([ | 111 | export const RuntimeMode = Schema.Literals([ |
| ... | @@ -225,6 +255,7 @@ export const OrchestrationSession = Schema.Struct({ | ... | @@ -225,6 +255,7 @@ export const OrchestrationSession = Schema.Struct({ |
| 225 | threadId: ThreadId, | 255 | threadId: ThreadId, |
| 226 | status: OrchestrationSessionStatus, | 256 | status: OrchestrationSessionStatus, |
| 227 | providerName: Schema.NullOr(TrimmedNonEmptyString), | 257 | providerName: Schema.NullOr(TrimmedNonEmptyString), |
| 258 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 228 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), | 259 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), |
| 229 | activeTurnId: Schema.NullOr(TurnId), | 260 | activeTurnId: Schema.NullOr(TurnId), |
| 230 | lastError: Schema.NullOr(TrimmedNonEmptyString), | 261 | lastError: Schema.NullOr(TrimmedNonEmptyString), |
packages/contracts/src/provider.test.ts+115-30| ... | @@ -1,10 +1,17 @@ | ... | @@ -1,10 +1,17 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts"; | 4 | import { |
| 5 | ProviderEvent, | ||
| 6 | ProviderSendTurnInput, | ||
| 7 | ProviderSession, | ||
| 8 | ProviderSessionStartInput, | ||
| 9 | } from "./provider.ts"; | ||
| 5 | 10 | ||
| 6 | const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); | 11 | const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); |
| 7 | const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); | 12 | const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); |
| 13 | const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession); | ||
| 14 | const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent); | ||
| 8 | 15 | ||
| 9 | function getOptionValue( | 16 | function getOptionValue( |
| 10 | options: ReadonlyArray<{ id: string; value: unknown }> | undefined, | 17 | options: ReadonlyArray<{ id: string; value: unknown }> | undefined, |
| ... | @@ -30,13 +37,10 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -30,13 +37,10 @@ describe("ProviderSessionStartInput", () => { |
| 30 | runtimeMode: "full-access", | 37 | runtimeMode: "full-access", |
| 31 | }); | 38 | }); |
| 32 | expect(parsed.runtimeMode).toBe("full-access"); | 39 | expect(parsed.runtimeMode).toBe("full-access"); |
| 33 | expect(parsed.modelSelection?.provider).toBe("codex"); | 40 | expect(parsed.modelSelection?.instanceId).toBe("codex"); |
| 34 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); | 41 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); |
| 35 | if (parsed.modelSelection?.provider !== "codex") { | 42 | expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("high"); |
| 36 | throw new Error("Expected codex modelSelection"); | 43 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 37 | } | ||
| 38 | expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("high"); | ||
| 39 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | ||
| 40 | }); | 44 | }); |
| 41 | 45 | ||
| 42 | it("rejects payloads without runtime mode", () => { | 46 | it("rejects payloads without runtime mode", () => { |
| ... | @@ -65,14 +69,11 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -65,14 +69,11 @@ describe("ProviderSessionStartInput", () => { |
| 65 | runtimeMode: "full-access", | 69 | runtimeMode: "full-access", |
| 66 | }); | 70 | }); |
| 67 | expect(parsed.provider).toBe("claudeAgent"); | 71 | expect(parsed.provider).toBe("claudeAgent"); |
| 68 | expect(parsed.modelSelection?.provider).toBe("claudeAgent"); | 72 | expect(parsed.modelSelection?.instanceId).toBe("claudeAgent"); |
| 69 | expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6"); | 73 | expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6"); |
| 70 | if (parsed.modelSelection?.provider !== "claudeAgent") { | 74 | expect(getOptionValue(parsed.modelSelection?.options, "thinking")).toBe(true); |
| 71 | throw new Error("Expected claude modelSelection"); | 75 | expect(getOptionValue(parsed.modelSelection?.options, "effort")).toBe("max"); |
| 72 | } | 76 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 73 | expect(getOptionValue(parsed.modelSelection.options, "thinking")).toBe(true); | ||
| 74 | expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("max"); | ||
| 75 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | ||
| 76 | expect(parsed.runtimeMode).toBe("full-access"); | 77 | expect(parsed.runtimeMode).toBe("full-access"); |
| 77 | }); | 78 | }); |
| 78 | 79 | ||
| ... | @@ -89,11 +90,27 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -89,11 +90,27 @@ describe("ProviderSessionStartInput", () => { |
| 89 | }, | 90 | }, |
| 90 | }); | 91 | }); |
| 91 | expect(parsed.provider).toBe("cursor"); | 92 | expect(parsed.provider).toBe("cursor"); |
| 92 | expect(parsed.modelSelection?.provider).toBe("cursor"); | 93 | expect(parsed.modelSelection?.instanceId).toBe("cursor"); |
| 93 | expect(parsed.modelSelection?.model).toBe("composer-2"); | 94 | expect(parsed.modelSelection?.model).toBe("composer-2"); |
| 94 | if (parsed.modelSelection?.provider === "cursor") { | 95 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 95 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | 96 | }); |
| 96 | } | 97 | |
| 98 | it("accepts fork-provided driver kinds as branded slugs", () => { | ||
| 99 | const parsed = decodeProviderSessionStartInput({ | ||
| 100 | threadId: "thread-1", | ||
| 101 | provider: "ollama", | ||
| 102 | providerInstanceId: "ollama_local", | ||
| 103 | cwd: "/tmp/workspace", | ||
| 104 | runtimeMode: "full-access", | ||
| 105 | modelSelection: { | ||
| 106 | instanceId: "ollama_local", | ||
| 107 | model: "llama3.3", | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | |||
| 111 | expect(parsed.provider).toBe("ollama"); | ||
| 112 | expect(parsed.providerInstanceId).toBe("ollama_local"); | ||
| 113 | expect(parsed.modelSelection?.instanceId).toBe("ollama_local"); | ||
| 97 | }); | 114 | }); |
| 98 | }); | 115 | }); |
| 99 | 116 | ||
| ... | @@ -111,13 +128,10 @@ describe("ProviderSendTurnInput", () => { | ... | @@ -111,13 +128,10 @@ describe("ProviderSendTurnInput", () => { |
| 111 | }, | 128 | }, |
| 112 | }); | 129 | }); |
| 113 | 130 | ||
| 114 | expect(parsed.modelSelection?.provider).toBe("codex"); | 131 | expect(parsed.modelSelection?.instanceId).toBe("codex"); |
| 115 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); | 132 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); |
| 116 | if (parsed.modelSelection?.provider !== "codex") { | 133 | expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("xhigh"); |
| 117 | throw new Error("Expected codex modelSelection"); | 134 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 118 | } | ||
| 119 | expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("xhigh"); | ||
| 120 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | ||
| 121 | }); | 135 | }); |
| 122 | 136 | ||
| 123 | it("accepts claude modelSelection including ultrathink", () => { | 137 | it("accepts claude modelSelection including ultrathink", () => { |
| ... | @@ -133,11 +147,82 @@ describe("ProviderSendTurnInput", () => { | ... | @@ -133,11 +147,82 @@ describe("ProviderSendTurnInput", () => { |
| 133 | }, | 147 | }, |
| 134 | }); | 148 | }); |
| 135 | 149 | ||
| 136 | expect(parsed.modelSelection?.provider).toBe("claudeAgent"); | 150 | expect(parsed.modelSelection?.instanceId).toBe("claudeAgent"); |
| 137 | if (parsed.modelSelection?.provider !== "claudeAgent") { | 151 | expect(getOptionValue(parsed.modelSelection?.options, "effort")).toBe("ultrathink"); |
| 138 | throw new Error("Expected claude modelSelection"); | 152 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 139 | } | 153 | }); |
| 140 | expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("ultrathink"); | 154 | }); |
| 141 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | 155 | |
| 156 | describe("providerInstanceId routing key (slice-2 invariant)", () => { | ||
| 157 | it("decodes a ProviderSessionStartInput without providerInstanceId (legacy producer)", () => { | ||
| 158 | const parsed = decodeProviderSessionStartInput({ | ||
| 159 | threadId: "thread-1", | ||
| 160 | provider: "codex", | ||
| 161 | runtimeMode: "full-access", | ||
| 162 | }); | ||
| 163 | expect(parsed.providerInstanceId).toBeUndefined(); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("decodes a ProviderSessionStartInput with providerInstanceId (post-migration producer)", () => { | ||
| 167 | const parsed = decodeProviderSessionStartInput({ | ||
| 168 | threadId: "thread-1", | ||
| 169 | provider: "codex", | ||
| 170 | providerInstanceId: "codex_personal", | ||
| 171 | runtimeMode: "full-access", | ||
| 172 | }); | ||
| 173 | expect(parsed.providerInstanceId).toBe("codex_personal"); | ||
| 174 | }); | ||
| 175 | |||
| 176 | it("propagates providerInstanceId through ProviderSession decode", () => { | ||
| 177 | const session = decodeProviderSession({ | ||
| 178 | provider: "codex", | ||
| 179 | providerInstanceId: "codex_work", | ||
| 180 | status: "ready", | ||
| 181 | runtimeMode: "full-access", | ||
| 182 | threadId: "thread-1", | ||
| 183 | createdAt: "2024-01-01T00:00:00Z", | ||
| 184 | updatedAt: "2024-01-01T00:00:00Z", | ||
| 185 | }); | ||
| 186 | expect(session.providerInstanceId).toBe("codex_work"); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("decodes ProviderSession for fork-provided driver kinds", () => { | ||
| 190 | const session = decodeProviderSession({ | ||
| 191 | provider: "ollama", | ||
| 192 | providerInstanceId: "ollama_local", | ||
| 193 | status: "ready", | ||
| 194 | runtimeMode: "full-access", | ||
| 195 | threadId: "thread-1", | ||
| 196 | createdAt: "2024-01-01T00:00:00Z", | ||
| 197 | updatedAt: "2024-01-01T00:00:00Z", | ||
| 198 | }); | ||
| 199 | |||
| 200 | expect(session.provider).toBe("ollama"); | ||
| 201 | expect(session.providerInstanceId).toBe("ollama_local"); | ||
| 202 | }); | ||
| 203 | |||
| 204 | it("decodes a ProviderEvent carrying both legacy provider and new instance routing", () => { | ||
| 205 | const event = decodeProviderEvent({ | ||
| 206 | id: "event-1", | ||
| 207 | kind: "notification", | ||
| 208 | provider: "codex", | ||
| 209 | providerInstanceId: "codex_personal", | ||
| 210 | threadId: "thread-1", | ||
| 211 | createdAt: "2024-01-01T00:00:00Z", | ||
| 212 | method: "session.created", | ||
| 213 | }); | ||
| 214 | expect(event.provider).toBe("codex"); | ||
| 215 | expect(event.providerInstanceId).toBe("codex_personal"); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it("rejects providerInstanceId values that fail the slug pattern (defense in depth)", () => { | ||
| 219 | expect(() => | ||
| 220 | decodeProviderSessionStartInput({ | ||
| 221 | threadId: "thread-1", | ||
| 222 | provider: "codex", | ||
| 223 | providerInstanceId: "1bad", | ||
| 224 | runtimeMode: "full-access", | ||
| 225 | }), | ||
| 226 | ).toThrow(); | ||
| 142 | }); | 227 | }); |
| 143 | }); | 228 | }); |
packages/contracts/src/provider.ts+12-4| ... | @@ -16,12 +16,12 @@ import { | ... | @@ -16,12 +16,12 @@ import { |
| 16 | ProviderApprovalDecision, | 16 | ProviderApprovalDecision, |
| 17 | ProviderApprovalPolicy, | 17 | ProviderApprovalPolicy, |
| 18 | ProviderInteractionMode, | 18 | ProviderInteractionMode, |
| 19 | ProviderKind, | ||
| 20 | ProviderRequestKind, | 19 | ProviderRequestKind, |
| 21 | ProviderSandboxMode, | 20 | ProviderSandboxMode, |
| 22 | ProviderUserInputAnswers, | 21 | ProviderUserInputAnswers, |
| 23 | RuntimeMode, | 22 | RuntimeMode, |
| 24 | } from "./orchestration.ts"; | 23 | } from "./orchestration.ts"; |
| 24 | import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; | ||
| 25 | 25 | ||
| 26 | const ProviderSessionStatus = Schema.Literals([ | 26 | const ProviderSessionStatus = Schema.Literals([ |
| 27 | "connecting", | 27 | "connecting", |
| ... | @@ -32,7 +32,11 @@ const ProviderSessionStatus = Schema.Literals([ | ... | @@ -32,7 +32,11 @@ const ProviderSessionStatus = Schema.Literals([ |
| 32 | ]); | 32 | ]); |
| 33 | 33 | ||
| 34 | export const ProviderSession = Schema.Struct({ | 34 | export const ProviderSession = Schema.Struct({ |
| 35 | provider: ProviderKind, | 35 | provider: ProviderDriverKind, |
| 36 | // Optional during the driver/instance migration. Once every producer | ||
| 37 | // populates it (post-slice-4), routing flips to instance-id-only and the | ||
| 38 | // legacy `provider` field is removed. | ||
| 39 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 36 | status: ProviderSessionStatus, | 40 | status: ProviderSessionStatus, |
| 37 | runtimeMode: RuntimeMode, | 41 | runtimeMode: RuntimeMode, |
| 38 | cwd: Schema.optional(TrimmedNonEmptyString), | 42 | cwd: Schema.optional(TrimmedNonEmptyString), |
| ... | @@ -48,7 +52,9 @@ export type ProviderSession = typeof ProviderSession.Type; | ... | @@ -48,7 +52,9 @@ export type ProviderSession = typeof ProviderSession.Type; |
| 48 | 52 | ||
| 49 | export const ProviderSessionStartInput = Schema.Struct({ | 53 | export const ProviderSessionStartInput = Schema.Struct({ |
| 50 | threadId: ThreadId, | 54 | threadId: ThreadId, |
| 51 | provider: Schema.optional(ProviderKind), | 55 | provider: Schema.optional(ProviderDriverKind), |
| 56 | // See ProviderSession for the migration story. | ||
| 57 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 52 | cwd: Schema.optional(TrimmedNonEmptyString), | 58 | cwd: Schema.optional(TrimmedNonEmptyString), |
| 53 | modelSelection: Schema.optional(ModelSelection), | 59 | modelSelection: Schema.optional(ModelSelection), |
| 54 | resumeCursor: Schema.optional(Schema.Unknown), | 60 | resumeCursor: Schema.optional(Schema.Unknown), |
| ... | @@ -108,7 +114,9 @@ const ProviderEventKind = Schema.Literals(["session", "notification", "request", | ... | @@ -108,7 +114,9 @@ const ProviderEventKind = Schema.Literals(["session", "notification", "request", |
| 108 | export const ProviderEvent = Schema.Struct({ | 114 | export const ProviderEvent = Schema.Struct({ |
| 109 | id: EventId, | 115 | id: EventId, |
| 110 | kind: ProviderEventKind, | 116 | kind: ProviderEventKind, |
| 111 | provider: ProviderKind, | 117 | provider: ProviderDriverKind, |
| 118 | // See ProviderSession for the migration story. | ||
| 119 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 112 | threadId: ThreadId, | 120 | threadId: ThreadId, |
| 113 | createdAt: IsoDateTime, | 121 | createdAt: IsoDateTime, |
| 114 | method: TrimmedNonEmptyString, | 122 | method: TrimmedNonEmptyString, |
packages/contracts/src/providerInstance.test.ts created+192| ... | @@ -0,0 +1,192 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | ProviderDriverKind, | ||
| 6 | ProviderInstanceConfig, | ||
| 7 | ProviderInstanceConfigMap, | ||
| 8 | ProviderInstanceId, | ||
| 9 | ProviderInstanceRef, | ||
| 10 | } from "./providerInstance.ts"; | ||
| 11 | |||
| 12 | const decodeProviderDriverKind = Schema.decodeUnknownSync(ProviderDriverKind); | ||
| 13 | const decodeProviderInstanceId = Schema.decodeUnknownSync(ProviderInstanceId); | ||
| 14 | const decodeProviderInstanceRef = Schema.decodeUnknownSync(ProviderInstanceRef); | ||
| 15 | const decodeProviderInstanceConfig = Schema.decodeUnknownSync(ProviderInstanceConfig); | ||
| 16 | const decodeProviderInstanceConfigMap = Schema.decodeUnknownSync(ProviderInstanceConfigMap); | ||
| 17 | |||
| 18 | describe("provider slug validation (shared by driver + instance ids)", () => { | ||
| 19 | const cases = [ | ||
| 20 | { schemaName: "ProviderInstanceId", decode: decodeProviderInstanceId }, | ||
| 21 | { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind }, | ||
| 22 | ] as const; | ||
| 23 | |||
| 24 | for (const { schemaName, decode } of cases) { | ||
| 25 | describe(schemaName, () => { | ||
| 26 | it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( | ||
| 27 | "accepts %s", | ||
| 28 | (id) => { | ||
| 29 | expect(decode(id)).toBe(id); | ||
| 30 | }, | ||
| 31 | ); | ||
| 32 | |||
| 33 | it.each([ | ||
| 34 | ["empty string", ""], | ||
| 35 | ["leading digit", "1codex"], | ||
| 36 | ["leading dash", "-codex"], | ||
| 37 | ["leading underscore", "_codex"], | ||
| 38 | ["whitespace inside", "codex personal"], | ||
| 39 | ["dot inside", "codex.personal"], | ||
| 40 | ["slash inside", "codex/personal"], | ||
| 41 | ])("rejects %s", (_label, value) => { | ||
| 42 | expect(() => decode(value)).toThrow(); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("trims surrounding whitespace before validating", () => { | ||
| 46 | expect(decode(" codex_work ")).toBe("codex_work"); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("rejects ids longer than 64 characters", () => { | ||
| 50 | const tooLong = "a".repeat(65); | ||
| 51 | expect(() => decode(tooLong)).toThrow(); | ||
| 52 | const justRight = "a".repeat(64); | ||
| 53 | expect(decode(justRight)).toBe(justRight); | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | }); | ||
| 58 | |||
| 59 | describe("ProviderInstanceRef", () => { | ||
| 60 | it("decodes a driver ref", () => { | ||
| 61 | const ref = decodeProviderInstanceRef({ | ||
| 62 | instanceId: "codex_work", | ||
| 63 | driver: "codex", | ||
| 64 | }); | ||
| 65 | expect(ref.instanceId).toBe("codex_work"); | ||
| 66 | expect(ref.driver).toBe("codex"); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("decodes a fork-defined driver ref without complaint", () => { | ||
| 70 | const ref = decodeProviderInstanceRef({ | ||
| 71 | instanceId: "ollama_local", | ||
| 72 | driver: "ollama", | ||
| 73 | }); | ||
| 74 | expect(ref.instanceId).toBe("ollama_local"); | ||
| 75 | expect(ref.driver).toBe("ollama"); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("rejects refs whose driver field is not a valid slug", () => { | ||
| 79 | expect(() => | ||
| 80 | decodeProviderInstanceRef({ | ||
| 81 | instanceId: "codex", | ||
| 82 | driver: "1nope", | ||
| 83 | }), | ||
| 84 | ).toThrow(); | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | describe("ProviderInstanceConfig", () => { | ||
| 89 | it("accepts a minimal config envelope for a driver", () => { | ||
| 90 | const decoded = decodeProviderInstanceConfig({ driver: "codex" }); | ||
| 91 | expect(decoded.driver).toBe("codex"); | ||
| 92 | expect(decoded.displayName).toBeUndefined(); | ||
| 93 | expect(decoded.enabled).toBeUndefined(); | ||
| 94 | expect(decoded.config).toBeUndefined(); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("preserves driver-opaque config payloads verbatim", () => { | ||
| 98 | const opaqueConfig = { homePath: "~/.codex_personal", binaryPath: "codex" }; | ||
| 99 | const decoded = decodeProviderInstanceConfig({ | ||
| 100 | driver: "codex", | ||
| 101 | displayName: "Codex (personal)", | ||
| 102 | accentColor: "#dc2626", | ||
| 103 | enabled: true, | ||
| 104 | config: opaqueConfig, | ||
| 105 | }); | ||
| 106 | expect(decoded.displayName).toBe("Codex (personal)"); | ||
| 107 | expect(decoded.accentColor).toBe("#dc2626"); | ||
| 108 | expect(decoded.enabled).toBe(true); | ||
| 109 | expect(decoded.config).toEqual(opaqueConfig); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("decodes generic environment variables on the instance envelope", () => { | ||
| 113 | const decoded = decodeProviderInstanceConfig({ | ||
| 114 | driver: "claudeAgent", | ||
| 115 | environment: [ | ||
| 116 | { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, | ||
| 117 | { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, | ||
| 118 | { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, | ||
| 119 | ], | ||
| 120 | }); | ||
| 121 | |||
| 122 | expect(decoded.environment).toEqual([ | ||
| 123 | { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, | ||
| 124 | { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, | ||
| 125 | { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, | ||
| 126 | ]); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("rejects invalid environment variable names", () => { | ||
| 130 | expect(() => | ||
| 131 | decodeProviderInstanceConfig({ | ||
| 132 | driver: "codex", | ||
| 133 | environment: [{ name: "HAS-DASH", value: "x", sensitive: false }], | ||
| 134 | }), | ||
| 135 | ).toThrow(); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("decodes envelopes that name an unknown driver and preserves their config opaquely", () => { | ||
| 139 | const opaqueConfig = { someUnknownKnob: 42, model: "llama3" }; | ||
| 140 | const decoded = decodeProviderInstanceConfig({ | ||
| 141 | driver: "ollama", | ||
| 142 | displayName: "Ollama", | ||
| 143 | enabled: true, | ||
| 144 | config: opaqueConfig, | ||
| 145 | }); | ||
| 146 | expect(decoded.driver).toBe("ollama"); | ||
| 147 | expect(decoded.config).toEqual(opaqueConfig); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("rejects a blank displayName (must be trimmed non-empty)", () => { | ||
| 151 | expect(() => decodeProviderInstanceConfig({ driver: "codex", displayName: " " })).toThrow(); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("rejects driver values that do not satisfy the slug pattern", () => { | ||
| 155 | expect(() => decodeProviderInstanceConfig({ driver: "" })).toThrow(); | ||
| 156 | expect(() => decodeProviderInstanceConfig({ driver: "has spaces" })).toThrow(); | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | |||
| 160 | describe("ProviderInstanceConfigMap", () => { | ||
| 161 | it("decodes a multi-instance map mixing first-party and fork drivers", () => { | ||
| 162 | const decoded = decodeProviderInstanceConfigMap({ | ||
| 163 | codex_personal: { | ||
| 164 | driver: "codex", | ||
| 165 | displayName: "Codex (personal)", | ||
| 166 | config: { homePath: "~/.codex_personal" }, | ||
| 167 | }, | ||
| 168 | codex_work: { | ||
| 169 | driver: "codex", | ||
| 170 | config: { homePath: "~/.codex_work" }, | ||
| 171 | }, | ||
| 172 | claudeAgent: { driver: "claudeAgent" }, | ||
| 173 | ollama_local: { driver: "ollama", config: { endpoint: "http://localhost:11434" } }, | ||
| 174 | }); | ||
| 175 | expect(new Set(Object.keys(decoded))).toEqual( | ||
| 176 | new Set(["claudeAgent", "codex_personal", "codex_work", "ollama_local"]), | ||
| 177 | ); | ||
| 178 | expect(decoded[ProviderInstanceId.make("codex_personal")]?.driver).toBe("codex"); | ||
| 179 | expect(decoded[ProviderInstanceId.make("codex_work")]?.config).toEqual({ | ||
| 180 | homePath: "~/.codex_work", | ||
| 181 | }); | ||
| 182 | expect(decoded[ProviderInstanceId.make("ollama_local")]?.driver).toBe("ollama"); | ||
| 183 | }); | ||
| 184 | |||
| 185 | it("rejects keys that fail the instance-id pattern", () => { | ||
| 186 | expect(() => | ||
| 187 | decodeProviderInstanceConfigMap({ | ||
| 188 | "1codex": { driver: "codex" }, | ||
| 189 | }), | ||
| 190 | ).toThrow(); | ||
| 191 | }); | ||
| 192 | }); | ||
packages/contracts/src/providerInstance.ts created+148| ... | @@ -0,0 +1,148 @@ | ||
| 1 | /** | ||
| 2 | * Provider-instance contracts. | ||
| 3 | * | ||
| 4 | * Splits the historical "provider kind" concept into two: | ||
| 5 | * | ||
| 6 | * - `ProviderDriverKind` is the implementation kind selector (e.g. codex, | ||
| 7 | * claudeAgent, a fork's `ollama`, …). It picks which driver package | ||
| 8 | * handles the protocol, the probe, the adapter, and text generation. | ||
| 9 | * | ||
| 10 | * - `ProviderInstanceId` is the routing key (a user-defined slug). | ||
| 11 | * Threads, sessions, runtime events, and persisted bindings reference | ||
| 12 | * instance ids — never driver kinds — so a user can configure multiple | ||
| 13 | * instances of the same driver (e.g. `codex_personal` + `codex_work`), | ||
| 14 | * each with independent driver-specific configuration. | ||
| 15 | * | ||
| 16 | * Forward/backward compatibility invariant | ||
| 17 | * ---------------------------------------- | ||
| 18 | * `ProviderDriverKind` is intentionally an **open** branded slug, not a closed | ||
| 19 | * literal union. The server hosts forks, ships in PRs that add drivers, and | ||
| 20 | * users frequently roll between branches and forks. Any of those paths can | ||
| 21 | * leave `ServerSettings`, persisted thread state, or session bindings | ||
| 22 | * referencing a driver that the currently-running build does not know about. | ||
| 23 | * | ||
| 24 | * The rule: parsing any of those payloads must always succeed, and the | ||
| 25 | * runtime is responsible for marking the unknown driver/instance as | ||
| 26 | * "unavailable" rather than crashing. Built-in drivers shipped by the core | ||
| 27 | * product happens to register in a given build is not part of the contract | ||
| 28 | * layer. Driver availability is discovered through the runtime registry. | ||
| 29 | * | ||
| 30 | * Driver-specific configuration is similarly opaque at the contracts layer: | ||
| 31 | * drivers live in (or will be extracted to) their own packages and own their | ||
| 32 | * config schemas. The contracts package only knows the envelope. | ||
| 33 | * | ||
| 34 | * @module providerInstance | ||
| 35 | */ | ||
| 36 | import { Effect, Schema } from "effect"; | ||
| 37 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 38 | |||
| 39 | const PROVIDER_SLUG_MAX_CHARS = 64; | ||
| 40 | /** | ||
| 41 | * Slug pattern shared by driver kinds and instance ids — letters, digits, | ||
| 42 | * dashes, underscores. The first character must be a letter so slugs remain | ||
| 43 | * JS-identifier friendly when used as object keys, log fields, or telemetry | ||
| 44 | * attributes. Mixed case is permitted so historical driver kinds (e.g. | ||
| 45 | * `claudeAgent`) can be used verbatim during the migration and so external | ||
| 46 | * fork authors retain reasonable freedom. | ||
| 47 | */ | ||
| 48 | const PROVIDER_SLUG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | ||
| 49 | const ENVIRONMENT_VARIABLE_NAME_MAX_CHARS = 128; | ||
| 50 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | ||
| 51 | |||
| 52 | const slugSchema = TrimmedNonEmptyString.check( | ||
| 53 | Schema.isMaxLength(PROVIDER_SLUG_MAX_CHARS), | ||
| 54 | Schema.isPattern(PROVIDER_SLUG_PATTERN), | ||
| 55 | ); | ||
| 56 | |||
| 57 | /** | ||
| 58 | * `ProviderDriverKind` — open branded slug naming a driver implementation. | ||
| 59 | * | ||
| 60 | * Constraints (validated at the schema layer): | ||
| 61 | * - starts with a letter | ||
| 62 | * - only letters, digits, `-`, `_` after the first char | ||
| 63 | * - 1..64 characters | ||
| 64 | * | ||
| 65 | * Notably **not** validated: that the driver is one we know how to load. | ||
| 66 | * That check belongs to the runtime registry, which downgrades unknown | ||
| 67 | * drivers gracefully (see module docs). | ||
| 68 | */ | ||
| 69 | export const ProviderDriverKind = slugSchema.pipe(Schema.brand("ProviderDriverKind")); | ||
| 70 | export type ProviderDriverKind = typeof ProviderDriverKind.Type; | ||
| 71 | |||
| 72 | const isProviderDriverKindValue = Schema.is(ProviderDriverKind); | ||
| 73 | export const isProviderDriverKind = (value: unknown): value is ProviderDriverKind => | ||
| 74 | isProviderDriverKindValue(value); | ||
| 75 | |||
| 76 | /** | ||
| 77 | * `ProviderInstanceId` — user-defined routing key for a configured provider | ||
| 78 | * instance. Same slug rules as `ProviderDriverKind`; branded separately so the | ||
| 79 | * type system cannot confuse the two. | ||
| 80 | */ | ||
| 81 | export const ProviderInstanceId = slugSchema.pipe(Schema.brand("ProviderInstanceId")); | ||
| 82 | export type ProviderInstanceId = typeof ProviderInstanceId.Type; | ||
| 83 | |||
| 84 | /** | ||
| 85 | * Lightweight reference identifying which driver implements an instance. | ||
| 86 | * Carried alongside `ProviderInstanceId` on wire shapes so consumers can | ||
| 87 | * branch on driver behavior (icons, capabilities, presentation) without | ||
| 88 | * having to look up the instance in the registry. | ||
| 89 | */ | ||
| 90 | export const ProviderInstanceRef = Schema.Struct({ | ||
| 91 | instanceId: ProviderInstanceId, | ||
| 92 | driver: ProviderDriverKind, | ||
| 93 | }); | ||
| 94 | export type ProviderInstanceRef = typeof ProviderInstanceRef.Type; | ||
| 95 | |||
| 96 | export const ProviderInstanceEnvironmentVariableName = TrimmedNonEmptyString.check( | ||
| 97 | Schema.isMaxLength(ENVIRONMENT_VARIABLE_NAME_MAX_CHARS), | ||
| 98 | Schema.isPattern(ENVIRONMENT_VARIABLE_NAME_PATTERN), | ||
| 99 | ); | ||
| 100 | export type ProviderInstanceEnvironmentVariableName = | ||
| 101 | typeof ProviderInstanceEnvironmentVariableName.Type; | ||
| 102 | |||
| 103 | export const ProviderInstanceEnvironmentVariable = Schema.Struct({ | ||
| 104 | name: ProviderInstanceEnvironmentVariableName, | ||
| 105 | value: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | ||
| 106 | sensitive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | ||
| 107 | valueRedacted: Schema.optionalKey(Schema.Boolean), | ||
| 108 | }); | ||
| 109 | export type ProviderInstanceEnvironmentVariable = typeof ProviderInstanceEnvironmentVariable.Type; | ||
| 110 | |||
| 111 | export const ProviderInstanceEnvironment = Schema.Array(ProviderInstanceEnvironmentVariable); | ||
| 112 | export type ProviderInstanceEnvironment = typeof ProviderInstanceEnvironment.Type; | ||
| 113 | |||
| 114 | /** | ||
| 115 | * Envelope shape for a provider instance configuration in `ServerSettings`. | ||
| 116 | * | ||
| 117 | * `driver` is intentionally accepted as any well-formed slug (see module | ||
| 118 | * docs). The driver-specific config payload is left as `Schema.Unknown`; | ||
| 119 | * each driver registers its own decoder with the runtime registry, and | ||
| 120 | * envelopes for unknown drivers are preserved verbatim so they round-trip | ||
| 121 | * across version changes without data loss. | ||
| 122 | */ | ||
| 123 | export const ProviderInstanceConfig = Schema.Struct({ | ||
| 124 | driver: ProviderDriverKind, | ||
| 125 | displayName: Schema.optional(TrimmedNonEmptyString), | ||
| 126 | accentColor: Schema.optional(TrimmedNonEmptyString), | ||
| 127 | environment: Schema.optionalKey(ProviderInstanceEnvironment), | ||
| 128 | enabled: Schema.optionalKey(Schema.Boolean), | ||
| 129 | config: Schema.optionalKey(Schema.Unknown), | ||
| 130 | }); | ||
| 131 | export type ProviderInstanceConfig = typeof ProviderInstanceConfig.Type; | ||
| 132 | |||
| 133 | /** | ||
| 134 | * Map shape for `ServerSettings.providerInstances`. Keyed by | ||
| 135 | * `ProviderInstanceId`, values are envelopes the registry feeds to drivers. | ||
| 136 | */ | ||
| 137 | export const ProviderInstanceConfigMap = Schema.Record(ProviderInstanceId, ProviderInstanceConfig); | ||
| 138 | export type ProviderInstanceConfigMap = typeof ProviderInstanceConfigMap.Type; | ||
| 139 | |||
| 140 | /** | ||
| 141 | * Construct the canonical `ProviderInstanceId` used as a back-compat default | ||
| 142 | * for a built-in driver. The legacy single-instance-per-driver world used | ||
| 143 | * the driver kind itself as the instance id; preserving that mapping keeps | ||
| 144 | * existing persisted threads, bindings, and cache files routable across the | ||
| 145 | * migration without rewriting their stored selection payloads. | ||
| 146 | */ | ||
| 147 | export const defaultInstanceIdForDriver = (driver: ProviderDriverKind): ProviderInstanceId => | ||
| 148 | ProviderInstanceId.make(driver); | ||
packages/contracts/src/providerRuntime.test.ts+17| ... | @@ -6,6 +6,23 @@ import { ProviderRuntimeEvent } from "./providerRuntime.ts"; | ... | @@ -6,6 +6,23 @@ import { ProviderRuntimeEvent } from "./providerRuntime.ts"; |
| 6 | const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); | 6 | const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); |
| 7 | 7 | ||
| 8 | describe("ProviderRuntimeEvent", () => { | 8 | describe("ProviderRuntimeEvent", () => { |
| 9 | it("accepts fork-provided driver kinds as branded slugs", () => { | ||
| 10 | const parsed = decodeRuntimeEvent({ | ||
| 11 | type: "session.started", | ||
| 12 | eventId: "event-ollama-session", | ||
| 13 | provider: "ollama", | ||
| 14 | providerInstanceId: "ollama_local", | ||
| 15 | createdAt: "2026-02-28T00:00:00.000Z", | ||
| 16 | threadId: "thread-1", | ||
| 17 | payload: { | ||
| 18 | message: "started", | ||
| 19 | }, | ||
| 20 | }); | ||
| 21 | |||
| 22 | expect(parsed.provider).toBe("ollama"); | ||
| 23 | expect(parsed.providerInstanceId).toBe("ollama_local"); | ||
| 24 | }); | ||
| 25 | |||
| 9 | it("decodes turn.plan.updated for plan rendering", () => { | 26 | it("decodes turn.plan.updated for plan rendering", () => { |
| 10 | const parsed = decodeRuntimeEvent({ | 27 | const parsed = decodeRuntimeEvent({ |
| 11 | type: "turn.plan.updated", | 28 | type: "turn.plan.updated", |
packages/contracts/src/providerRuntime.ts+6-2| ... | @@ -12,7 +12,7 @@ import { | ... | @@ -12,7 +12,7 @@ import { |
| 12 | TrimmedNonEmptyString, | 12 | TrimmedNonEmptyString, |
| 13 | TurnId, | 13 | TurnId, |
| 14 | } from "./baseSchemas.ts"; | 14 | } from "./baseSchemas.ts"; |
| 15 | import { ProviderKind } from "./orchestration.ts"; | 15 | import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; |
| 16 | 16 | ||
| 17 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 17 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 18 | const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); | 18 | const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); |
| ... | @@ -245,7 +245,11 @@ const RuntimeErrorType = Schema.Literal("runtime.error"); | ... | @@ -245,7 +245,11 @@ const RuntimeErrorType = Schema.Literal("runtime.error"); |
| 245 | 245 | ||
| 246 | const ProviderRuntimeEventBase = Schema.Struct({ | 246 | const ProviderRuntimeEventBase = Schema.Struct({ |
| 247 | eventId: EventId, | 247 | eventId: EventId, |
| 248 | provider: ProviderKind, | 248 | provider: ProviderDriverKind, |
| 249 | // Optional during the driver/instance migration. See providerInstance.ts | ||
| 250 | // for the routing-key-vs-driver-id distinction. Once every emitter | ||
| 251 | // populates it (post-slice-4), routing flips to instance-id-only. | ||
| 252 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 249 | threadId: ThreadId, | 253 | threadId: ThreadId, |
| 250 | createdAt: IsoDateTime, | 254 | createdAt: IsoDateTime, |
| 251 | turnId: Schema.optional(TurnId), | 255 | turnId: Schema.optional(TurnId), |
packages/contracts/src/rpc.ts+10-1| ... | @@ -48,6 +48,7 @@ import { | ... | @@ -48,6 +48,7 @@ import { |
| 48 | OrchestrationReplayEventsInput, | 48 | OrchestrationReplayEventsInput, |
| 49 | OrchestrationRpcSchemas, | 49 | OrchestrationRpcSchemas, |
| 50 | } from "./orchestration.ts"; | 50 | } from "./orchestration.ts"; |
| 51 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 51 | import { | 52 | import { |
| 52 | ProjectSearchEntriesError, | 53 | ProjectSearchEntriesError, |
| 53 | ProjectSearchEntriesInput, | 54 | ProjectSearchEntriesInput, |
| ... | @@ -140,7 +141,15 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { | ... | @@ -140,7 +141,15 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { |
| 140 | }); | 141 | }); |
| 141 | 142 | ||
| 142 | export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { | 143 | export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { |
| 143 | payload: Schema.Struct({}), | 144 | payload: Schema.Struct({ |
| 145 | /** | ||
| 146 | * When supplied, only refresh this specific provider instance. When | ||
| 147 | * omitted, refresh all configured instances — the legacy `refresh()` | ||
| 148 | * behaviour retained for transports that still dispatch untargeted | ||
| 149 | * refreshes. | ||
| 150 | */ | ||
| 151 | instanceId: Schema.optional(ProviderInstanceId), | ||
| 152 | }), | ||
| 144 | success: ServerProviderUpdatedPayload, | 153 | success: ServerProviderUpdatedPayload, |
| 145 | }); | 154 | }); |
| 146 | 155 |
packages/contracts/src/server.test.ts+22-2| ... | @@ -6,9 +6,10 @@ import { ServerProvider } from "./server.ts"; | ... | @@ -6,9 +6,10 @@ import { ServerProvider } from "./server.ts"; |
| 6 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); | 6 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); |
| 7 | 7 | ||
| 8 | describe("ServerProvider", () => { | 8 | describe("ServerProvider", () => { |
| 9 | it("defaults capability arrays when decoding legacy snapshots", () => { | 9 | it("defaults capability arrays when decoding provider snapshots", () => { |
| 10 | const parsed = decodeServerProvider({ | 10 | const parsed = decodeServerProvider({ |
| 11 | provider: "codex", | 11 | instanceId: "codex", |
| 12 | driver: "codex", | ||
| 12 | enabled: true, | 13 | enabled: true, |
| 13 | installed: true, | 14 | installed: true, |
| 14 | version: "1.0.0", | 15 | version: "1.0.0", |
| ... | @@ -23,4 +24,23 @@ describe("ServerProvider", () => { | ... | @@ -23,4 +24,23 @@ describe("ServerProvider", () => { |
| 23 | expect(parsed.slashCommands).toEqual([]); | 24 | expect(parsed.slashCommands).toEqual([]); |
| 24 | expect(parsed.skills).toEqual([]); | 25 | expect(parsed.skills).toEqual([]); |
| 25 | }); | 26 | }); |
| 27 | |||
| 28 | it("decodes continuation group metadata", () => { | ||
| 29 | const parsed = decodeServerProvider({ | ||
| 30 | instanceId: "codex_personal", | ||
| 31 | driver: "codex", | ||
| 32 | continuation: { groupKey: "codex:home:/Users/julius/.codex" }, | ||
| 33 | enabled: true, | ||
| 34 | installed: true, | ||
| 35 | version: "1.0.0", | ||
| 36 | status: "ready", | ||
| 37 | auth: { | ||
| 38 | status: "authenticated", | ||
| 39 | }, | ||
| 40 | checkedAt: "2026-04-10T00:00:00.000Z", | ||
| 41 | models: [], | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); | ||
| 45 | }); | ||
| 26 | }); | 46 | }); |
packages/contracts/src/server.ts+53-2| ... | @@ -11,7 +11,7 @@ import { | ... | @@ -11,7 +11,7 @@ import { |
| 11 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; | 11 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; |
| 12 | import { EditorId } from "./editor.ts"; | 12 | import { EditorId } from "./editor.ts"; |
| 13 | import { ModelCapabilities } from "./model.ts"; | 13 | import { ModelCapabilities } from "./model.ts"; |
| 14 | import { ProviderKind } from "./orchestration.ts"; | 14 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 15 | import { ServerSettings } from "./settings.ts"; | 15 | import { ServerSettings } from "./settings.ts"; |
| 16 | 16 | ||
| 17 | const KeybindingsMalformedConfigIssue = Schema.Struct({ | 17 | const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| ... | @@ -47,6 +47,7 @@ export const ServerProviderAuth = Schema.Struct({ | ... | @@ -47,6 +47,7 @@ export const ServerProviderAuth = Schema.Struct({ |
| 47 | status: ServerProviderAuthStatus, | 47 | status: ServerProviderAuthStatus, |
| 48 | type: Schema.optional(TrimmedNonEmptyString), | 48 | type: Schema.optional(TrimmedNonEmptyString), |
| 49 | label: Schema.optional(TrimmedNonEmptyString), | 49 | label: Schema.optional(TrimmedNonEmptyString), |
| 50 | email: Schema.optional(TrimmedNonEmptyString), | ||
| 50 | }); | 51 | }); |
| 51 | export type ServerProviderAuth = typeof ServerProviderAuth.Type; | 52 | export type ServerProviderAuth = typeof ServerProviderAuth.Type; |
| 52 | 53 | ||
| ... | @@ -83,10 +84,42 @@ export const ServerProviderSkill = Schema.Struct({ | ... | @@ -83,10 +84,42 @@ export const ServerProviderSkill = Schema.Struct({ |
| 83 | }); | 84 | }); |
| 84 | export type ServerProviderSkill = typeof ServerProviderSkill.Type; | 85 | export type ServerProviderSkill = typeof ServerProviderSkill.Type; |
| 85 | 86 | ||
| 87 | /** | ||
| 88 | * Availability of a configured provider instance from the runtime's POV. | ||
| 89 | * | ||
| 90 | * - `available` — the build ships this driver and an instance is wired | ||
| 91 | * up. Default for legacy snapshots produced from the closed | ||
| 92 | * `ServerSettings.providers` map. | ||
| 93 | * - `unavailable` — the user's `ServerSettings.providerInstances` (or a | ||
| 94 | * persisted thread / session binding) references a driver this build | ||
| 95 | * doesn't ship. Common after rolling back from a fork or PR branch | ||
| 96 | * that introduced a new driver. The snapshot is preserved so the UI | ||
| 97 | * can render "missing driver" affordances and so the data round-trips | ||
| 98 | * when the user moves back to the fork. | ||
| 99 | * | ||
| 100 | * Snapshots with `availability: "unavailable"` MUST set | ||
| 101 | * `installed: false` and `enabled: false`; the runtime refuses turn | ||
| 102 | * starts against them with a structured error. | ||
| 103 | */ | ||
| 104 | export const ServerProviderAvailability = Schema.Literals(["available", "unavailable"]); | ||
| 105 | export type ServerProviderAvailability = typeof ServerProviderAvailability.Type; | ||
| 106 | |||
| 107 | export const ServerProviderContinuation = Schema.Struct({ | ||
| 108 | groupKey: TrimmedNonEmptyString, | ||
| 109 | }); | ||
| 110 | export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; | ||
| 111 | |||
| 86 | export const ServerProvider = Schema.Struct({ | 112 | export const ServerProvider = Schema.Struct({ |
| 87 | provider: ProviderKind, | 113 | // Routing key for the configured instance this snapshot represents. This |
| 114 | // is the only stable identity consumers may use for provider routing. | ||
| 115 | instanceId: ProviderInstanceId, | ||
| 116 | // Open driver kind slug that selects the implementation handling this | ||
| 117 | // instance. It is metadata/capability context, not a routing key. | ||
| 118 | driver: ProviderDriverKind, | ||
| 88 | displayName: Schema.optional(TrimmedNonEmptyString), | 119 | displayName: Schema.optional(TrimmedNonEmptyString), |
| 120 | accentColor: Schema.optional(TrimmedNonEmptyString), | ||
| 89 | badgeLabel: Schema.optional(TrimmedNonEmptyString), | 121 | badgeLabel: Schema.optional(TrimmedNonEmptyString), |
| 122 | continuation: Schema.optional(ServerProviderContinuation), | ||
| 90 | showInteractionModeToggle: Schema.optional(Schema.Boolean), | 123 | showInteractionModeToggle: Schema.optional(Schema.Boolean), |
| 91 | enabled: Schema.Boolean, | 124 | enabled: Schema.Boolean, |
| 92 | installed: Schema.Boolean, | 125 | installed: Schema.Boolean, |
| ... | @@ -95,6 +128,15 @@ export const ServerProvider = Schema.Struct({ | ... | @@ -95,6 +128,15 @@ export const ServerProvider = Schema.Struct({ |
| 95 | auth: ServerProviderAuth, | 128 | auth: ServerProviderAuth, |
| 96 | checkedAt: IsoDateTime, | 129 | checkedAt: IsoDateTime, |
| 97 | message: Schema.optional(TrimmedNonEmptyString), | 130 | message: Schema.optional(TrimmedNonEmptyString), |
| 131 | // Optional for back-compat: every legacy producer omits this field and | ||
| 132 | // an absent value is interpreted as `"available"` by consumers (see | ||
| 133 | // `isProviderAvailable`). New `ProviderInstanceRegistry` outputs set it | ||
| 134 | // explicitly so the UI can render unavailable shadows from | ||
| 135 | // `ServerSettings.providerInstances`. | ||
| 136 | availability: Schema.optional(ServerProviderAvailability), | ||
| 137 | // Human-readable reason populated when `availability === "unavailable"`. | ||
| 138 | // Surfaces in the UI alongside the missing-driver affordance. | ||
| 139 | unavailableReason: Schema.optional(TrimmedNonEmptyString), | ||
| 98 | models: Schema.Array(ServerProviderModel), | 140 | models: Schema.Array(ServerProviderModel), |
| 99 | slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( | 141 | slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( |
| 100 | Schema.withDecodingDefault(Effect.succeed([])), | 142 | Schema.withDecodingDefault(Effect.succeed([])), |
| ... | @@ -106,6 +148,15 @@ export type ServerProvider = typeof ServerProvider.Type; | ... | @@ -106,6 +148,15 @@ export type ServerProvider = typeof ServerProvider.Type; |
| 106 | export const ServerProviders = Schema.Array(ServerProvider); | 148 | export const ServerProviders = Schema.Array(ServerProvider); |
| 107 | export type ServerProviders = typeof ServerProviders.Type; | 149 | export type ServerProviders = typeof ServerProviders.Type; |
| 108 | 150 | ||
| 151 | /** | ||
| 152 | * Treat the optional `availability` as "available" when absent. This is | ||
| 153 | * the rule legacy producers (which omit the field) and new producers | ||
| 154 | * (which set it explicitly) agree on so consumers never have to thread | ||
| 155 | * `?? "available"` defaults through their code paths. | ||
| 156 | */ | ||
| 157 | export const isProviderAvailable = (snapshot: ServerProvider): boolean => | ||
| 158 | snapshot.availability !== "unavailable"; | ||
| 159 | |||
| 109 | export const ServerObservability = Schema.Struct({ | 160 | export const ServerObservability = Schema.Struct({ |
| 110 | logsDirectoryPath: TrimmedNonEmptyString, | 161 | logsDirectoryPath: TrimmedNonEmptyString, |
| 111 | localTracingEnabled: Schema.Boolean, | 162 | localTracingEnabled: Schema.Boolean, |
packages/contracts/src/settings.test.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 5 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; | ||
| 6 | |||
| 7 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); | ||
| 8 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); | ||
| 9 | |||
| 10 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | ||
| 11 | it("defaults to an empty record so legacy configs without the key still decode", () => { | ||
| 12 | expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("decodes a fully empty config (legacy on-disk shape) without complaint", () => { | ||
| 16 | const decoded = decodeServerSettings({}); | ||
| 17 | expect(decoded.providerInstances).toEqual({}); | ||
| 18 | // Legacy `providers` struct is still hydrated with its per-driver defaults | ||
| 19 | // so existing call sites keep working through the migration. | ||
| 20 | expect(decoded.providers.codex.enabled).toBe(true); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("decodes a multi-instance map mixing first-party and fork drivers", () => { | ||
| 24 | const decoded = decodeServerSettings({ | ||
| 25 | providerInstances: { | ||
| 26 | codex_personal: { | ||
| 27 | driver: "codex", | ||
| 28 | displayName: "Codex (personal)", | ||
| 29 | config: { homePath: "~/.codex_personal" }, | ||
| 30 | }, | ||
| 31 | codex_work: { | ||
| 32 | driver: "codex", | ||
| 33 | config: { homePath: "~/.codex_work" }, | ||
| 34 | }, | ||
| 35 | ollama_local: { | ||
| 36 | driver: "ollama", | ||
| 37 | displayName: "Ollama (local)", | ||
| 38 | config: { endpoint: "http://localhost:11434" }, | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | }); | ||
| 42 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 43 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 44 | const ollamaId = ProviderInstanceId.make("ollama_local"); | ||
| 45 | |||
| 46 | expect(decoded.providerInstances[personalId]?.driver).toBe("codex"); | ||
| 47 | expect(decoded.providerInstances[workId]?.config).toEqual({ homePath: "~/.codex_work" }); | ||
| 48 | // Critical: a config naming a driver this build does not know about | ||
| 49 | // (`ollama` is not in `ProviderDriverKind`) must round-trip without loss. | ||
| 50 | // The runtime handles "driver not installed" — the schema must not. | ||
| 51 | expect(decoded.providerInstances[ollamaId]?.driver).toBe("ollama"); | ||
| 52 | expect(decoded.providerInstances[ollamaId]?.config).toEqual({ | ||
| 53 | endpoint: "http://localhost:11434", | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("rejects instance keys that violate the slug pattern", () => { | ||
| 58 | expect(() => | ||
| 59 | decodeServerSettings({ | ||
| 60 | providerInstances: { "1bad": { driver: "codex" } }, | ||
| 61 | }), | ||
| 62 | ).toThrow(); | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("ServerSettingsPatch.providerInstances", () => { | ||
| 67 | it("treats providerInstances as an optional whole-map replacement", () => { | ||
| 68 | const patch = decodeServerSettingsPatch({}); | ||
| 69 | expect(patch.providerInstances).toBeUndefined(); | ||
| 70 | |||
| 71 | const replacement = decodeServerSettingsPatch({ | ||
| 72 | providerInstances: { | ||
| 73 | codex_personal: { driver: "codex", config: { homePath: "~/.codex" } }, | ||
| 74 | }, | ||
| 75 | }); | ||
| 76 | expect(replacement.providerInstances).toBeDefined(); | ||
| 77 | expect(replacement.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( | ||
| 78 | "codex", | ||
| 79 | ); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("preserves a fork-defined driver entry through patch decoding", () => { | ||
| 83 | const patch = decodeServerSettingsPatch({ | ||
| 84 | providerInstances: { | ||
| 85 | ollama_local: { | ||
| 86 | driver: "ollama", | ||
| 87 | config: { endpoint: "http://localhost:11434" }, | ||
| 88 | }, | ||
| 89 | }, | ||
| 90 | }); | ||
| 91 | const ollamaId = ProviderInstanceId.make("ollama_local"); | ||
| 92 | expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama"); | ||
| 93 | }); | ||
| 94 | }); | ||
packages/contracts/src/settings.ts+67-32| ... | @@ -2,11 +2,9 @@ import { Effect } from "effect"; | ... | @@ -2,11 +2,9 @@ import { Effect } from "effect"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; | 4 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| 5 | import { | 5 | import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; |
| 6 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 6 | import { ModelSelection } from "./orchestration.ts"; |
| 7 | ProviderOptionSelections, | 7 | import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; |
| 8 | } from "./model.ts"; | ||
| 9 | import { ModelSelection, ProviderKind } from "./orchestration.ts"; | ||
| 10 | 8 | ||
| 11 | // ── Client Settings (local-only) ─────────────────────────────── | 9 | // ── Client Settings (local-only) ─────────────────────────────── |
| 12 | 10 | ||
| ... | @@ -35,12 +33,31 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -35,12 +33,31 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 35 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 33 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 36 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 34 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 37 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 35 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 36 | // Model favorites. Historically keyed by provider kind, now | ||
| 37 | // widened to `ProviderInstanceId` so users can favorite a specific model | ||
| 38 | // on a custom provider instance (e.g. "Codex Personal · gpt-5") without | ||
| 39 | // the UI collapsing it into the same bucket as the default Codex. The | ||
| 40 | // widening is backward-compatible by construction: prior provider-kind | ||
| 41 | // strings satisfy the `ProviderInstanceId` slug schema, so previously | ||
| 42 | // persisted favorites decode unchanged and continue to point at the | ||
| 43 | // default instance for their kind (because `defaultInstanceIdForDriver(kind)` | ||
| 44 | // uses the same slug). The field name is kept as `provider` for storage | ||
| 45 | // stability; new call sites should treat the value as an instance id. | ||
| 38 | favorites: Schema.Array( | 46 | favorites: Schema.Array( |
| 39 | Schema.Struct({ | 47 | Schema.Struct({ |
| 40 | provider: ProviderKind, | 48 | provider: ProviderInstanceId, |
| 41 | model: TrimmedNonEmptyString, | 49 | model: TrimmedNonEmptyString, |
| 42 | }), | 50 | }), |
| 43 | ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | 51 | ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 52 | providerModelPreferences: Schema.Record( | ||
| 53 | ProviderInstanceId, | ||
| 54 | Schema.Struct({ | ||
| 55 | hiddenModels: Schema.Array(Schema.String).pipe( | ||
| 56 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 57 | ), | ||
| 58 | modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | ||
| 59 | }), | ||
| 60 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | ||
| 44 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( | 61 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 45 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), | 62 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 46 | ), | 63 | ), |
| ... | @@ -83,6 +100,7 @@ export const CodexSettings = Schema.Struct({ | ... | @@ -83,6 +100,7 @@ export const CodexSettings = Schema.Struct({ |
| 83 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 100 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 84 | binaryPath: makeBinaryPathSetting("codex"), | 101 | binaryPath: makeBinaryPathSetting("codex"), |
| 85 | homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 102 | homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 103 | shadowHomePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | ||
| 86 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | 104 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 87 | }); | 105 | }); |
| 88 | export type CodexSettings = typeof CodexSettings.Type; | 106 | export type CodexSettings = typeof CodexSettings.Type; |
| ... | @@ -90,6 +108,7 @@ export type CodexSettings = typeof CodexSettings.Type; | ... | @@ -90,6 +108,7 @@ export type CodexSettings = typeof CodexSettings.Type; |
| 90 | export const ClaudeSettings = Schema.Struct({ | 108 | export const ClaudeSettings = Schema.Struct({ |
| 91 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 109 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 92 | binaryPath: makeBinaryPathSetting("claude"), | 110 | binaryPath: makeBinaryPathSetting("claude"), |
| 111 | homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | ||
| 93 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | 112 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 94 | launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 113 | launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 95 | }); | 114 | }); |
| ... | @@ -126,19 +145,32 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -126,19 +145,32 @@ export const ServerSettings = Schema.Struct({ |
| 126 | textGenerationModelSelection: ModelSelection.pipe( | 145 | textGenerationModelSelection: ModelSelection.pipe( |
| 127 | Schema.withDecodingDefault( | 146 | Schema.withDecodingDefault( |
| 128 | Effect.succeed({ | 147 | Effect.succeed({ |
| 129 | provider: "codex" as const, | 148 | instanceId: ProviderInstanceId.make("codex"), |
| 130 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, | 149 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL, |
| 131 | }), | 150 | }), |
| 132 | ), | 151 | ), |
| 133 | ), | 152 | ), |
| 134 | 153 | ||
| 135 | // Provider specific settings | 154 | // Legacy single-instance-per-driver settings. Continues to be the source |
| 155 | // of truth until `providerInstances` (below) lands per-driver migration | ||
| 156 | // shims and the server starts hydrating instances from it. Driver-specific | ||
| 157 | // schemas live here for the duration of the migration; once each driver | ||
| 158 | // owns its config in its own package, this struct shrinks to nothing and | ||
| 159 | // is removed entirely. | ||
| 136 | providers: Schema.Struct({ | 160 | providers: Schema.Struct({ |
| 137 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 161 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 138 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 162 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 139 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 163 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 140 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 164 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 141 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 165 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 166 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values | ||
| 167 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob | ||
| 168 | // is `Schema.Unknown` at this layer so envelopes with unknown drivers | ||
| 169 | // (forks, downgrades, in-flight PR branches) round-trip without loss. | ||
| 170 | // See providerInstance.ts for the forward/backward compatibility invariant. | ||
| 171 | providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( | ||
| 172 | Schema.withDecodingDefault(Effect.succeed({})), | ||
| 173 | ), | ||
| 142 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 174 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 143 | }); | 175 | }); |
| 144 | export type ServerSettings = typeof ServerSettings.Type; | 176 | export type ServerSettings = typeof ServerSettings.Type; |
| ... | @@ -168,39 +200,24 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { | ... | @@ -168,39 +200,24 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { |
| 168 | 200 | ||
| 169 | // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── | 201 | // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── |
| 170 | 202 | ||
| 171 | const ModelSelectionPatch = Schema.Union([ | 203 | const ModelSelectionPatch = Schema.Struct({ |
| 172 | Schema.Struct({ | 204 | instanceId: Schema.optionalKey(ProviderInstanceId), |
| 173 | provider: Schema.optionalKey(Schema.Literal("codex")), | 205 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 174 | model: Schema.optionalKey(TrimmedNonEmptyString), | 206 | options: Schema.optionalKey(ProviderOptionSelections), |
| 175 | options: Schema.optionalKey(ProviderOptionSelections), | 207 | }); |
| 176 | }), | ||
| 177 | Schema.Struct({ | ||
| 178 | provider: Schema.optionalKey(Schema.Literal("claudeAgent")), | ||
| 179 | model: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 180 | options: Schema.optionalKey(ProviderOptionSelections), | ||
| 181 | }), | ||
| 182 | Schema.Struct({ | ||
| 183 | provider: Schema.optionalKey(Schema.Literal("cursor")), | ||
| 184 | model: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 185 | options: Schema.optionalKey(ProviderOptionSelections), | ||
| 186 | }), | ||
| 187 | Schema.Struct({ | ||
| 188 | provider: Schema.optionalKey(Schema.Literal("opencode")), | ||
| 189 | model: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 190 | options: Schema.optionalKey(ProviderOptionSelections), | ||
| 191 | }), | ||
| 192 | ]); | ||
| 193 | 208 | ||
| 194 | const CodexSettingsPatch = Schema.Struct({ | 209 | const CodexSettingsPatch = Schema.Struct({ |
| 195 | enabled: Schema.optionalKey(Schema.Boolean), | 210 | enabled: Schema.optionalKey(Schema.Boolean), |
| 196 | binaryPath: Schema.optionalKey(Schema.String), | 211 | binaryPath: Schema.optionalKey(Schema.String), |
| 197 | homePath: Schema.optionalKey(Schema.String), | 212 | homePath: Schema.optionalKey(Schema.String), |
| 213 | shadowHomePath: Schema.optionalKey(Schema.String), | ||
| 198 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 214 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 199 | }); | 215 | }); |
| 200 | 216 | ||
| 201 | const ClaudeSettingsPatch = Schema.Struct({ | 217 | const ClaudeSettingsPatch = Schema.Struct({ |
| 202 | enabled: Schema.optionalKey(Schema.Boolean), | 218 | enabled: Schema.optionalKey(Schema.Boolean), |
| 203 | binaryPath: Schema.optionalKey(Schema.String), | 219 | binaryPath: Schema.optionalKey(Schema.String), |
| 220 | homePath: Schema.optionalKey(Schema.String), | ||
| 204 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 221 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 205 | launchArgs: Schema.optionalKey(Schema.String), | 222 | launchArgs: Schema.optionalKey(Schema.String), |
| 206 | }); | 223 | }); |
| ... | @@ -240,6 +257,11 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -240,6 +257,11 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 240 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), | 257 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), |
| 241 | }), | 258 | }), |
| 242 | ), | 259 | ), |
| 260 | // Whole-map replacement for the new instance config. Patching individual | ||
| 261 | // entries is intentionally out of scope: the map is small, and partial | ||
| 262 | // patches risk leaving driver-specific config in a half-merged state. | ||
| 263 | // The web UI sends a fully-formed map every time it edits this field. | ||
| 264 | providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), | ||
| 243 | }); | 265 | }); |
| 244 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; | 266 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 245 | 267 | ||
| ... | @@ -251,11 +273,24 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -251,11 +273,24 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 251 | favorites: Schema.optionalKey( | 273 | favorites: Schema.optionalKey( |
| 252 | Schema.Array( | 274 | Schema.Array( |
| 253 | Schema.Struct({ | 275 | Schema.Struct({ |
| 254 | provider: ProviderKind, | 276 | provider: ProviderInstanceId, |
| 255 | model: TrimmedNonEmptyString, | 277 | model: TrimmedNonEmptyString, |
| 256 | }), | 278 | }), |
| 257 | ), | 279 | ), |
| 258 | ), | 280 | ), |
| 281 | providerModelPreferences: Schema.optionalKey( | ||
| 282 | Schema.Record( | ||
| 283 | ProviderInstanceId, | ||
| 284 | Schema.Struct({ | ||
| 285 | hiddenModels: Schema.Array(Schema.String).pipe( | ||
| 286 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 287 | ), | ||
| 288 | modelOrder: Schema.Array(Schema.String).pipe( | ||
| 289 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 290 | ), | ||
| 291 | }), | ||
| 292 | ), | ||
| 293 | ), | ||
| 259 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), | 294 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 260 | sidebarProjectGroupingOverrides: Schema.optionalKey( | 295 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 261 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), | 296 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |
packages/shared/src/model.test.ts+28-14| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts"; | 2 | import { |
| 3 | DEFAULT_MODEL, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ModelCapabilities, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 3 | 8 | ||
| 4 | import { | 9 | import { |
| 5 | applyClaudePromptEffortPrefix, | 10 | applyClaudePromptEffortPrefix, |
| ... | @@ -67,9 +72,10 @@ const claudeCaps: ModelCapabilities = createModelCapabilities({ | ... | @@ -67,9 +72,10 @@ const claudeCaps: ModelCapabilities = createModelCapabilities({ |
| 67 | 72 | ||
| 68 | describe("normalizeModelSlug", () => { | 73 | describe("normalizeModelSlug", () => { |
| 69 | it("maps known aliases to canonical slugs", () => { | 74 | it("maps known aliases to canonical slugs", () => { |
| 75 | const claude = ProviderDriverKind.make("claudeAgent"); | ||
| 70 | expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4"); | 76 | expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4"); |
| 71 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | 77 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); |
| 72 | expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); | 78 | expect(normalizeModelSlug("sonnet", claude)).toBe("claude-sonnet-4-6"); |
| 73 | }); | 79 | }); |
| 74 | 80 | ||
| 75 | it("returns null for empty or missing values", () => { | 81 | it("returns null for empty or missing values", () => { |
| ... | @@ -82,16 +88,18 @@ describe("normalizeModelSlug", () => { | ... | @@ -82,16 +88,18 @@ describe("normalizeModelSlug", () => { |
| 82 | 88 | ||
| 83 | describe("resolveModelSlugForProvider", () => { | 89 | describe("resolveModelSlugForProvider", () => { |
| 84 | it("returns defaults when the model is missing", () => { | 90 | it("returns defaults when the model is missing", () => { |
| 85 | expect(resolveModelSlugForProvider("codex", undefined)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); | 91 | expect(resolveModelSlugForProvider(ProviderDriverKind.make("codex"), undefined)).toBe( |
| 86 | expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( | 92 | DEFAULT_MODEL, |
| 87 | DEFAULT_MODEL_BY_PROVIDER.claudeAgent, | 93 | ); |
| 94 | expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe( | ||
| 95 | DEFAULT_MODEL, | ||
| 88 | ); | 96 | ); |
| 89 | }); | 97 | }); |
| 90 | 98 | ||
| 91 | it("preserves normalized unknown models", () => { | 99 | it("preserves normalized unknown models", () => { |
| 92 | expect(resolveModelSlugForProvider("codex", "custom/internal-model")).toBe( | 100 | expect( |
| 93 | "custom/internal-model", | 101 | resolveModelSlugForProvider(ProviderDriverKind.make("codex"), "custom/internal-model"), |
| 94 | ); | 102 | ).toBe("custom/internal-model"); |
| 95 | }); | 103 | }); |
| 96 | }); | 104 | }); |
| 97 | 105 | ||
| ... | @@ -101,9 +109,15 @@ describe("resolveSelectableModel", () => { | ... | @@ -101,9 +109,15 @@ describe("resolveSelectableModel", () => { |
| 101 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | 109 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, |
| 102 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | 110 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, |
| 103 | ]; | 111 | ]; |
| 104 | expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); | 112 | expect(resolveSelectableModel(ProviderDriverKind.make("codex"), "gpt-5.3-codex", options)).toBe( |
| 105 | expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); | 113 | "gpt-5.3-codex", |
| 106 | expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); | 114 | ); |
| 115 | expect(resolveSelectableModel(ProviderDriverKind.make("codex"), "gpt-5.3 codex", options)).toBe( | ||
| 116 | "gpt-5.3-codex", | ||
| 117 | ); | ||
| 118 | expect(resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "sonnet", options)).toBe( | ||
| 119 | "claude-sonnet-4-6", | ||
| 120 | ); | ||
| 107 | }); | 121 | }); |
| 108 | }); | 122 | }); |
| 109 | 123 | ||
| ... | @@ -182,12 +196,12 @@ describe("descriptor helpers", () => { | ... | @@ -182,12 +196,12 @@ describe("descriptor helpers", () => { |
| 182 | 196 | ||
| 183 | it("stores option selection arrays in model selections", () => { | 197 | it("stores option selection arrays in model selections", () => { |
| 184 | expect( | 198 | expect( |
| 185 | createModelSelection("codex", "gpt-5.4", [ | 199 | createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 186 | { id: "reasoningEffort", value: "high" }, | 200 | { id: "reasoningEffort", value: "high" }, |
| 187 | { id: "fastMode", value: true }, | 201 | { id: "fastMode", value: true }, |
| 188 | ]), | 202 | ]), |
| 189 | ).toEqual({ | 203 | ).toEqual({ |
| 190 | provider: "codex", | 204 | instanceId: "codex", |
| 191 | model: "gpt-5.4", | 205 | model: "gpt-5.4", |
| 192 | options: [ | 206 | options: [ |
| 193 | { id: "reasoningEffort", value: "high" }, | 207 | { id: "reasoningEffort", value: "high" }, |
| ... | @@ -197,7 +211,7 @@ describe("descriptor helpers", () => { | ... | @@ -197,7 +211,7 @@ describe("descriptor helpers", () => { |
| 197 | }); | 211 | }); |
| 198 | 212 | ||
| 199 | it("reads typed option selection values", () => { | 213 | it("reads typed option selection values", () => { |
| 200 | const selection = createModelSelection("codex", "gpt-5.4", [ | 214 | const selection = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 201 | { id: "reasoningEffort", value: "high" }, | 215 | { id: "reasoningEffort", value: "high" }, |
| 202 | { id: "fastMode", value: true }, | 216 | { id: "fastMode", value: true }, |
| 203 | ]); | 217 | ]); |
packages/shared/src/model.ts+16-12| ... | @@ -1,13 +1,17 @@ | ... | @@ -1,13 +1,17 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_MODEL, | ||
| 2 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 3 | MODEL_SLUG_ALIASES_BY_PROVIDER, | 4 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 4 | type ModelCapabilities, | 5 | type ModelCapabilities, |
| 5 | type ModelSelection, | 6 | type ModelSelection, |
| 7 | ProviderDriverKind, | ||
| 8 | ProviderInstanceId, | ||
| 6 | type ProviderOptionDescriptor, | 9 | type ProviderOptionDescriptor, |
| 7 | type ProviderOptionSelection, | 10 | type ProviderOptionSelection, |
| 8 | type ProviderKind, | ||
| 9 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 10 | 12 | ||
| 13 | const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); | ||
| 14 | |||
| 11 | export interface SelectableModelOption { | 15 | export interface SelectableModelOption { |
| 12 | slug: string; | 16 | slug: string; |
| 13 | name: string; | 17 | name: string; |
| ... | @@ -230,7 +234,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole | ... | @@ -230,7 +234,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole |
| 230 | 234 | ||
| 231 | export function normalizeModelSlug( | 235 | export function normalizeModelSlug( |
| 232 | model: string | null | undefined, | 236 | model: string | null | undefined, |
| 233 | provider: ProviderKind = "codex", | 237 | provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, |
| 234 | ): string | null { | 238 | ): string | null { |
| 235 | if (typeof model !== "string") { | 239 | if (typeof model !== "string") { |
| 236 | return null; | 240 | return null; |
| ... | @@ -241,7 +245,7 @@ export function normalizeModelSlug( | ... | @@ -241,7 +245,7 @@ export function normalizeModelSlug( |
| 241 | return null; | 245 | return null; |
| 242 | } | 246 | } |
| 243 | 247 | ||
| 244 | const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, string>; | 248 | const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] ?? {}; |
| 245 | const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed) | 249 | const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed) |
| 246 | ? aliases[trimmed] | 250 | ? aliases[trimmed] |
| 247 | : undefined; | 251 | : undefined; |
| ... | @@ -249,7 +253,7 @@ export function normalizeModelSlug( | ... | @@ -249,7 +253,7 @@ export function normalizeModelSlug( |
| 249 | } | 253 | } |
| 250 | 254 | ||
| 251 | export function resolveSelectableModel( | 255 | export function resolveSelectableModel( |
| 252 | provider: ProviderKind, | 256 | provider: ProviderDriverKind, |
| 253 | value: string | null | undefined, | 257 | value: string | null | undefined, |
| 254 | options: ReadonlyArray<SelectableModelOption>, | 258 | options: ReadonlyArray<SelectableModelOption>, |
| 255 | ): string | null { | 259 | ): string | null { |
| ... | @@ -281,16 +285,16 @@ export function resolveSelectableModel( | ... | @@ -281,16 +285,16 @@ export function resolveSelectableModel( |
| 281 | return resolved ? resolved.slug : null; | 285 | return resolved ? resolved.slug : null; |
| 282 | } | 286 | } |
| 283 | 287 | ||
| 284 | function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { | 288 | function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string { |
| 285 | const normalized = normalizeModelSlug(model, provider); | 289 | const normalized = normalizeModelSlug(model, provider); |
| 286 | if (!normalized) { | 290 | if (!normalized) { |
| 287 | return DEFAULT_MODEL_BY_PROVIDER[provider]; | 291 | return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; |
| 288 | } | 292 | } |
| 289 | return normalized; | 293 | return normalized; |
| 290 | } | 294 | } |
| 291 | 295 | ||
| 292 | export function resolveModelSlugForProvider( | 296 | export function resolveModelSlugForProvider( |
| 293 | provider: ProviderKind, | 297 | provider: ProviderDriverKind, |
| 294 | model: string | null | undefined, | 298 | model: string | null | undefined, |
| 295 | ): string { | 299 | ): string { |
| 296 | return resolveModelSlug(model, provider); | 300 | return resolveModelSlug(model, provider); |
| ... | @@ -310,16 +314,16 @@ function cloneSelections( | ... | @@ -310,16 +314,16 @@ function cloneSelections( |
| 310 | } | 314 | } |
| 311 | 315 | ||
| 312 | export function createModelSelection( | 316 | export function createModelSelection( |
| 313 | provider: ProviderKind, | 317 | instanceId: ProviderInstanceId, |
| 314 | model: string, | 318 | model: string, |
| 315 | options?: ReadonlyArray<ProviderOptionSelection> | null, | 319 | options?: ReadonlyArray<ProviderOptionSelection> | null, |
| 316 | ): ModelSelection { | 320 | ): ModelSelection { |
| 317 | const selections = options ? cloneSelections(options) : []; | 321 | const selections = options ? cloneSelections(options) : []; |
| 318 | return { | 322 | const base: ModelSelection = { |
| 319 | provider, | 323 | instanceId, |
| 320 | model, | 324 | model, |
| 321 | ...(selections.length > 0 ? { options: selections } : {}), | 325 | }; |
| 322 | } as ModelSelection; | 326 | return selections.length > 0 ? { ...base, options: selections } : base; |
| 323 | } | 327 | } |
| 324 | 328 | ||
| 325 | /** | 329 | /** |
packages/shared/src/serverSettings.test.ts+70-20| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | 6 | import { describe, expect, it } from "vitest"; |
| 3 | import { createModelSelection } from "./model.ts"; | 7 | import { createModelSelection } from "./model.ts"; |
| 4 | import { | 8 | import { |
| ... | @@ -57,21 +61,25 @@ describe("serverSettings helpers", () => { | ... | @@ -57,21 +61,25 @@ describe("serverSettings helpers", () => { |
| 57 | it("replaces text generation selection when provider/model are provided", () => { | 61 | it("replaces text generation selection when provider/model are provided", () => { |
| 58 | const current = { | 62 | const current = { |
| 59 | ...DEFAULT_SERVER_SETTINGS, | 63 | ...DEFAULT_SERVER_SETTINGS, |
| 60 | textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ | 64 | textGenerationModelSelection: createModelSelection( |
| 61 | { id: "reasoningEffort", value: "high" }, | 65 | ProviderInstanceId.make("codex"), |
| 62 | { id: "fastMode", value: true }, | 66 | "gpt-5.4-mini", |
| 63 | ]), | 67 | [ |
| 68 | { id: "reasoningEffort", value: "high" }, | ||
| 69 | { id: "fastMode", value: true }, | ||
| 70 | ], | ||
| 71 | ), | ||
| 64 | }; | 72 | }; |
| 65 | 73 | ||
| 66 | expect( | 74 | expect( |
| 67 | applyServerSettingsPatch(current, { | 75 | applyServerSettingsPatch(current, { |
| 68 | textGenerationModelSelection: { | 76 | textGenerationModelSelection: { |
| 69 | provider: "codex", | 77 | instanceId: ProviderInstanceId.make("codex"), |
| 70 | model: "gpt-5.4-mini", | 78 | model: "gpt-5.4-mini", |
| 71 | }, | 79 | }, |
| 72 | }).textGenerationModelSelection, | 80 | }).textGenerationModelSelection, |
| 73 | ).toEqual({ | 81 | ).toEqual({ |
| 74 | provider: "codex", | 82 | instanceId: "codex", |
| 75 | model: "gpt-5.4-mini", | 83 | model: "gpt-5.4-mini", |
| 76 | }); | 84 | }); |
| 77 | }); | 85 | }); |
| ... | @@ -79,10 +87,14 @@ describe("serverSettings helpers", () => { | ... | @@ -79,10 +87,14 @@ describe("serverSettings helpers", () => { |
| 79 | it("still deep merges text generation selection when only options are provided", () => { | 87 | it("still deep merges text generation selection when only options are provided", () => { |
| 80 | const current = { | 88 | const current = { |
| 81 | ...DEFAULT_SERVER_SETTINGS, | 89 | ...DEFAULT_SERVER_SETTINGS, |
| 82 | textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ | 90 | textGenerationModelSelection: createModelSelection( |
| 83 | { id: "reasoningEffort", value: "high" }, | 91 | ProviderInstanceId.make("codex"), |
| 84 | { id: "fastMode", value: true }, | 92 | "gpt-5.4-mini", |
| 85 | ]), | 93 | [ |
| 94 | { id: "reasoningEffort", value: "high" }, | ||
| 95 | { id: "fastMode", value: true }, | ||
| 96 | ], | ||
| 97 | ), | ||
| 86 | }; | 98 | }; |
| 87 | 99 | ||
| 88 | expect( | 100 | expect( |
| ... | @@ -92,7 +104,7 @@ describe("serverSettings helpers", () => { | ... | @@ -92,7 +104,7 @@ describe("serverSettings helpers", () => { |
| 92 | }, | 104 | }, |
| 93 | }).textGenerationModelSelection, | 105 | }).textGenerationModelSelection, |
| 94 | ).toEqual({ | 106 | ).toEqual({ |
| 95 | provider: "codex", | 107 | instanceId: "codex", |
| 96 | model: "gpt-5.4-mini", | 108 | model: "gpt-5.4-mini", |
| 97 | options: [ | 109 | options: [ |
| 98 | { id: "reasoningEffort", value: "high" }, | 110 | { id: "reasoningEffort", value: "high" }, |
| ... | @@ -104,21 +116,25 @@ describe("serverSettings helpers", () => { | ... | @@ -104,21 +116,25 @@ describe("serverSettings helpers", () => { |
| 104 | it("replaces text generation selection across providers without leaking stale options", () => { | 116 | it("replaces text generation selection across providers without leaking stale options", () => { |
| 105 | const current = { | 117 | const current = { |
| 106 | ...DEFAULT_SERVER_SETTINGS, | 118 | ...DEFAULT_SERVER_SETTINGS, |
| 107 | textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ | 119 | textGenerationModelSelection: createModelSelection( |
| 108 | { id: "reasoningEffort", value: "high" }, | 120 | ProviderInstanceId.make("codex"), |
| 109 | { id: "fastMode", value: true }, | 121 | "gpt-5.4-mini", |
| 110 | ]), | 122 | [ |
| 123 | { id: "reasoningEffort", value: "high" }, | ||
| 124 | { id: "fastMode", value: true }, | ||
| 125 | ], | ||
| 126 | ), | ||
| 111 | }; | 127 | }; |
| 112 | 128 | ||
| 113 | expect( | 129 | expect( |
| 114 | applyServerSettingsPatch(current, { | 130 | applyServerSettingsPatch(current, { |
| 115 | textGenerationModelSelection: { | 131 | textGenerationModelSelection: { |
| 116 | provider: "opencode", | 132 | instanceId: ProviderInstanceId.make("opencode"), |
| 117 | model: "openai/gpt-5", | 133 | model: "openai/gpt-5", |
| 118 | }, | 134 | }, |
| 119 | }).textGenerationModelSelection, | 135 | }).textGenerationModelSelection, |
| 120 | ).toEqual({ | 136 | ).toEqual({ |
| 121 | provider: "opencode", | 137 | instanceId: "opencode", |
| 122 | model: "openai/gpt-5", | 138 | model: "openai/gpt-5", |
| 123 | }); | 139 | }); |
| 124 | }); | 140 | }); |
| ... | @@ -127,7 +143,7 @@ describe("serverSettings helpers", () => { | ... | @@ -127,7 +143,7 @@ describe("serverSettings helpers", () => { |
| 127 | expect( | 143 | expect( |
| 128 | applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | 144 | applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 129 | textGenerationModelSelection: { | 145 | textGenerationModelSelection: { |
| 130 | provider: "opencode", | 146 | instanceId: ProviderInstanceId.make("opencode"), |
| 131 | model: "openai/gpt-5", | 147 | model: "openai/gpt-5", |
| 132 | options: [ | 148 | options: [ |
| 133 | { id: "variant", value: "prod" }, | 149 | { id: "variant", value: "prod" }, |
| ... | @@ -136,7 +152,7 @@ describe("serverSettings helpers", () => { | ... | @@ -136,7 +152,7 @@ describe("serverSettings helpers", () => { |
| 136 | }, | 152 | }, |
| 137 | }).textGenerationModelSelection, | 153 | }).textGenerationModelSelection, |
| 138 | ).toEqual({ | 154 | ).toEqual({ |
| 139 | provider: "opencode", | 155 | instanceId: "opencode", |
| 140 | model: "openai/gpt-5", | 156 | model: "openai/gpt-5", |
| 141 | options: [ | 157 | options: [ |
| 142 | { id: "variant", value: "prod" }, | 158 | { id: "variant", value: "prod" }, |
| ... | @@ -144,4 +160,38 @@ describe("serverSettings helpers", () => { | ... | @@ -144,4 +160,38 @@ describe("serverSettings helpers", () => { |
| 144 | ], | 160 | ], |
| 145 | }); | 161 | }); |
| 146 | }); | 162 | }); |
| 163 | |||
| 164 | it("replaces providerInstances maps so omitted instance fields are cleared", () => { | ||
| 165 | const codexId = ProviderInstanceId.make("codex"); | ||
| 166 | const current = { | ||
| 167 | ...DEFAULT_SERVER_SETTINGS, | ||
| 168 | providerInstances: { | ||
| 169 | [codexId]: { | ||
| 170 | driver: ProviderDriverKind.make("codex"), | ||
| 171 | displayName: "Codex Work", | ||
| 172 | accentColor: "#7c3aed", | ||
| 173 | enabled: true, | ||
| 174 | config: { homePath: "~/.codex" }, | ||
| 175 | }, | ||
| 176 | }, | ||
| 177 | }; | ||
| 178 | |||
| 179 | expect( | ||
| 180 | applyServerSettingsPatch(current, { | ||
| 181 | providerInstances: { | ||
| 182 | [codexId]: { | ||
| 183 | driver: ProviderDriverKind.make("codex"), | ||
| 184 | displayName: "Codex Work", | ||
| 185 | enabled: true, | ||
| 186 | config: { homePath: "~/.codex" }, | ||
| 187 | }, | ||
| 188 | }, | ||
| 189 | }).providerInstances[codexId], | ||
| 190 | ).toEqual({ | ||
| 191 | driver: ProviderDriverKind.make("codex"), | ||
| 192 | displayName: "Codex Work", | ||
| 193 | enabled: true, | ||
| 194 | config: { homePath: "~/.codex" }, | ||
| 195 | }); | ||
| 196 | }); | ||
| 147 | }); | 197 | }); |
packages/shared/src/serverSettings.ts+12-5| ... | @@ -44,7 +44,7 @@ export function parsePersistedServerObservabilitySettings( | ... | @@ -44,7 +44,7 @@ export function parsePersistedServerObservabilitySettings( |
| 44 | function shouldReplaceTextGenerationModelSelection( | 44 | function shouldReplaceTextGenerationModelSelection( |
| 45 | patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, | 45 | patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, |
| 46 | ): boolean { | 46 | ): boolean { |
| 47 | return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); | 47 | return Boolean(patch && (patch.instanceId !== undefined || patch.model !== undefined)); |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | function mergeModelSelectionOptionsById(input: { | 50 | function mergeModelSelectionOptionsById(input: { |
| ... | @@ -76,11 +76,18 @@ export function applyServerSettingsPatch( | ... | @@ -76,11 +76,18 @@ export function applyServerSettingsPatch( |
| 76 | ): ServerSettings { | 76 | ): ServerSettings { |
| 77 | const selectionPatch = patch.textGenerationModelSelection; | 77 | const selectionPatch = patch.textGenerationModelSelection; |
| 78 | const next = deepMerge(current, patch); | 78 | const next = deepMerge(current, patch); |
| 79 | const nextWithReplacements = | ||
| 80 | patch.providerInstances !== undefined | ||
| 81 | ? { | ||
| 82 | ...next, | ||
| 83 | providerInstances: patch.providerInstances, | ||
| 84 | } | ||
| 85 | : next; | ||
| 79 | if (!selectionPatch) { | 86 | if (!selectionPatch) { |
| 80 | return next; | 87 | return nextWithReplacements; |
| 81 | } | 88 | } |
| 82 | 89 | ||
| 83 | const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; | 90 | const instanceId = selectionPatch.instanceId ?? current.textGenerationModelSelection.instanceId; |
| 84 | const model = selectionPatch.model ?? current.textGenerationModelSelection.model; | 91 | const model = selectionPatch.model ?? current.textGenerationModelSelection.model; |
| 85 | const options = shouldReplaceTextGenerationModelSelection(selectionPatch) | 92 | const options = shouldReplaceTextGenerationModelSelection(selectionPatch) |
| 86 | ? selectionPatch.options | 93 | ? selectionPatch.options |
| ... | @@ -90,7 +97,7 @@ export function applyServerSettingsPatch( | ... | @@ -90,7 +97,7 @@ export function applyServerSettingsPatch( |
| 90 | }); | 97 | }); |
| 91 | 98 | ||
| 92 | return { | 99 | return { |
| 93 | ...next, | 100 | ...nextWithReplacements, |
| 94 | textGenerationModelSelection: createModelSelection(provider, model, options), | 101 | textGenerationModelSelection: createModelSelection(instanceId, model, options), |
| 95 | }; | 102 | }; |
| 96 | } | 103 | } |