authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-29 16:13:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-29 16:13:55-07:00
log08e6d4cfbfe55af83fe5fb6b9e75364f42f1f22f
treea135a659f540ba6c145af50331f63bc238f7deab
parent35822884d19ba64c6529d7736fb0182b361f3e4c
signature Signed by PGP key B5690EEEBB952194

feat: Multi-Provider support (#2277)

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";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import {4import {
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/
36import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";37import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
37import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";38import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
38import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";39import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
39import { ProviderUnsupportedError } from "../src/provider/Errors.ts";40import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
40import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";41import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
41import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";42import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
42import { ServerSettingsService } from "../src/serverSettings.ts";43import { ServerSettingsService } from "../src/serverSettings.ts";
43import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";44import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
44import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";45import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts";
45import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";46import {
47 NoOpProviderEventLoggers,
48 ProviderEventLoggers,
49} from "../src/provider/Layers/ProviderEventLoggers.ts";
46import { ProviderService } from "../src/provider/Services/ProviderService.ts";50import { ProviderService } from "../src/provider/Services/ProviderService.ts";
47import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";51import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
48import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";52import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
...@@ -214,7 +218,7 @@ export interface OrchestrationIntegrationHarness {...@@ -214,7 +218,7 @@ export interface OrchestrationIntegrationHarness {
214}218}
215219
216interface MakeOrchestrationIntegrationHarnessOptions {220interface MakeOrchestrationIntegrationHarnessOptions {
217 readonly provider?: ProviderKind;221 readonly provider?: ProviderDriverKind;
218 readonly realCodex?: boolean;222 readonly realCodex?: boolean;
219}223}
220224
...@@ -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;
227231
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 = useRealCodex234 const adapterHarness = useRealCodex
231 ? null235 ? null
...@@ -233,13 +237,10 @@ export const makeOrchestrationIntegrationHarness = (...@@ -233,13 +237,10 @@ export const makeOrchestrationIntegrationHarness = (
233 provider,237 provider,
234 });238 });
235 const fakeRegistry = adapterHarness239 const fakeRegistry = adapterHarness
236 ? Layer.succeed(ProviderAdapterRegistry, {240 ? Layer.succeed(
237 getByProvider: (resolvedProvider) =>241 ProviderAdapterRegistry,
238 resolvedProvider === adapterHarness.provider242 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 = useRealCodex280 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 );
293293
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";
15import { Effect, Queue, Stream } from "effect";15import { Effect, Queue, Stream } from "effect";
1616
...@@ -36,7 +36,7 @@ export interface TestTurnResponse {...@@ -36,7 +36,7 @@ export interface TestTurnResponse {
36export type FixtureProviderRuntimeEvent = {36export 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
178178
179export interface TestProviderAdapterHarness {179export 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}
199199
200interface MakeTestProviderAdapterHarnessOptions {200interface MakeTestProviderAdapterHarnessOptions {
201 readonly provider?: ProviderKind;201 readonly provider?: ProviderDriverKind;
202}202}
203203
204function nowIso(): string {204function nowIso(): string {
...@@ -206,7 +206,7 @@ function nowIso(): string {...@@ -206,7 +206,7 @@ function nowIso(): string {
206}206}
207207
208function sessionNotFound(208function 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}
217217
218function missingSessionEffect(218function 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(
224224
225export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>225export 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
257257
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 @@
1import { EventId, RuntimeRequestId } from "@t3tools/contracts";1import { EventId, ProviderDriverKind, RuntimeRequestId } from "@t3tools/contracts";
2import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts";2import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts";
33
4const PROVIDER = "codex" as const;4const PROVIDER = ProviderDriverKind.make("codex");
5const SESSION_ID = "fixture-session";5const SESSION_ID = "fixture-session";
6const THREAD_ID = "fixture-thread";6const THREAD_ID = "fixture-thread";
7const TURN_ID = "fixture-turn";7const 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";
4import {4import {
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";
16import { assert, it } from "@effect/vitest";19import { assert, it } from "@effect/vitest";
17import { Effect, Option, Schema } from "effect";20import { 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");
39const THREAD_ID = ThreadId.make("thread-1");42const THREAD_ID = ThreadId.make("thread-1");
40const FIXTURE_TURN_ID = "fixture-turn";43const FIXTURE_TURN_ID = "fixture-turn";
41const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");44const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
42type IntegrationProvider = ProviderKind;45type IntegrationProvider = ProviderDriverKind;
46const CODEX_PROVIDER = ProviderDriverKind.make("codex");
47const CLAUDE_AGENT_PROVIDER = ProviderDriverKind.make("claudeAgent");
4348
44function nowIso() {49function 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}
7681
77function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") {82function 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
8493
85function withHarness<A, E>(94function 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>(
106const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>115const 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);
111121
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);
961983
...@@ -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);
10691115
...@@ -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);
11431201
...@@ -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);
12121282
...@@ -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 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";2import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";3import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it, assert } from "@effect/vitest";5import { it, assert } from "@effect/vitest";
6import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";6import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
77
8import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
9import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";8import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
9import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
10import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";10import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
11import {
12 NoOpProviderEventLoggers,
13 ProviderEventLoggers,
14} from "../src/provider/Layers/ProviderEventLoggers.ts";
11import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";15import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
12import {16import {
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";
3135
36const codexInstanceId = ProviderInstanceId.make("codex");
37
32const makeWorkspaceDirectory = Effect.gen(function* () {38const 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();
4955
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 };
5759
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));
6871
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 @@
1import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";3import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";4import { Effect, FileSystem, Layer, Path, Schema } from "effect";
4import { createModelSelection } from "@t3tools/shared/model";5import { createModelSelection } from "@t3tools/shared/model";
5import { expect } from "vitest";6import { expect } from "vitest";
67
7import { ServerConfig } from "../../config.ts";8import { ServerConfig } from "../../config.ts";
8import { TextGeneration } from "../Services/TextGeneration.ts";9import { type TextGenerationShape } from "../Services/TextGeneration.ts";
9import { sanitizeThreadTitle } from "../Utils.ts";10import { sanitizeThreadTitle } from "../Utils.ts";
10import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";11import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
11import { ServerSettingsService } from "../../serverSettings.ts";
1212
13const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(13const 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);
2216
23function makeFakeClaudeBinary(dir: string) {17function 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;
9191
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;
9596
...@@ -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;
141126
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;
147136
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 }
153142
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 }
159148
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 }
165154
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 }
171160
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}
180186
181it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {187it.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* () {
196202 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 });
209214
210 expect(generated.subject).toBe("Add important change");215 expect(generated.subject).toBe("Add important change");
211 }),216 }),
212 ),217 ),
213 );218 );
214219
...@@ -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* () {
228233 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 });
243247
244 expect(generated.title).toBe("Improve orchestration flow");248 expect(generated.title).toBe("Improve orchestration flow");
245 }),249 }),
246 ),250 ),
247 );251 );
248252
...@@ -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 });
262274
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 );
280283
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* () {
292326 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 });
301334
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 ClaudeTextGeneration8 * @module ClaudeTextGeneration
9 */9 */
10import { Effect, Layer, Option, Schema, Stream } from "effect";10import { Effect, Option, Schema, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
13import { ClaudeModelSelection } from "@t3tools/contracts";13import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";
14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1515
16import { TextGenerationError } from "@t3tools/contracts";16import { TextGenerationError } from "@t3tools/contracts";
17import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";17import { type TextGenerationShape } from "../Services/TextGeneration.ts";
18import {18import {
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";
41import { ServerSettingsService } from "../../serverSettings.ts";41import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
4242
43const CLAUDE_TIMEOUT_MS = 180_000;43const CLAUDE_TIMEOUT_MS = 180_000;
4444
...@@ -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});
5252
53const makeClaudeTextGeneration = Effect.gen(function* () {53export 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);
5659
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 };
113116
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 });
234233
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 });
269261
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 });
298283
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 });
326304
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
355export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);
apps/server/src/git/Layers/CodexTextGeneration.test.ts+243-285
...@@ -1,30 +1,24 @@...@@ -1,30 +1,24 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, Result } from "effect";3import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect";
4import { createModelSelection } from "@t3tools/shared/model";4import { createModelSelection } from "@t3tools/shared/model";
5import { expect } from "vitest";5import { expect } from "vitest";
66
7import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
8
7import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
8import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";10import { type TextGenerationShape } from "../Services/TextGeneration.ts";
9import { TextGenerationError } from "@t3tools/contracts";11import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
10import { TextGeneration } from "../Services/TextGeneration.ts";12
11import { ServerSettingsService } from "../../serverSettings.ts";13const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
1214 ProviderInstanceId.make("codex"),
13const DEFAULT_TEST_MODEL_SELECTION = {15 "gpt-5.4-mini",
14 provider: "codex" as const,
15 model: "gpt-5.4-mini",
16};
17
18const 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);
2717
18const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
19 prefix: "t3code-codex-text-generation-test-",
20}).pipe(Layer.provideMerge(NodeServices.layer));
21
28function makeFakeCodexBinary(22function 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}
196170
197it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {171it.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* () {
210184 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 });
218191
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 );
226199
...@@ -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 );
256226
...@@ -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 );
279246
...@@ -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* () {
292259 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 });
301267
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 );
307273
...@@ -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* () {
318284 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 });
328293
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 );
335300
...@@ -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* () {
346311 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 });
352316
353 expect(generated.branch).toBe("feat/session");317 expect(generated.branch).toBe("feat/session");
354 }),318 }),
355 ),319 ),
356 );320 );
357321
...@@ -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* () {
368332 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 });
374337
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 );
379342
...@@ -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* () {
389352 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 });
395357
396 expect(generated.title).toBe("New thread");358 expect(generated.title).toBe("New thread");
397 }),359 }),
398 ),360 ),
399 );361 );
400362
...@@ -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* () {
410372 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 });
416377
417 expect(generated.title).toBe("hello world");378 expect(generated.title).toBe("hello world");
418 }),379 }),
419 ),380 ),
420 );381 );
421382
...@@ -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* () {
432393 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 });
438398
439 expect(generated.branch).toBe("fix/session-timeout");399 expect(generated.branch).toBe("fix/session-timeout");
440 }),400 }),
441 ),401 ),
442 );402 );
443403
...@@ -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 });
461421 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 );
526437
527 expect(generated.branch).toBe("fix/ui-regression");438 expect(generated.branch).toBe("fix/ui-regression");
528 }),439 }),
529 ),440 ),
530 );441 );
531442
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`);
547458 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
548 const textGeneration = yield* TextGeneration;459 yield* fs.writeFile(imagePath, Buffer.from("hello"));
549 const result = yield* textGeneration460
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 })
565476 .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 );
574491
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));
586508
587 const result = yield* textGeneration509 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);
594525
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 );
603562
...@@ -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);
613581
614 const result = yield* textGeneration582 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 @@
1import { randomUUID } from "node:crypto";1import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect";
2
3import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";2import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
53
6import { CodexModelSelection } from "@t3tools/contracts";4import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";
7import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
86
9import { resolveAttachmentPath } from "../../attachmentStore.ts";7import { 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";
19import {16import {
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";
32import { ServerSettingsService } from "../../serverSettings.ts";
33import {29import {
34 getModelSelectionBooleanOptionValue,30 getModelSelectionBooleanOptionValue,
35 getModelSelectionStringOptionValue,31 getModelSelectionStringOptionValue,
...@@ -37,12 +33,18 @@ import {...@@ -37,12 +33,18 @@ import {
3733
38const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";34const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
39const CODEX_TIMEOUT_MS = 180_000;35const CODEX_TIMEOUT_MS = 180_000;
40const 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 */
40export 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);
4648
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 fileSystem73 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 };
8791
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", "");
154158
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?.homePath188 ...(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 });
290287
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 });
325315
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 });
358341
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 });
391367
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});
420389
421export 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
55
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";7import { it } from "@effect/vitest";
8import { Effect, Layer } from "effect";8import { Effect, Layer, Schema } from "effect";
9import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
10import { expect } from "vitest";10import { expect } from "vitest";
1111
12import { ServerSettingsError } from "@t3tools/contracts";12import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
1313
14import { ServerConfig } from "../../config.ts";14import { ServerConfig } from "../../config.ts";
15import { TextGeneration } from "../Services/TextGeneration.ts";15import { type TextGenerationShape } from "../Services/TextGeneration.ts";
16import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";16import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
17import { ServerSettingsService } from "../../serverSettings.ts";
1817
19const __dirname = path.dirname(fileURLToPath(import.meta.url));18const __dirname = path.dirname(fileURLToPath(import.meta.url));
20const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");19const 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}
2524
26const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(25const 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);
3528
36function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {29function 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 {
5750
58function withFakeAcpAgent<A, E, R>(51function 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 });
6760 }),
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}
9968
100function waitForFileContent(path: string): Effect.Effect<string> {69function 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* () {
131100 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 });
146114
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");
149117
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 );
155125
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 );
205175
206 rmSync(requestLogDir, { recursive: true, force: true });176 rmSync(requestLogDir, { recursive: true, force: true });
207 }),177 }),
208 );178 );
209 });179 });
210180
...@@ -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* () {
219189 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 });
230199
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 );
236205
...@@ -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* () {
246215 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 });
255223
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 );
260228
...@@ -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* () {
275243 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 });
287254
288 expect(generated.subject).toBe("Close runtime after generation");255 expect(generated.subject).toBe("Close runtime after generation");
289256
290 const exitLog = yield* waitForFileContent(exitLogPath);257 const exitLog = yield* waitForFileContent(exitLogPath);
291 expect(exitLog).toContain("exit:0");258 expect(exitLog).toContain("exit:0");
292259
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 @@
1import { Effect, Layer, Option, Ref, Schema } from "effect";1import { Effect, Option, Ref, Schema } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";2import { ChildProcessSpawner } from "effect/unstable/process";
33
4import { CursorModelSelection } from "@t3tools/contracts";4import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
66
7import { TextGenerationError } from "@t3tools/contracts";7import { TextGenerationError } from "@t3tools/contracts";
8import {8import {
9 type ThreadTitleGenerationResult,9 type ThreadTitleGenerationResult,
10 type TextGenerationShape,10 type TextGenerationShape,
11 TextGeneration,
12} from "../Services/TextGeneration.ts";11} from "../Services/TextGeneration.ts";
13import {12import {
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";
29import { ServerSettingsService } from "../../serverSettings.ts";
3028
31const CURSOR_TIMEOUT_MS = 180_000;29const CURSOR_TIMEOUT_MS = 180_000;
3230
...@@ -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}
5755
58const 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 */
60export 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);
6165
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 });
188188
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 });
223216
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 });
252238
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 });
280259
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});
308280
309export 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 @@
1import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";3import { it } from "@effect/vitest";
3import { Duration, Effect, Layer } from "effect";4import { Duration, Effect, Layer, Schema } from "effect";
4import { TestClock } from "effect/testing";5import { TestClock } from "effect/testing";
5import { NetService } from "@t3tools/shared/Net";6import { NetService } from "@t3tools/shared/Net";
6import { beforeEach, expect } from "vitest";7import { beforeEach, expect } from "vitest";
78
8import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
10import {10import {
11 OpenCodeRuntime,11 OpenCodeRuntime,
12 OpenCodeRuntimeError,12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,13 type OpenCodeRuntimeShape,
14} from "../../provider/opencodeRuntime.ts";14} from "../../provider/opencodeRuntime.ts";
15import { TextGeneration } from "../Services/TextGeneration.ts";15import { type TextGenerationShape } from "../Services/TextGeneration.ts";
16import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";16import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";
1717
18const runtimeMock = {18const runtimeMock = {
19 state: {19 state: {
...@@ -97,23 +97,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -97,23 +97,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
97};97};
9898
99const DEFAULT_TEST_MODEL_SELECTION = {99const 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};
103103
104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
105105
106const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(106const 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);
125118
126const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(119const 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);
147131
132const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
133 binaryPath: "fake-opencode",
134});
135const 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
141function 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
148beforeEach(() => {151beforeEach(() => {
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* () {
157160
158it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => {161it.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* () {
162165 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);
296179
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});
301193
302it.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 });
323214
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 );
333224
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 );
335244
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
308it.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 @@
1import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";1import { Effect, Exit, Fiber, Schema, Scope } from "effect";
2import * as Semaphore from "effect/Semaphore";2import * as Semaphore from "effect/Semaphore";
33
4import {4import {
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";
9import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";10import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1112
12import { ServerConfig } from "../../config.ts";13import { ServerConfig } from "../../config.ts";
13import { resolveAttachmentPath } from "../../attachmentStore.ts";14import { resolveAttachmentPath } from "../../attachmentStore.ts";
14import { ServerSettingsService } from "../../serverSettings.ts";
15import {15import {
16 buildBranchNamePrompt,16 buildBranchNamePrompt,
17 buildCommitMessagePrompt,17 buildCommitMessagePrompt,
18 buildPrContentPrompt,18 buildPrContentPrompt,
19 buildThreadTitlePrompt,19 buildThreadTitlePrompt,
20} from "../Prompts.ts";20} from "../Prompts.ts";
21import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";21import { type TextGenerationShape } from "../Services/TextGeneration.ts";
22import {22import {
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}
9595
96const makeOpenCodeTextGeneration = Effect.gen(function* () {96export 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 openCodeRuntime204 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 }
280283
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.serverPassword296 ...(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 });
355338
356 const rawOutput =339 const rawOutput =
357 settings.serverUrl.length > 0340 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
511export 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 */
12import { Effect, Layer, Context } from "effect";
13
14import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts";
15import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
16import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
17import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
18import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
19
20// ---------------------------------------------------------------------------
21// Internal service tags so both concrete layers can coexist.
22// ---------------------------------------------------------------------------
23
24class CodexTextGen extends Context.Service<CodexTextGen, TextGenerationShape>()(
25 "t3/git/Layers/RoutingTextGeneration/CodexTextGen",
26) {}
27
28class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>()(
29 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
30) {}
31
32class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()(
33 "t3/git/Layers/RoutingTextGeneration/CursorTextGen",
34) {}
35
36class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
37 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
38) {}
39
40// ---------------------------------------------------------------------------
41// Routing implementation
42// ---------------------------------------------------------------------------
43
44const 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
64const InternalCodexLayer = Layer.effect(
65 CodexTextGen,
66 Effect.gen(function* () {
67 const svc = yield* TextGeneration;
68 return svc;
69 }),
70).pipe(Layer.provide(CodexTextGenerationLive));
71
72const InternalClaudeLayer = Layer.effect(
73 ClaudeTextGen,
74 Effect.gen(function* () {
75 const svc = yield* TextGeneration;
76 return svc;
77 }),
78).pipe(Layer.provide(ClaudeTextGenerationLive));
79
80const InternalCursorLayer = Layer.effect(
81 CursorTextGen,
82 Effect.gen(function* () {
83 const svc = yield* TextGeneration;
84 return svc;
85 }),
86).pipe(Layer.provide(CursorTextGenerationLive));
87
88const InternalOpenCodeLayer = Layer.effect(
89 OpenCodeTextGen,
90 Effect.gen(function* () {
91 const svc = yield* TextGeneration;
92 return svc;
93 }),
94).pipe(Layer.provide(OpenCodeTextGenerationLive));
95
96export 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 @@
1import { it } from "@effect/vitest";
2import { Effect, PubSub, Result, Stream } from "effect";
3import { describe, expect } from "vitest";
4
5import { ProviderInstanceId } from "@t3tools/contracts";
6import { createModelSelection } from "@t3tools/shared/model";
7
8import type { ProviderInstance } from "../../provider/ProviderDriver.ts";
9import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts";
10import type { TextGenerationShape } from "../Services/TextGeneration.ts";
11
12import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts";
13
14const 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
23const 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
41const 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
58describe("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 */
28import { Effect, Layer } from "effect";
29
30import { TextGenerationError } from "@t3tools/contracts";
31import type { ProviderInstanceId } from "@t3tools/contracts";
32
33import {
34 ProviderInstanceRegistry,
35 type ProviderInstanceRegistryShape,
36} from "../../provider/Services/ProviderInstanceRegistry.ts";
37import type { ProviderInstance } from "../../provider/ProviderDriver.ts";
38import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts";
39
40type TextGenerationOp =
41 | "generateCommitMessage"
42 | "generatePrContent"
43 | "generateBranchName"
44 | "generateThreadTitle";
45
46const 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 */
69export 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 */
95export 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 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
2import { Effect, Metric } from "effect";3import { Effect, Metric } from "effect";
34
4import { withMetrics } from "./Metrics.ts";5import { 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");
7981
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";
3import path from "node:path";3import path from "node:path";
4import { execFileSync } from "node:child_process";4import { execFileSync } from "node:child_process";
55
6import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";6import {
7 ProviderDriverKind,
8 ProviderRuntimeEvent,
9 ProviderSession,
10 ProviderInstanceId,
11} from "@t3tools/contracts";
7import {12import {
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);
50type LegacyProviderRuntimeEvent = {55type 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"),
400416
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"),
415431
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"),
498514
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"),
514530
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"),
530546
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();
550566
...@@ -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"),
631647
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"),
720736
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"),
766782
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"),
816832
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"),
827843
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 });
919935
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();
923939
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";
11import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";12import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";
12import { describe, expect, it } from "vitest";13import { 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";
11import * as NodeServices from "@effect/platform-node/NodeServices";12import * as NodeServices from "@effect/platform-node/NodeServices";
12import { assert, it } from "@effect/vitest";13import { 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 @@
1import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";1import {
2 CheckpointRef,
3 EventId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 TurnId,
8 ProviderInstanceId,
9} from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";10import { assert, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";11import { Effect, Layer } from "effect";
4import * as SqlClient from "effect/unstable/sql/SqlClient";12import * 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";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";5import {
6 ModelSelection,
7 ProviderRuntimeEvent,
8 ProviderSession,
9 ProviderDriverKind,
10 ProviderInstanceId,
11} from "@t3tools/contracts";
6import { createModelSelection } from "@t3tools/shared/model";12import { createModelSelection } from "@t3tools/shared/model";
7import {13import {
8 ApprovalRequestId,14 ApprovalRequestId,
...@@ -37,7 +43,11 @@ import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryI...@@ -37,7 +43,11 @@ import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryI
37import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";43import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
38import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";44import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
39import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";45import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
40import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";46import {
47 providerErrorLabel,
48 providerErrorLabelFromInstanceHint,
49 ProviderCommandReactorLive,
50} from "./ProviderCommandReactor.ts";
41import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";51import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
42import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";52import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
43import * as NodeServices from "@effect/platform-node/NodeServices";53import * 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 });
100110
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", () => {
603675
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();
609684
...@@ -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 });
644725
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();
650734
...@@ -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 });
685775
...@@ -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 });
774864
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();
780870
...@@ -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 );
801891
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 });
810893
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 });
813903
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 });
826912
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 });
872958
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();
8781029
...@@ -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", () => {
9421093
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();
9481102
...@@ -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 });
10011161
...@@ -1086,7 +1246,10 @@ describe("ProviderCommandReactor", () => {...@@ -1086,7 +1246,10 @@ describe("ProviderCommandReactor", () => {
10861246
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();
10921255
...@@ -1122,7 +1285,7 @@ describe("ProviderCommandReactor", () => {...@@ -1122,7 +1285,7 @@ describe("ProviderCommandReactor", () => {
11221285
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 });
12681431
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 });
13611590
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);
77const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";77const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
78const DEFAULT_THREAD_TITLE = "New thread";78const DEFAULT_THREAD_TITLE = "New thread";
7979
80export function providerErrorLabel(value: string | undefined): string {
81 const normalized = value?.trim();
82 return normalized && normalized.length > 0 ? normalized : "unknown";
83}
84
85export 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
80function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {95function 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 }
272287
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 !== threadProvider302 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 });
297382
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 });
315396
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 });
331422
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 === undefined429 .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);
351442
352 if (443 if (
353 !runtimeModeChanged &&444 !runtimeModeChanged &&
354 !cwdChanged &&445 !cwdChanged &&
446 !instanceChanged &&
355 !shouldRestartForModelChange &&447 !shouldRestartForModelChange &&
356 !shouldRestartForModelSelectionChange448 !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 === undefined525 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";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import type {5import {
6 OrchestrationReadModel,6 OrchestrationReadModel,
7 ProviderDriverKind,
7 ProviderRuntimeEvent,8 ProviderRuntimeEvent,
8 ProviderSession,9 ProviderSession,
10 ProviderInstanceId,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
10import {12import {
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 );
13671382
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";
11import { Effect } from "effect";12import { Effect } from "effect";
1213
...@@ -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";
11import { Effect } from "effect";12import { Effect } from "effect";
12import { describe, expect, it } from "vitest";13import { 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";
9import { createModelSelection } from "@t3tools/shared/model";10import { createModelSelection } from "@t3tools/shared/model";
10import { describe, expect, it } from "vitest";11import { 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 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import { Effect, Layer, Option } from "effect";3import { Effect, Layer, Option } from "effect";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * 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 SET43 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 SET69 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 SELECT86 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 SELECT106 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";
39import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";39import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
40import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts";40import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts";
41import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";41import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";
42import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";
43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
4244
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;
8185
82export const makeMigrationLoader = (throughId?: number) =>86export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts created+74
...@@ -0,0 +1,74 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { runMigrations } from "../Migrations.ts";
6import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7
8const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9
10layer("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 */
18import * as SqlClient from "effect/unstable/sql/SqlClient";
19import * as Effect from "effect/Effect";
20
21export 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 */
8import {8import {
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";
19export const ProviderSessionRuntime = Schema.Struct({20export 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 */
15import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { ChildProcessSpawner } from "effect/unstable/process";
18
19import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts";
20import { ServerConfig } from "../../config.ts";
21import { ProviderDriverError } from "../Errors.ts";
22import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
23import {
24 checkClaudeProviderStatus,
25 makePendingClaudeProvider,
26 probeClaudeCapabilities,
27} from "../Layers/ClaudeProvider.ts";
28import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
29import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
30import {
31 defaultProviderContinuationIdentity,
32 type ProviderDriver,
33 type ProviderInstance,
34} from "../ProviderDriver.ts";
35import type { ServerProviderDraft } from "../providerSnapshot.ts";
36import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
37import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
38
39const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
40const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
41const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
42
43export type ClaudeDriverEnv =
44 | ChildProcessSpawner.ChildProcessSpawner
45 | FileSystem.FileSystem
46 | Path.Path
47 | ProviderEventLoggers
48 | ServerConfig;
49
50const 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
66export 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 @@
1import * as NodeOS from "node:os";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { describe, expect, it } from "@effect/vitest";
5import { Effect, Path } from "effect";
6
7import {
8 makeClaudeCapabilitiesCacheKey,
9 makeClaudeContinuationGroupKey,
10 makeClaudeEnvironment,
11 resolveClaudeHomePath,
12} from "./ClaudeHome.ts";
13
14it.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 @@
1import * as NodeOS from "node:os";
2
3import type { ClaudeSettings } from "@t3tools/contracts";
4import { Effect, Path } from "effect";
5
6import { expandHomePath } from "../../pathExpansion.ts";
7
8export 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
16export 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
29export 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
36export 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 */
24import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
25import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
26import { ChildProcessSpawner } from "effect/unstable/process";
27
28import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts";
29import { ServerConfig } from "../../config.ts";
30import { ProviderDriverError } from "../Errors.ts";
31import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
32import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
33import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
34import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
35import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
36import type { ServerProviderDraft } from "../providerSnapshot.ts";
37import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 codexContinuationIdentity,
40 materializeCodexShadowHome,
41 resolveCodexHomeLayout,
42} from "./CodexHomeLayout.ts";
43
44const DRIVER_KIND = ProviderDriverKind.make("codex");
45const 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 */
52export 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 */
65const 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
81export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import { Effect, FileSystem, Path, Schema } from "effect";
4
5import { CodexSettings } from "@t3tools/contracts";
6import {
7 CodexShadowHomeError,
8 materializeCodexShadowHome,
9 resolveCodexHomeLayout,
10} from "./CodexHomeLayout.ts";
11
12const 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
20const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) {
21 const fileSystem = yield* FileSystem.FileSystem;
22 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
23});
24
25const 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
35it.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 @@
1import * as NodeOS from "node:os";
2
3import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts";
4import { Effect, FileSystem, Path, Schema } from "effect";
5import * as PlatformError from "effect/PlatformError";
6
7import { expandHomePath } from "../../pathExpansion.ts";
8
9export interface CodexHomeLayout {
10 readonly mode: "direct" | "authOverlay";
11 readonly sharedHomePath: string;
12 readonly effectiveHomePath: string | undefined;
13 readonly continuationKey: string;
14}
15
16const 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
28const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]);
29const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]);
30
31function 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
39export 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
63export 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
75type LinkState =
76 | {
77 readonly _tag: "Missing";
78 }
79 | {
80 readonly _tag: "NotSymlink";
81 }
82 | {
83 readonly _tag: "Symlink";
84 readonly target: string;
85 };
86
87function 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
96function 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
102function 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
113const 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
131const 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
144const 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
172const 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
186export 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
258export 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 */
15import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { ChildProcessSpawner } from "effect/unstable/process";
18
19import { ServerConfig } from "../../config.ts";
20import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts";
21import { ProviderDriverError } from "../Errors.ts";
22import { makeCursorAdapter } from "../Layers/CursorAdapter.ts";
23import {
24 buildInitialCursorProviderSnapshot,
25 checkCursorProviderStatus,
26 enrichCursorSnapshot,
27} from "../Layers/CursorProvider.ts";
28import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
29import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
30import {
31 defaultProviderContinuationIdentity,
32 type ProviderDriver,
33 type ProviderInstance,
34} from "../ProviderDriver.ts";
35import type { ServerProviderDraft } from "../providerSnapshot.ts";
36import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
37
38const DRIVER_KIND = ProviderDriverKind.make("cursor");
39const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
40
41export type CursorDriverEnv =
42 | ChildProcessSpawner.ChildProcessSpawner
43 | FileSystem.FileSystem
44 | Path.Path
45 | ProviderEventLoggers
46 | ServerConfig;
47
48const 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
64export 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 */
15import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { ChildProcessSpawner } from "effect/unstable/process";
18
19import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts";
20import { ServerConfig } from "../../config.ts";
21import { ProviderDriverError } from "../Errors.ts";
22import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts";
23import {
24 checkOpenCodeProviderStatus,
25 makePendingOpenCodeProvider,
26} from "../Layers/OpenCodeProvider.ts";
27import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
28import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
29import { OpenCodeRuntime } from "../opencodeRuntime.ts";
30import {
31 defaultProviderContinuationIdentity,
32 type ProviderDriver,
33 type ProviderInstance,
34} from "../ProviderDriver.ts";
35import type { ServerProviderDraft } from "../providerSnapshot.ts";
36import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
37
38const DRIVER_KIND = ProviderDriverKind.make("opencode");
39const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
40
41export type OpenCodeDriverEnv =
42 | ChildProcessSpawner.ChildProcessSpawner
43 | FileSystem.FileSystem
44 | OpenCodeRuntime
45 | Path.Path
46 | ProviderEventLoggers
47 | ServerConfig;
48
49const 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
65export 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}
118118
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 */
128export 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 */
145export 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 =
157export type ProviderServiceError =197export type ProviderServiceError =
158 | ProviderValidationError198 | ProviderValidationError
159 | ProviderUnsupportedError199 | ProviderUnsupportedError
200 | ProviderInstanceNotFoundError
160 | ProviderSessionNotFoundError201 | ProviderSessionNotFoundError
161 | ProviderSessionDirectoryPersistenceError202 | ProviderSessionDirectoryPersistenceError
162 | ProviderAdapterError203 | 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";
13import {13import {
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";
20import { createModelSelection } from "@t3tools/shared/model";23import { createModelSelection } from "@t3tools/shared/model";
21import { assert, describe, it } from "@effect/vitest";24import { assert, describe, it } from "@effect/vitest";
22import { Effect, Fiber, Layer, Random, Stream } from "effect";25import { Context, Effect, Fiber, Layer, Random, Schema, Stream } from "effect";
2326
24import { attachmentRelativePath } from "../../attachmentStore.ts";27import { attachmentRelativePath } from "../../attachmentStore.ts";
25import { ServerConfig } from "../../config.ts";28import { ServerConfig } from "../../config.ts";
26import { ServerSettingsService } from "../../serverSettings.ts";29import { ServerSettingsService } from "../../serverSettings.ts";
27import { ProviderAdapterValidationError } from "../Errors.ts";30import { ProviderAdapterValidationError } from "../Errors.ts";
28import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";31import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
29import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";32import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
33
34// Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`.
35class ClaudeAdapter extends Context.Service<ClaudeAdapter, ClaudeAdapterShape>()(
36 "test/ClaudeAdapter",
37) {}
3038
31class FakeClaudeQuery implements AsyncIterable<SDKMessage> {39class 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;
148158
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 };
165176
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* adapter266 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);
252273
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 });
280301
...@@ -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 });
300321
...@@ -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 });
320341
...@@ -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 });
342365
...@@ -348,15 +371,37 @@ describe("ClaudeAdapterLive", () => {...@@ -348,15 +371,37 @@ describe("ClaudeAdapterLive", () => {
348 );371 );
349 });372 });
350373
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 });
385432
...@@ -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 });
406455
...@@ -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 });
427478
...@@ -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 });
448501
...@@ -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 });
471526
...@@ -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 });
492549
...@@ -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 });
515574
...@@ -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 });
536597
...@@ -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 });
545608
546 const createInput = harness.getLastCreateQueryInput();609 const createInput = harness.getLastCreateQueryInput();
...@@ -585,7 +648,7 @@ describe("ClaudeAdapterLive", () => {...@@ -585,7 +648,7 @@ describe("ClaudeAdapterLive", () => {
585648
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 });
591654
...@@ -630,9 +693,9 @@ describe("ClaudeAdapterLive", () => {...@@ -630,9 +693,9 @@ describe("ClaudeAdapterLive", () => {
630693
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", () => {
807870
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 });
813876
...@@ -986,7 +1049,7 @@ describe("ClaudeAdapterLive", () => {...@@ -986,7 +1049,7 @@ describe("ClaudeAdapterLive", () => {
9861049
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 });
9921055
...@@ -1077,7 +1140,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1077,7 +1140,7 @@ describe("ClaudeAdapterLive", () => {
10771140
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 });
10831146
...@@ -1153,7 +1216,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1153,7 +1216,7 @@ describe("ClaudeAdapterLive", () => {
11531216
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 });
11591222
...@@ -1219,7 +1282,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1219,7 +1282,7 @@ describe("ClaudeAdapterLive", () => {
12191282
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 });
12251288
...@@ -1270,13 +1333,19 @@ describe("ClaudeAdapterLive", () => {...@@ -1270,13 +1333,19 @@ describe("ClaudeAdapterLive", () => {
12701333
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", () => {
12921361
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 });
12981367
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", () => {
13471416
1348 let promptConsumerError: unknown = undefined;1417 let promptConsumerError: unknown = undefined;
13491418
1350 const layer = makeClaudeAdapterLive({1419 const layer = Layer.effect(
1351 createQuery: (input) => {1420 ClaudeAdapter,
1352 // Simulate the SDK consuming the prompt iterable1421 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", () => {
13791454
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 });
13851460
...@@ -1416,7 +1491,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1416,7 +1491,7 @@ describe("ClaudeAdapterLive", () => {
14161491
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 });
14221497
...@@ -1463,7 +1538,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1463,7 +1538,7 @@ describe("ClaudeAdapterLive", () => {
14631538
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 });
14691544
...@@ -1517,7 +1592,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1517,7 +1592,7 @@ describe("ClaudeAdapterLive", () => {
15171592
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 });
15231598
...@@ -1584,7 +1659,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1584,7 +1659,7 @@ describe("ClaudeAdapterLive", () => {
15841659
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 });
15901665
...@@ -1649,7 +1724,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1649,7 +1724,7 @@ describe("ClaudeAdapterLive", () => {
16491724
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 });
16551730
...@@ -1730,7 +1805,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1730,7 +1805,7 @@ describe("ClaudeAdapterLive", () => {
17301805
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 });
17361811
...@@ -1821,7 +1896,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1821,7 +1896,7 @@ describe("ClaudeAdapterLive", () => {
18211896
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 });
18271902
...@@ -1987,7 +2062,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1987,7 +2062,7 @@ describe("ClaudeAdapterLive", () => {
19872062
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 });
19932068
...@@ -2056,7 +2131,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2056,7 +2131,7 @@ describe("ClaudeAdapterLive", () => {
20562131
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 });
20622137
...@@ -2278,7 +2353,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2278,7 +2353,7 @@ describe("ClaudeAdapterLive", () => {
22782353
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", () => {
23512426
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 });
23572432
...@@ -2460,7 +2535,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2460,7 +2535,7 @@ describe("ClaudeAdapterLive", () => {
24602535
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 });
24662541
...@@ -2533,7 +2608,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2533,7 +2608,7 @@ describe("ClaudeAdapterLive", () => {
25332608
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", () => {
25752650
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", () => {
26582733
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 });
26642739
...@@ -2692,7 +2767,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2692,7 +2767,7 @@ describe("ClaudeAdapterLive", () => {
26922767
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 });
26982773
...@@ -2772,14 +2847,14 @@ describe("ClaudeAdapterLive", () => {...@@ -2772,14 +2847,14 @@ describe("ClaudeAdapterLive", () => {
27722847
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 });
27942869
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 };
28052908
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", () => {
28382941
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 });
28442947
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", () => {
28742979
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", () => {
29043009
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 });
29103015
...@@ -2956,7 +3061,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2956,7 +3061,7 @@ describe("ClaudeAdapterLive", () => {
29563061
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", () => {
29793084
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 });
29853090
...@@ -3045,7 +3150,7 @@ describe("ClaudeAdapterLive", () => {...@@ -3045,7 +3150,7 @@ describe("ClaudeAdapterLive", () => {
30453150
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 });
30513156
...@@ -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 });
31233228
...@@ -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 });
32463351
...@@ -3306,7 +3411,7 @@ describe("ClaudeAdapterLive", () => {...@@ -3306,7 +3411,7 @@ describe("ClaudeAdapterLive", () => {
33063411
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 });
33123417
...@@ -3393,7 +3498,7 @@ describe("ClaudeAdapterLive", () => {...@@ -3393,7 +3498,7 @@ describe("ClaudeAdapterLive", () => {
33933498
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 {
6568
66import { resolveAttachmentPath } from "../../attachmentStore.ts";69import { resolveAttachmentPath } from "../../attachmentStore.ts";
67import { ServerConfig } from "../../config.ts";70import { ServerConfig } from "../../config.ts";
68import { ServerSettingsService } from "../../serverSettings.ts";71import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
69import {72import {
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";
83import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";86import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
84import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";87import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
8588
86const PROVIDER = "claudeAgent" as const;89const PROVIDER = ProviderDriverKind.make("claudeAgent");
87type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;90type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
88type ClaudeToolResultStreamKind = Extract<91type 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}
183186
184export interface ClaudeAdapterLiveOptions {187export 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>;
567572
568function buildPromptText(input: ProviderSendTurnInput): string {573function 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);
576584
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>> = [];
618627
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}
965974
966const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (975export 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 !== undefined988 (options?.nativeEventLogPath !== undefined
...@@ -989,7 +1004,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -989,7 +1004,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
9891004
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;
9931007
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));
28222836
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;
30483052
3049 if (context.turnState) {3053 if (context.turnState) {
3050 // Auto-close a stale synthetic turn (from background agent responses3054 // 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 });
31223127
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
3259export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter());
3260
3261export 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 @@
1import type {1import {
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";
11import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";9import { Effect, Option, Path, Result } from "effect";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import {11import {
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";
40import { compareCliVersions } from "../cliVersion.ts";35import { compareCliVersions } from "../cliVersion.ts";
41import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";36import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
42import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
43import { ServerSettingsService } from "../../serverSettings.ts";
44import { ServerSettingsError } from "@t3tools/contracts";
4537
46const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({38const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
47 optionDescriptors: [],39 optionDescriptors: [],
48});40});
4941
50const PROVIDER = "claudeAgent" as const;42const PROVIDER = ProviderDriverKind.make("claudeAgent");
51const CLAUDE_PRESENTATION = {43const 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}
244236
245export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string {237export 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}
253export 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. */
341const 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. */
351const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const;
352const AUTH_METHOD_KEYS = ["authMethod", "auth_method"] as const;
353const 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. */
356const 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. */
360const 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 */
369function 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
390function 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 */
415const decodeUnknownJson = decodeJsonResult(Schema.Unknown);
416
417function 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
423function 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}
428245
429function toTitleCaseWords(value: string): string {246function toTitleCaseWords(value: string): string {
430 return value247 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;
440257
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 |
460function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined {293function 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}
466305
306function 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
467function claudeAuthMetadata(input: {323function 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 }
477333
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 }
485340
...@@ -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}
497352
353type ClaudeCapabilitiesProbe = {
354 readonly email: string | undefined;
355 readonly subscriptionType: string | undefined;
356 readonly tokenSource: string | undefined;
357 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
358};
359
498function parseClaudeInitializationCommands(360function 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 include442 * This is used as a fallback when `claude auth status` does not include
581 * subscription type information.443 * subscription type information.
582 */444 */
583const probeClaudeCapabilities = (binaryPath: string) => {445const 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-yield454 // 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};
621499
622const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: ReadonlyArray<string>) {500const 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});
632512
633export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (513export 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 | ServerSettingsService522 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")(
654531
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 }
671547
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")(
696571
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 ? undefined617 ? undefined
745 : formatClaudeOpus47UpgradeMessage(parsedVersion);618 : formatClaudeOpus47UpgradeMessage(parsedVersion);
746619
747 const slashCommands =620 const capabilities = resolveCapabilities
748 (resolveSlashCommands621 ? 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);
754625
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 }
821642
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.message662 ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}),
840 ? { message: parsed.message }
841 : opus47UpgradeMessage
842 ? { message: opus47UpgradeMessage }
843 : {}),
844 },663 },
845 });664 });
846});665});
847666
848const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => {667export 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
856675
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 }
873691
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};
889706
890export const ClaudeProviderLive = Layer.effect(707export { 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";
4import path from "node:path";4import path from "node:path";
5import {5import {
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";
18import * as NodeServices from "@effect/platform-node/NodeServices";21import * as NodeServices from "@effect/platform-node/NodeServices";
19import { it, vi } from "@effect/vitest";22import { it, vi } from "@effect/vitest";
2023
21import { Effect, Exit, Fiber, Layer, Option, Queue, Scope, Stream } from "effect";24import { Context, Effect, Exit, Fiber, Layer, Option, Queue, Schema, Scope, Stream } from "effect";
22import * as CodexErrors from "effect-codex-app-server/errors";25import * as CodexErrors from "effect-codex-app-server/errors";
2326
24import { ServerConfig } from "../../config.ts";27import { ServerConfig } from "../../config.ts";
25import { ServerSettingsService } from "../../serverSettings.ts";28import { ServerSettingsService } from "../../serverSettings.ts";
26import { ProviderAdapterValidationError } from "../Errors.ts";29import { ProviderAdapterValidationError } from "../Errors.ts";
27import { CodexAdapter } from "../Services/CodexAdapter.ts";30import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
28import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";31import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
29import {32import {
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";
35import { makeCodexAdapterLive } from "./CodexAdapter.ts";38import { makeCodexAdapter } from "./CodexAdapter.ts";
39
40// Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`.
41class CodexAdapter extends Context.Service<CodexAdapter, CodexAdapterShape>()(
42 "test/CodexAdapter",
43) {}
3644
37const asThreadId = (value: string): ThreadId => ThreadId.make(value);45const asThreadId = (value: string): ThreadId => ThreadId.make(value);
38const asTurnId = (value: string): TurnId => TurnId.make(value);46const asTurnId = (value: string): TurnId => TurnId.make(value);
...@@ -45,7 +53,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {...@@ -45,7 +53,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
4553
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
203211
204const validationRuntimeFactory = makeRuntimeFactory();212const validationRuntimeFactory = makeRuntimeFactory();
205const validationLayer = it.layer(213const 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* adapter234 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;
242258
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) => {
263280
264const sessionRuntimeFactory = makeRuntimeFactory();281const sessionRuntimeFactory = makeRuntimeFactory();
265const sessionErrorLayer = it.layer(282const 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});
326406
327const lifecycleRuntimeFactory = makeRuntimeFactory();407const lifecycleRuntimeFactory = makeRuntimeFactory();
328const lifecycleLayer = it.layer(408const 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) => {
897985
898const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory();986const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory();
899const scopedLifecycleLayer = it.layer(987const 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;
9131009
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) => {
9331029
934const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true });1030const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true });
935const scopedFailureLayer = it.layer(1031const 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) => {
9491053
950 const result = yield* adapter1054 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;
9751079
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));
9881098
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 @@
10import {10import {
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";
24import { Effect, Exit, Fiber, FileSystem, Layer, Queue, Schema, Scope, Stream } from "effect";27import { Effect, Exit, Fiber, FileSystem, Queue, Schema, Scope, Stream } from "effect";
25import { ChildProcessSpawner } from "effect/unstable/process";28import { ChildProcessSpawner } from "effect/unstable/process";
26import * as CodexErrors from "effect-codex-app-server/errors";29import * as CodexErrors from "effect-codex-app-server/errors";
27import * as EffectCodexSchema from "effect-codex-app-server/schema";30import * 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";
42import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";45import { type CodexAdapterShape } from "../Services/CodexAdapter.ts";
43import { resolveAttachmentPath } from "../../attachmentStore.ts";46import { resolveAttachmentPath } from "../../attachmentStore.ts";
44import { ServerConfig } from "../../config.ts";47import { ServerConfig } from "../../config.ts";
45import { ServerSettingsService } from "../../serverSettings.ts";
46import {48import {
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";
54import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";56import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
5557
56const PROVIDER = "codex" as const;58const PROVIDER = ProviderDriverKind.make("codex");
5759
58export interface CodexAdapterLiveOptions {60export 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}
13201324
1321const 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 */
1334export 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>();
13391353
...@@ -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 }
13551369
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") === true1385 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* (
14921496
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.runtime1506 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 ...(reasoningEffort1512 ...(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});
16781682
1679export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter());1683// NOTE: the old `CodexAdapterLive` / `makeCodexAdapterLive` singleton Layer
16801684// exports have been removed as part of the per-instance-driver refactor.
1681export 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 @@
1import {1import { 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";
13import { ChildProcessSpawner } from "effect/unstable/process";2import { ChildProcessSpawner } from "effect/unstable/process";
14import * as CodexClient from "effect-codex-app-server/client";3import * as CodexClient from "effect-codex-app-server/client";
15import * as CodexSchema from "effect-codex-app-server/schema";4import * 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";
2716
28import { createModelCapabilities } from "@t3tools/shared/model";17import { createModelCapabilities } from "@t3tools/shared/model";
2918
30import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";19import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";
31import { buildServerProvider } from "../providerSnapshot.ts";
32import { CodexProvider } from "../Services/CodexProvider.ts";
33import { expandHomePath } from "../../pathExpansion.ts";20import { expandHomePath } from "../../pathExpansion.ts";
34import { ServerSettingsService } from "../../serverSettings.ts";21import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
35import packageJson from "../../../package.json" with { type: "json" };22import packageJson from "../../../package.json" with { type: "json" };
3623
37const PROVIDER = "codex" as const;
38const PROVIDER_PROBE_TIMEOUT_MS = 8_000;24const PROVIDER_PROBE_TIMEOUT_MS = 8_000;
39const CODEX_PRESENTATION = {25const 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}
9278
79function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) {
80 if (!account || account.type !== "chatgpt") return undefined;
81 return account.email;
82}
83
93function mapCodexModelCapabilities(84function 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}
247238
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.
248const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {245const 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"));
309315
310const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>316const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
311 codexSettings.customModels317 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 }));
320326
321const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => {327const 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);
324330
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 }
342347
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"];
371377
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}
386392
387export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (393export 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.ChildProcessSpawner404 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.ChildProcessSpawner410 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);
409414
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);
434439
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
456460
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);
477480
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});
494496
495export 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),504export { 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 @@
1import { randomUUID } from "node:crypto";
2
3import {1import {
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";
19import { normalizeModelSlug } from "@t3tools/shared/model";19import { normalizeModelSlug } from "@t3tools/shared/model";
20import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Schema, Stream } from "effect";20import { Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Random, Schema, Stream } from "effect";
21import * as SchemaIssue from "effect/SchemaIssue";21import * as SchemaIssue from "effect/SchemaIssue";
22import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";22import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
23import * as CodexClient from "effect-codex-app-server/client";23import * 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";
26import * as EffectCodexSchema from "effect-codex-app-server/schema";26import * as EffectCodexSchema from "effect-codex-app-server/schema";
2727
28import { buildCodexInitializeParams } from "./CodexProvider.ts";28import { buildCodexInitializeParams } from "./CodexProvider.ts";
29import { expandHomePath } from "../../pathExpansion.ts";
29import {30import {
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";
33import { expandHomePath } from "../../pathExpansion.ts";
3434
35const PROVIDER = "codex" as const;35const PROVIDER = ProviderDriverKind.make("codex");
3636
37const ANSI_ESCAPE_CHAR = String.fromCharCode(27);37const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
38const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");38const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
...@@ -76,8 +76,10 @@ type CodexThreadItem =...@@ -76,8 +76,10 @@ type CodexThreadItem =
7676
77export interface CodexSessionRuntimeOptions {77export 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 {
8789
88export interface CodexSessionRuntimeSendTurnInput {90export 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);
682690
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* spawner699 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.homePath703 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 = (
712726
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);
726741
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(),
734749 ...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 = (
891908
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 = (
947964
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 = (
10031020
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";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";7import { assert, it } from "@effect/vitest";
8import { Deferred, Effect, Fiber, Layer, Stream } from "effect";8import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect";
9import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
1010
11import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts";11import {
12 ApprovalRequestId,
13 CursorSettings,
14 ProviderDriverKind,
15 type ProviderRuntimeEvent,
16 ThreadId,
17 ProviderInstanceId,
18} from "@t3tools/contracts";
1219
13import { ServerConfig } from "../../config.ts";20import { ServerConfig } from "../../config.ts";
14import { ServerSettingsService } from "../../serverSettings.ts";21import { ServerSettingsService } from "../../serverSettings.ts";
15import { CursorAdapter } from "../Services/CursorAdapter.ts";22import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
16import { makeCursorAdapterLive } from "./CursorAdapter.ts";23import { makeCursorAdapter } from "./CursorAdapter.ts";
24
25// Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`.
26class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()(
27 "test/CursorAdapter",
28) {}
1729
18const __dirname = path.dirname(fileURLToPath(import.meta.url));30const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");31const 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}
93105
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.
114const 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
94const cursorAdapterTestLayer = it.layer(122const 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) => {
120155
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 });
128163
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) => {
205240
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 });
213248
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* adapter311 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) => {
302337
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 });
310345
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 });
360395
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) => {
366401
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 });
468503
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) => {
615657
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 });
623665
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) => {
714756
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 });
722764
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) => {
836878
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 });
844886
845 const sendTurnFiber = yield* adapter887 const sendTurnFiber = yield* adapter
...@@ -906,10 +948,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -906,10 +948,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
906948
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 });
914956
915 const sendTurnFiber = yield* adapter957 const sendTurnFiber = yield* adapter
...@@ -949,10 +991,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -949,10 +991,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
949991
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 });
957999
958 const sendTurnFiber = yield* adapter1000 const sendTurnFiber = yield* adapter
...@@ -992,10 +1034,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -992,10 +1034,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
9921034
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 });
10001042
1001 const sendTurnFiber = yield* adapter1043 const sendTurnFiber = yield* adapter
...@@ -1035,10 +1077,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -1035,10 +1077,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10351077
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 });
10431085
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) => {
10731115
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 });
10811123
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) => {
11361178
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 });
11441186
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";
77
8import {8import {
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";
4042
41import { resolveAttachmentPath } from "../../attachmentStore.ts";43import { resolveAttachmentPath } from "../../attachmentStore.ts";
42import { ServerConfig } from "../../config.ts";44import { ServerConfig } from "../../config.ts";
43import { ServerSettingsService } from "../../serverSettings.ts";
44import {45import {
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";
75import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts";76import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";
76import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";77import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
77import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";78import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
7879
79const PROVIDER = "cursor" as const;80const PROVIDER = ProviderDriverKind.make("cursor");
80const CURSOR_RESUME_VERSION = 1 as const;81const CURSOR_RESUME_VERSION = 1 as const;
81const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];82const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
82const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];83const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
83const ACP_APPROVAL_MODE_ALIASES = ["ask"];84const ACP_APPROVAL_MODE_ALIASES = ["ask"];
8485
85export interface CursorAdapterLiveOptions {86export 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}
89108
90interface PendingApproval {109interface PendingApproval {
...@@ -280,12 +299,15 @@ function selectAutoApprovedPermissionOption(...@@ -280,12 +299,15 @@ function selectAutoApprovedPermissionOption(
280 return undefined;299 return undefined;
281}300}
282301
283function makeCursorAdapter(options?: CursorAdapterLiveOptions) {302export 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 !== undefined313 (options?.nativeEventLogPath !== undefined
...@@ -440,25 +462,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -440,25 +462,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
440462
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 }
448470
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 });
477486
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
1053export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter());
1054
1055export 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 @@
1import * as path from "node:path";1import * as NodeOS from "node:os";
2import * as os from "node:os";
3import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
52
6import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect } from "effect";4import { Effect, FileSystem, Path } from "effect";
8import { describe, expect, it } from "vitest";5import { describe, expect, it } from "vitest";
9import type * as EffectAcpSchema from "effect-acp/schema";6import type * as EffectAcpSchema from "effect-acp/schema";
10import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";7import 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";
2725
28const __dirname = path.dirname(fileURLToPath(import.meta.url));26const runNode = <A, E>(
29const 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
30const 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});
3034
31function selectDescriptor(35function 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}
5559
56async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {60const 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}
64exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"76exec ${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
83const 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
93if [ "$1" = "about" ]; then
94 printf 'CLI Version 2026.04.09-f2b0fcd\\n'
95 printf 'User Email cursor@example.com\\n'
96 exit 0
97fi
98exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"
99`;
100 yield* fileSystem.writeFileString(wrapperPath, script);
101 yield* fileSystem.chmod(wrapperPath, 0o755);
102 return wrapperPath;
103});
70104
71async function waitForFileContent(filePath: string, attempts = 40): Promise<string> {105const 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
124const 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
137const 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});
83152
84const parameterizedGpt54ConfigOptions = [153const parameterizedGpt54ConfigOptions = [
85 {154 {
...@@ -428,9 +497,38 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => {...@@ -428,9 +497,38 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => {
428 });497 });
429});498});
430499
500describe("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
431describe("discoverCursorModelsViaAcp", () => {529describe("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());
434532
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 });
451549
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 });
458554
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 );
467563
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});
472568
473describe("discoverCursorModelCapabilitiesViaAcp", () => {569describe("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 ]);
510604
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 @@
1import * as nodeFs from "node:fs";
2import * as nodeOs from "node:os";1import * as nodeOs from "node:os";
3import * as nodePath from "node:path";
42
5import type {3import 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";
12import { ProviderDriverKind } from "@t3tools/contracts";
15import type * as EffectAcpSchema from "effect-acp/schema";13import type * as EffectAcpSchema from "effect-acp/schema";
16import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect";14import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18import {16import {
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";
33import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
34import { CursorProvider } from "../Services/CursorProvider.ts";
35import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";32import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";
3733
38const PROVIDER = "cursor" as const;34const PROVIDER = ProviderDriverKind.make("cursor");
39const CURSOR_PRESENTATION = {35const 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({
48const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;44const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
49const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";45const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
50const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;46const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;
51const CURSOR_REFRESH_INTERVAL = "1 hour";
52const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;47const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
53export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {48export 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"]>;
5853
59function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider {54export 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);
6259
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 }
7975
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 entry109 "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}
394394
395const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) =>395const 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) =>
417const withCursorAcpProbeRuntime = <A, E, R>(421const 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 );
421430
422function normalizeCursorConfigOptionToken(value: string | null | undefined): string {431function 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
460export function resolveCursorAcpConfigUpdates(469export 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 }
467479
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 }> = [];
469484
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}
527542
528export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) =>543export 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 );
534557
535export const discoverCursorModelCapabilitiesViaAcp = (558export 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 }
548584
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 }
558665
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", {})),
573674 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 );
640676
641export function getCursorFallbackModels(677export 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}
813848
814function readCursorCliConfigChannel(): string | undefined {849const 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}
822856
823export function getCursorParameterizedModelPickerUnsupportedMessage(input: {857export 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 }
9901025
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}
9941033
995const runCursorCommand = (args: ReadonlyArray<string>) =>1034const 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 });
10051045
...@@ -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);
10181058
1019const runCursorAboutCommand = Effect.gen(function* () {1059const 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,
10261066 ["about", "--format", "json"],
1027export 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 });
10561074
1057 // Single `agent about` probe: returns version + auth status in one call.1075export 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);
10621085
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 }
10821101
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 }
10991107
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);
11581126
1159export 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 }
11641142
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});
11691200
1170 return yield* makeManagedServerProvider<CursorSettings>({1201export 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 }
11891204
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 */
1215export 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);
11961224
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";
22
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";4import { it } from "@effect/vitest";
5import { Effect, Layer, Option } from "effect";5import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect";
6import { beforeEach } from "vitest";6import { beforeEach } from "vitest";
77
8import { ThreadId } from "@t3tools/contracts";8import {
9 OpenCodeSettings,
10 ProviderDriverKind,
11 ProviderInstanceId,
12 ThreadId,
13} from "@t3tools/contracts";
14import { createModelSelection } from "@t3tools/shared/model";
9import { ServerConfig } from "../../config.ts";15import { ServerConfig } from "../../config.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";16import { ServerSettingsService } from "../../serverSettings.ts";
11import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";17import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
12import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";18import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
13import {19import {
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";
18import {24import {
19 appendOpenCodeAssistantTextDelta,25 appendOpenCodeAssistantTextDelta,
20 makeOpenCodeAdapterLive,26 makeOpenCodeAdapter,
21 mergeOpenCodeAssistantText,27 mergeOpenCodeAssistantText,
22} from "./OpenCodeAdapter.ts";28} from "./OpenCodeAdapter.ts";
2329
30// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`.
31class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(
32 "test/OpenCodeAdapter",
33) {}
34
24const asThreadId = (value: string): ThreadId => ThreadId.make(value);35const asThreadId = (value: string): ThreadId => ThreadId.make(value);
2536
26type MessageEntry = {37type 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});
167181
168const 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`.
188const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({
189 binaryPath: "fake-opencode",
190 serverUrl: "http://127.0.0.1:9999",
191 serverPassword: "secret-password",
192});
193
194const 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;
197228
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 );
232263
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 );
263320
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 );
301391
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 };
398634
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 };
477718
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 @@
1import { randomUUID } from "node:crypto";
2
3import {1import {
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";
14import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect";15import { Cause, Effect, Exit, Queue, Random, Ref, Scope, Stream } from "effect";
15import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";16import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
16import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";17import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1718
18import { resolveAttachmentPath } from "../../attachmentStore.ts";19import { resolveAttachmentPath } from "../../attachmentStore.ts";
19import { ServerConfig } from "../../config.ts";20import { ServerConfig } from "../../config.ts";
20import { ServerSettingsService } from "../../serverSettings.ts";
21import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";21import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
22import {22import {
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";
29import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";29import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
30import {30import {
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";
4343
44const PROVIDER = "opencode" as const;44const PROVIDER = ProviderDriverKind.make("opencode");
4545
46interface OpenCodeTurnSnapshot {46interface OpenCodeTurnSnapshot {
47 readonly id: TurnId;47 readonly id: TurnId;
...@@ -89,6 +89,8 @@ interface OpenCodeSessionContext {...@@ -89,6 +89,8 @@ interface OpenCodeSessionContext {
89}89}
9090
91export interface OpenCodeAdapterLiveOptions {91export 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 });
127129
128function buildEventBase(input: {130const 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 !== undefined149 ...(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 );
157162
158function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {163function 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 and258 // `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 }
424435
425 // Best-effort remote abort. The scope close below tears down the local436 // 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});
437449
438export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {450export 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 !== undefined459 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 via464 : 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>();
458470 const sessions = new Map<ThreadId, OpenCodeSessionContext>();
459 // Layer-level finalizer: when the adapter layer shuts down, stop every471
460 // session. Each session's `Scope.close` tears down its spawned OpenCode472 // Layer-level finalizer: when the adapter layer shuts down, stop every
461 // server (via the `ChildProcessSpawner` finalizer installed in473 // session. Each session's `Scope.close` tears down its spawned OpenCode
462 // `startOpenCodeServerProcess`) and interrupts the forked event/exit474 // server (via the `ChildProcessSpawner` finalizer installed in
463 // fibers. Consumers that can't reason about Effect scopes therefore475 // `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 defects481 sessions.clear();
470 // from throwing scope finalizers so a sibling's death can't interrupt482 // `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 lifecycle489 );
478 // events emitted during shutdown still get written. `close` flushes490 // Close the logger AFTER session teardown so any final lifecycle
479 // the `Logger.batched` window and closes each per-thread491 // 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))),
486498 );
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);
503557 // 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 stream562 ).pipe(Effect.ignore({ log: true }));
509 // failure and the server-exit watcher). `getAndSet` flips the flag in563 yield* Scope.close(context.sessionScope, Exit.void);
510 // a single step so the loser observes `true` and returns; a plain564 });
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 sites571 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 any573 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't630 }
539 // delegate to it because our `getAndSet` above already flipped the631 });
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 });
546655
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 }
588719
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 });
613791
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 event797 turnId,
620 ? (event.properties as { sessionID?: unknown }).sessionID798 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 }
625809
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 }
638826
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 }
652848
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 }
657863
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 }
752871
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 }
774887
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 }
792905
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 }
809946
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 }
831962
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* () {
846985 // 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 }
851990 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 );
9331000
934 // Fibers forked into `context.sessionScope` are interrupted1001 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 session1005 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 );
9711013 }
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 });
9891027
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 });
10631071
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);
10751083 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 }
11331086
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 }
11471099
1148 const agent =1100 const context: OpenCodeSessionContext = {
1149 input.modelSelection?.provider === PROVIDER1101 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 === PROVIDER1105 openCodeSessionId: started.openCodeSession.id,
1154 ? getModelSelectionStringOptionValue(input.modelSelection, "variant")1106 pendingPermissions: new Map(),
1155 : undefined;1107 pendingQuestions: new Map(),
11561108 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);
11691121
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 });
11781136
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 ready1175 return yield* new ProviderAdapterValidationError({
1190 // with lastError set, emit turn.aborted, then let the typed error1176 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 );
12171197
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 });
12231206
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 );
12411248
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 });
12611254
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 }
12991308
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 );
13021342
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));
13051345
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));
13121348
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));
13191357
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 }));
13261364
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 );
13331371
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));
13451380
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));
13491392
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 are1397 const stopAll: OpenCodeAdapterShape["stopAll"] = () =>
1355 // already `Effect.ignore`'d inside it. `ignoreCause` here also1398 Effect.gen(function* () {
1356 // swallows defects from throwing finalizers so one bad close can't1399 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 });
13641411
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
1389export 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";
22
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";4import { it } from "@effect/vitest";
5import { Effect, Layer } from "effect";5import { Effect, Layer, Schema } from "effect";
6import { beforeEach } from "vitest";6import { beforeEach } from "vitest";
77
8import { OpenCodeSettings } from "@t3tools/contracts";
8import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
10import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
11import {10import {
12 OpenCodeRuntime,11 OpenCodeRuntime,
13 OpenCodeRuntimeError,12 OpenCodeRuntimeError,
14 type OpenCodeRuntimeShape,13 type OpenCodeRuntimeShape,
15} from "../opencodeRuntime.ts";14} from "../opencodeRuntime.ts";
16import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";15import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts";
17import type { OpenCodeInventory } from "../opencodeRuntime.ts";16import type { OpenCodeInventory } from "../opencodeRuntime.ts";
1817
19const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";18const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
2019
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
21const runtimeMock = {29const 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});
92100
93const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) =>101const 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),106const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
99 );107 Schema.decodeSync(OpenCodeSettings)({
100108 enabled: true,
101it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {109 binaryPath: "opencode",
110 serverUrl: "",
111 serverPassword: "",
112 customModels: [],
113 ...overrides,
114 });
115
116it.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;
107121
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;
119132
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 );
125138
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 };
184170
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");
188173
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 );
202193
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;
207197
208 assert.equal(runtimeMock.state.closeCalls, 1);198 assert.equal(runtimeMock.state.closeCalls, 1);
209 }),199 }),
210 );200 );
211});201});
212202
213it.layer(203it.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 );
228214
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 );
245236
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 @@
1import type {1import {
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";
7import { Cause, Data, Effect, Equal, Layer, Stream } from "effect";7import { Cause, Data, Effect } from "effect";
88
9import { createModelCapabilities } from "@t3tools/shared/model";9import { createModelCapabilities } from "@t3tools/shared/model";
1010
11import { ServerConfig } from "../../config.ts";
12import { ServerSettingsService } from "../../serverSettings.ts";
13import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
14import {11import {
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";
20import { compareCliVersions } from "../cliVersion.ts";18import { compareCliVersions } from "../cliVersion.ts";
21import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
22import {19import {
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";
27import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";24import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2825
29const PROVIDER = "opencode" as const;26const PROVIDER = ProviderDriverKind.make("opencode");
30const OPENCODE_PRESENTATION = {27const 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}
253250
254const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => {251export 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
262261
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 }
282280
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};
298295
299export const OpenCodeProviderLive = Layer.effect(296export 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;
305302 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,
313310 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 }
452331
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 }
483404
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 }
487442
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 @@
1import type { ProviderKind } from "@t3tools/contracts";1import {
2 defaultInstanceIdForDriver,
3 ProviderDriverKind,
4 type ServerProvider,
5} from "@t3tools/contracts";
2import { it, assert, vi } from "@effect/vitest";6import { it, assert, vi } from "@effect/vitest";
3import { assertFailure } from "@effect/vitest/utils";
47
5import { Effect, Layer, Stream } from "effect";8import { Effect, Layer, PubSub, Stream } from "effect";
69
7import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
8import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";10import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
9import { CodexAdapter } from "../Services/CodexAdapter.ts";
10import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";11import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11import { CursorAdapter } from "../Services/CursorAdapter.ts";
12import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";12import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
13import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
14import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";13import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
15import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";14import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
15import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
16import type { ProviderInstance } from "../ProviderDriver.ts";
17import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts";
16import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";18import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
17import { ProviderUnsupportedError } from "../Errors.ts";
18import * as NodeServices from "@effect/platform-node/NodeServices";19import * as NodeServices from "@effect/platform-node/NodeServices";
1920
21const CODEX_DRIVER = ProviderDriverKind.make("codex");
22const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
23const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
24const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
25
20const fakeCodexAdapter: CodexAdapterShape = {26const 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};
3642
37const fakeClaudeAdapter: ClaudeAdapterShape = {43const 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};
5359
54const fakeOpenCodeAdapter: OpenCodeAdapterShape = {60const 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};
7076
71const fakeCursorAdapter: CursorAdapterShape = {77const 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};
8793
88const 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),99const 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
123const fakeInstances: ReadonlyArray<ProviderInstance> = [
124 makeFakeInstance("codex", fakeCodexAdapter),
125 makeFakeInstance("claudeAgent", fakeClaudeAdapter),
126 makeFakeInstance("opencode", fakeOpenCodeAdapter),
127 makeFakeInstance("cursor", fakeCursorAdapter),
128];
129
130const 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
141const layer = Layer.mergeAll(
142 Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer),
143 NodeServices.layer,
101);144);
102145
103layer("ProviderAdapterRegistryLive", (it) => {146it.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);
115151
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 );
120154
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-scoped5 * 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 ProviderAdapterRegistryLive16 * @module ProviderAdapterRegistryLive
9 */17 */
18import {
19 defaultInstanceIdForDriver,
20 ProviderInstanceId,
21 type ProviderDriverKind,
22} from "@t3tools/contracts";
10import { Effect, Layer } from "effect";23import { Effect, Layer } from "effect";
1124
12import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";25import { ProviderUnsupportedError } from "../Errors.ts";
13import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";26import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
14import {27import {
15 ProviderAdapterRegistry,28 ProviderAdapterRegistry,
16 type ProviderAdapterRegistryShape,29 type ProviderAdapterRegistryShape,
17} from "../Services/ProviderAdapterRegistry.ts";30} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";31
19import { CodexAdapter } from "../Services/CodexAdapter.ts";32const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () {
20import { CursorAdapter } from "../Services/CursorAdapter.ts";33 const registry = yield* ProviderInstanceRegistry;
21import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";34
22import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts";35 const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) =>
2336 registry.getInstance(instanceId).pipe(
24export interface ProviderAdapterRegistryLiveOptions {37 Effect.flatMap((instance) =>
25 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;38 instance === undefined
26}39 ? Effect.fail(
2740 new ProviderUnsupportedError({
28const 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 !== undefined46 );
34 ? options.adapters47
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,
4255 }),
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 );
5072
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 );
5389
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});
59101
...@@ -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.
109export { makeProviderAdapterRegistry };
110
111// Re-export for consumers that need the accessor shape. The service tag
112// itself lives in `Services/ProviderAdapterRegistry.ts`.
113export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
114// Re-export for consumers (including tests) that construct a
115// `ProviderInstanceId` before calling `getByInstance`.
116export { 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 */
30import { Context, Effect, Layer } from "effect";
31
32import { ServerConfig } from "../../config.ts";
33import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
34
35export 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 */
48export 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 */
58export 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 */
68export 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 */
44import {
45 defaultInstanceIdForDriver,
46 type ProviderInstanceConfig,
47 type ProviderInstanceConfigMap,
48 ServerSettings,
49} from "@t3tools/contracts";
50import { Effect, Layer, Stream } from "effect";
51
52import { ServerSettingsService } from "../../serverSettings.ts";
53import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts";
54import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
55import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts";
56import { 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 */
71export 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 */
115const 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 */
154export 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 */
25import { describe, expect, it } from "@effect/vitest";
26import * as NodeServices from "@effect/platform-node/NodeServices";
27import {
28 type ClaudeSettings,
29 type CodexSettings,
30 type CursorSettings,
31 type OpenCodeSettings,
32 ProviderDriverKind,
33 type ProviderInstanceConfigMap,
34 ProviderInstanceId,
35} from "@t3tools/contracts";
36import { Effect, Layer } from "effect";
37
38import { ServerConfig } from "../../config.ts";
39import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
40import { CodexDriver } from "../Drivers/CodexDriver.ts";
41import { CursorDriver } from "../Drivers/CursorDriver.ts";
42import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
43import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
44import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
45import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
46
47const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
48 enabled: false,
49 binaryPath: "codex",
50 homePath: "",
51 shadowHomePath: "",
52 customModels: [],
53 ...overrides,
54});
55
56const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => ({
57 enabled: false,
58 binaryPath: "claude",
59 homePath: "",
60 customModels: [],
61 launchArgs: "",
62 ...overrides,
63});
64
65const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => ({
66 enabled: false,
67 binaryPath: "agent",
68 apiEndpoint: "",
69 customModels: [],
70 ...overrides,
71});
72
73const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({
74 enabled: false,
75 binaryPath: "opencode",
76 serverUrl: "",
77 serverPassword: "",
78 customModels: [],
79 ...overrides,
80});
81
82describe("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
210describe("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 */
35import {
36 defaultInstanceIdForDriver,
37 ProviderInstanceId,
38 type ProviderInstanceConfig,
39 type ProviderInstanceConfigMap,
40 type ProviderDriverKind,
41 type ServerProvider,
42} from "@t3tools/contracts";
43import { Context, Effect, Equal, Exit, Layer, PubSub, Ref, Schema, Scope, Stream } from "effect";
44
45import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts";
46import {
47 ProviderInstanceRegistry,
48 type ProviderInstanceRegistryShape,
49} from "../Services/ProviderInstanceRegistry.ts";
50import {
51 ProviderInstanceRegistryMutator,
52 type ProviderInstanceRegistryMutatorShape,
53} from "../Services/ProviderInstanceRegistryMutator.ts";
54import 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 */
61interface 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 */
71interface 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 */
84const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean =>
85 Equal.equals(a, b);
86
87const 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 */
100const 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 */
202const 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 */
321export 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 */
405export 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 */
420export 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
434export { defaultInstanceIdForDriver };
apps/server/src/provider/Layers/ProviderRegistry.test.ts+681-169
...@@ -1,11 +1,16 @@...@@ -1,11 +1,16 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";2import { describe, it, assert, live } from "@effect/vitest";
3import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect";3import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect";
4import * as CodexErrors from "effect-codex-app-server/errors";4import * as CodexErrors from "effect-codex-app-server/errors";
5import {5import {
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";
11import * as PlatformError from "effect/PlatformError";16import * 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";
14import { createModelCapabilities } from "@t3tools/shared/model";19import { createModelCapabilities } from "@t3tools/shared/model";
1520
16import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";21import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
17import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";22import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
23import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
24import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
25import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
18import {26import {
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";
23import { ServerConfig } from "../../config.ts";31import { ServerConfig } from "../../config.ts";
24import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";32import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
33import type { ProviderInstance } from "../ProviderDriver.ts";
34import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
25import { ProviderRegistry } from "../Services/ProviderRegistry.ts";35import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
2636
37const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
38const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
39const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({
40 enabled: false,
41});
42
27process.env.T3CODE_CURSOR_ENABLED = "1";43process.env.T3CODE_CURSOR_ENABLED = "1";
2844
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}
5672
73type TestClaudeCapabilities = {
74 readonly email: string | undefined;
75 readonly subscriptionType: string | undefined;
76 readonly tokenSource: string | undefined;
77 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
78};
79
80function 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
91const noClaudeCapabilities = () =>
92 Effect.sync(() => undefined as TestClaudeCapabilities | undefined);
93
57function mockHandle(result: { stdout: string; stderr: string; code: number }) {94function 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}
72109
73function mockSpawnerLayer(110function 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}
84125
126function 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
85function mockCommandSpawnerLayer(153function 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()))(
226296
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()))(
268338
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()))(
288358
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()))(
342413
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()))(
381453
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 });
429502
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()))(
469846
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(
5491026 (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()))(
5641045
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()))(
6551130
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()))(
6831161
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 );
7081188
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 );
7221304
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 );
7631347
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()))(
7871371
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()))(
8121398
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()))(
8261414
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 );
8721438
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 ProviderRegistryLive23 * @module ProviderRegistryLive
5 */24 */
6import type { ProviderKind, ServerProvider } from "@t3tools/contracts";25import {
7import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";26 defaultInstanceIdForDriver,
27 ProviderDriverKind,
28 type ProviderInstanceId,
29 type ServerProvider,
30} from "@t3tools/contracts";
31import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";
32import * as Semaphore from "effect/Semaphore";
833
9import { ServerConfig } from "../../config.ts";34import { ServerConfig } from "../../config.ts";
10import { ClaudeProviderLive } from "./ClaudeProvider.ts";35import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
11import { CodexProviderLive } from "./CodexProvider.ts";
12import { CursorProviderLive } from "./CursorProvider.ts";
13import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
14import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
15import { CodexProvider } from "../Services/CodexProvider.ts";
16import { CursorProvider } from "../Services/CursorProvider.ts";
17import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
18import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";36import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
19import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
20import {37import {
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";
28import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts";45import type { ProviderInstance } from "../ProviderDriver.ts";
29import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";46import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
3047
31const loadProviders = (48const 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 );
3761
38const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>62const 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);
79103
80const ProviderRegistryLiveBase = Layer.effect(104const 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 */
130const 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.
139const 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
147export 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;
89154
90 const cursorProvider = yield* CursorProvider;155 // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for
91156 // 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 }
119179
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 === undefined188 // identity-less payloads are discarded and the awaited refresh
129 ? undefined189 // 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);
146230
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 });
157266
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 );
170281
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 }
177291
...@@ -181,10 +295,12 @@ const ProviderRegistryLiveBase = Layer.effect(...@@ -181,10 +295,12 @@ const ProviderRegistryLiveBase = Layer.effect(
181 );295 );
182296
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 });
204320
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 });
225332
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 );
240490
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
255export 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 {
12import {12import {
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 {
20import { createModelSelection } from "@t3tools/shared/model";21import { createModelSelection } from "@t3tools/shared/model";
21import { it, assert, vi } from "@effect/vitest";22import { it, assert, vi } from "@effect/vitest";
2223
23import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect";24import { Effect, Exit, Fiber, Layer, Metric, Option, PubSub, Ref, Scope, Stream } from "effect";
24import * as SqlClient from "effect/unstable/sql/SqlClient";25import * as SqlClient from "effect/unstable/sql/SqlClient";
2526
26import {27import {
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";
32import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";34import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
33import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";35import {
36 ProviderAdapterRegistry,
37 type ProviderAdapterRegistryShape,
38} from "../Services/ProviderAdapterRegistry.ts";
34import { ProviderService } from "../Services/ProviderService.ts";39import { ProviderService } from "../Services/ProviderService.ts";
35import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";40import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
36import { makeProviderServiceLive } from "./ProviderService.ts";41import { makeProviderServiceLive } from "./ProviderService.ts";
42import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
37import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";43import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
38import * as NodeServices from "@effect/platform-node/NodeServices";44import * as NodeServices from "@effect/platform-node/NodeServices";
39import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";45import { 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";
45import { ServerSettingsService } from "../../serverSettings.ts";51import { ServerSettingsService } from "../../serverSettings.ts";
46import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";52import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
53import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
4754
48const defaultServerSettingsLayer = ServerSettingsService.layerTest();55const defaultServerSettingsLayer = ServerSettingsService.layerTest();
4956
...@@ -51,11 +58,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make...@@ -51,11 +58,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make
51const asEventId = (value: string): EventId => EventId.make(value);58const asEventId = (value: string): EventId => EventId.make(value);
52const asThreadId = (value: string): ThreadId => ThreadId.make(value);59const asThreadId = (value: string): ThreadId => ThreadId.make(value);
53const asTurnId = (value: string): TurnId => TurnId.make(value);60const asTurnId = (value: string): TurnId => TurnId.make(value);
61const codexInstanceId = ProviderInstanceId.make("codex");
62const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent");
63const CODEX_DRIVER = ProviderDriverKind.make("codex");
64const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
65const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
5466
55type LegacyProviderRuntimeEvent = {67type 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};
6779
68function makeFakeCodexAdapter(provider: ProviderKind = "codex") {80function 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>());
7183
...@@ -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 = (
245262
246function makeProviderServiceLayer() {263function 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 };
261272
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,
277289
...@@ -288,27 +300,76 @@ function makeProviderServiceLayer() {...@@ -288,27 +300,76 @@ function makeProviderServiceLayer() {
288 };300 };
289}301}
290302
303it.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
291it.effect("ProviderServiceLive rejects new sessions for disabled providers", () =>348it.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 );
322384
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 );
333396
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);
339402
403it.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
476it.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
340const routing = makeProviderServiceLayer();541const routing = makeProviderServiceLayer();
341542
342it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () =>543it.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 );
374572
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");
401599
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 };
410604
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 );
431627
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", (
441637
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);
447645
...@@ -471,13 +669,9 @@ it.effect(...@@ -471,13 +669,9 @@ it.effect(
471 );669 );
472670
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 };
481675
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(
515711
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 }
525723
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 );
543738
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;
583778
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;
668864
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;
709906
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;
759957
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 );
778982
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");
7831018
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();
7931029
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;
8191056
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;
8591097
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 });
8691110
...@@ -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;
9071150
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) => {
9311176
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 );
9791225
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 );
9971240
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));
10121256
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 );
10301271
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 );
10731315
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 );
10911330
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));
11011341
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 );
11191356
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 );
11911437
...@@ -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;
13211569
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) => {
13491598
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;
13981647
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) => {
14131663
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) => {
14321682
1433const validation = makeProviderServiceLayer();1683const validation = makeProviderServiceLayer();
1434validation.layer("ProviderServiceLive validation", (it) => {1684validation.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 );
14791773
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";
25import { Effect, Layer, Option, PubSub, Schema, SchemaIssue, Stream } from "effect";27import { Cause, Effect, Layer, Option, PubSub, Ref, Schema, SchemaIssue, Stream } from "effect";
2628
27import {29import {
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";
37import { ProviderValidationError } from "../Errors.ts";39import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
40import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
38import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";41import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
39import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";42import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
40import {43import {
41 ProviderSessionDirectory,44 ProviderSessionDirectory,
42 type ProviderRuntimeBinding,45 type ProviderRuntimeBinding,
43} from "../Services/ProviderSessionDirectory.ts";46} from "../Services/ProviderSessionDirectory.ts";
44import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";47import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
48import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";
45import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";49import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
46import { ServerSettingsService } from "../../serverSettings.ts";
4750
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 */
48export interface ProviderServiceLiveOptions {56export interface ProviderServiceLiveOptions {
49 readonly canonicalEventLogPath?: string;
50 readonly canonicalEventLogger?: EventNdjsonLogger;57 readonly canonicalEventLogger?: EventNdjsonLogger;
51}58}
5259
...@@ -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}
143150
151const 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
168const 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
144const makeProviderService = Effect.fn("makeProviderService")(function* (188const 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 !== undefined195 // 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);
156198
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 );
171213
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 });
189256
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 );
197284
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);
201327
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 }
256388
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);
288424
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 }
293434
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 }
297443
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 });
301455
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.currentProvider464 instanceId === input.currentInstanceId
310 ? Effect.void465 ? 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 });
343498
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.provider534 (persistedBinding?.providerInstanceId === resolvedInstanceId
375 ? persistedBinding.resumeCursor535 ? persistedBinding.resumeCursor
376 : undefined);536 : undefined);
377 const effectiveCwd =537 const effectiveCwd =
378 input.cwd ??538 input.cwd ??
379 (persistedBinding?.provider === input.provider539 (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 !== undefined545 input.resumeCursor !== undefined
385 ? "request"546 ? "request"
386 : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider547 : 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 !== undefined553 input.cwd !== undefined
392 ? "request"554 ? "request"
393 : effectiveCwd !== undefined && persistedBinding?.provider === input.provider555 : 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 };
411579
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 });
428596
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* (
669840
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 }
696875
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 }
702883
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 );
717918
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);
720924
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* (
761965
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 });
7951006
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 );
8011014
...@@ -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 multiple1026 // Each access creates a fresh PubSub subscription so that multiple
813 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each1027 // 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";
3import path from "node:path";3import path from "node:path";
44
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ThreadId } from "@t3tools/contracts";6import { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
7import { it, assert } from "@effect/vitest";7import { it, assert } from "@effect/vitest";
8import { assertSome } from "@effect/vitest/utils";8import { assertSome } from "@effect/vitest/utils";
9import { Effect, Layer, Option } from "effect";9import { 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");
3939
40 yield* directory.upsert({40 yield* directory.upsert({
41 provider: "codex",41 provider: ProviderDriverKind.make("codex"),
42 threadId: initialThreadId,42 threadId: initialThreadId,
43 });43 });
4444
...@@ -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");
5757
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");
8686
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 });
9999
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 });
213216
214 yield* directory.upsert({217 yield* directory.upsert({
215 provider: "codex",218 provider: ProviderDriverKind.make("codex"),
216 threadId,219 threadId,
217 });220 });
218221
...@@ -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 @@
1import { ProviderKind, type ThreadId } from "@t3tools/contracts";1import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option, Schema } from "effect";2import { Effect, Layer, Option, Schema } from "effect";
33
4import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";4import 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}
2222
23function decodeProviderKind(23function 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* repository124 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";2import {
3 ProjectId,
4 ThreadId,
5 TurnId,
6 ProviderDriverKind,
7 ProviderInstanceId,
8} from "@t3tools/contracts";
3import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";9import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";
4import { afterEach, describe, expect, it, vi } from "vitest";10import { afterEach, describe, expect, it, vi } from "vitest";
511
...@@ -17,7 +23,7 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";...@@ -17,7 +23,7 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
17import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts";23import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts";
1824
19const defaultModelSelection = {25const 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;
2329
...@@ -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 @@
1import { it } from "@effect/vitest";
2import { Cause, Effect, Exit } from "effect";
3import { describe, expect } from "vitest";
4
5import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
6
7describe("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 */
37import { 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 */
49export 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 */
24import type {
25 ProviderDriverKind,
26 ProviderInstanceEnvironment,
27 ProviderInstanceId,
28} from "@t3tools/contracts";
29import type { Effect, Schema, Scope } from "effect";
30
31import type { TextGenerationShape } from "../git/Services/TextGeneration.ts";
32import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";
33import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
34import 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 */
41export 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 */
62export 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
74export interface ProviderContinuationIdentity {
75 readonly driverKind: ProviderDriverKind;
76 readonly continuationKey: string;
77}
78
79export 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 */
95export 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 */
117export 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.
167export type AnyProviderDriver<R = never> = ProviderDriver<any, R>;
apps/server/src/provider/ProviderInstanceEnvironment.test.ts created+21
...@@ -0,0 +1,21 @@
1import { describe, expect, it } from "vitest";
2
3import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
4
5describe("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 @@
1import type { ProviderInstanceEnvironment } from "@t3tools/contracts";
2
3export 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 canonical4 * Historically this module exposed a `Context.Service` tag so consumers
5 * provider runtime events. It does not perform cross-provider routing, shared5 * 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 the8 * 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 ClaudeAdapter10 * @module ClaudeAdapter
12 */11 */
13import { Context } from "effect";
14
15import type { ProviderAdapterError } from "../Errors.ts";12import type { ProviderAdapterError } from "../Errors.ts";
16import type { ProviderAdapterShape } from "./ProviderAdapter.ts";13import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
1714
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.
21export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "claudeAgent";
23}
24
25/**
26 * ClaudeAdapter - Service tag for Claude Agent provider adapter operations.
27 */18 */
28export class ClaudeAdapter extends Context.Service<ClaudeAdapter, ClaudeAdapterShape>()(19export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
29 "t3/provider/Services/ClaudeAdapter",
30) {}
apps/server/src/provider/Services/ClaudeProvider.ts deleted-9
...@@ -1,9 +0,0 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface ClaudeProviderShape extends ServerProviderShape {}
6
7export 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 emits4 * Historically this module exposed a `Context.Service` tag so consumers
5 * Codex provider events. It does not perform cross-provider routing, shared5 * 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 the8 * 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 CodexAdapter10 * @module CodexAdapter
12 */11 */
13import { Context } from "effect";
14
15import type { ProviderAdapterError } from "../Errors.ts";12import type { ProviderAdapterError } from "../Errors.ts";
16import type { ProviderAdapterShape } from "./ProviderAdapter.ts";13import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
1714
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.
21export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "codex";
23}
24
25/**
26 * CodexAdapter - Service tag for Codex provider adapter operations.
27 */18 */
28export class CodexAdapter extends Context.Service<CodexAdapter, CodexAdapterShape>()(19export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
29 "t3/provider/Services/CodexAdapter",
30) {}
apps/server/src/provider/Services/CodexProvider.ts deleted-9
...@@ -1,9 +0,0 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface CodexProviderShape extends ServerProviderShape {}
6
7export 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 @@
1import { Context } from "effect";1/**
22 * 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 */
3import type { ProviderAdapterError } from "../Errors.ts";12import type { ProviderAdapterError } from "../Errors.ts";
4import type { ProviderAdapterShape } from "./ProviderAdapter.ts";13import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
514
6export 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.
918 */
10export class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()(19export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
11 "t3/provider/Services/CursorAdapter",
12) {}
apps/server/src/provider/Services/CursorProvider.ts deleted-9
...@@ -1,9 +0,0 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface CursorProviderShape extends ServerProviderShape {}
6
7export 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 @@
1import { Context } from "effect";1/**
22 * 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 */
3import type { ProviderAdapterError } from "../Errors.ts";12import type { ProviderAdapterError } from "../Errors.ts";
4import type { ProviderAdapterShape } from "./ProviderAdapter.ts";13import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
514
6export 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.
918 */
10export class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(19export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
11 "t3/provider/Services/OpenCodeAdapter",
12) {}
apps/server/src/provider/Services/OpenCodeProvider.ts deleted-9
...@@ -1,9 +0,0 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface OpenCodeProviderShape extends ServerProviderShape {}
6
7export 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 @@
10import type {10import 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;
5151
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` uses5 * `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 ProviderAdapterRegistry20 * @module ProviderAdapterRegistry
9 */21 */
10import type { ProviderKind } from "@t3tools/contracts";22import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
11import { Context } from "effect";23import { Context } from "effect";
12import type { Effect } from "effect";24import type { Effect, PubSub, Scope, Stream } from "effect";
1325
14import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts";26import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts";
15import type { ProviderAdapterShape } from "./ProviderAdapter.ts";27import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
28import type { ProviderContinuationIdentity } from "../ProviderDriver.ts";
29
30export 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}
1638
17/**39/**
18 * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind.40 * ProviderAdapterRegistryShape - Service API for adapter lookup.
19 */41 */
20export interface ProviderAdapterRegistryShape {42export 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>;
2753
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}
3390
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 ProviderAdapterRegistryShape96 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 */
20import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
21import { Context } from "effect";
22import type { Effect, PubSub, Scope, Stream } from "effect";
23
24import type { ProviderInstance } from "../ProviderDriver.ts";
25
26export 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
81export 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 */
31import type { ProviderInstanceConfigMap } from "@t3tools/contracts";
32import { Context } from "effect";
33import type { Effect } from "effect";
34
35export 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
49export 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 ProviderRegistry7 * @module ProviderRegistry
8 */8 */
9import type { ProviderKind, ServerProvider } from "@t3tools/contracts";9import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts";
10import { Context } from "effect";10import { Context } from "effect";
11import type { Effect, Stream } from "effect";11import type { Effect, Stream } from "effect";
1212
13export interface ProviderRegistryShape {13export 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>>;
1820
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>>;
2331
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 */
14import type {14import 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";
2929
30import type { ProviderServiceError } from "../Errors.ts";30import type { ProviderServiceError } from "../Errors.ts";
31import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts";31import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts";
32import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts";
3233
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>>;
8687
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>;
9394
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 @@
1import type {1import 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 {
1415
15export interface ProviderRuntimeBinding {16export 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 {
3946
40 readonly getProvider: (47 readonly getProvider: (
41 threadId: ThreadId,48 threadId: ThreadId,
42 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;49 ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>;
4350
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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import * as EffectAcpErrors from "effect-acp/errors";2import * as EffectAcpErrors from "effect-acp/errors";
3import { ProviderDriverKind } from "@t3tools/contracts";
34
4import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts";5import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts";
56
...@@ -12,7 +13,7 @@ describe("AcpAdapterSupport", () => {...@@ -12,7 +13,7 @@ describe("AcpAdapterSupport", () => {
1213
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 @@
1import {1import {
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";
6import { Schema } from "effect";6import { Schema } from "effect";
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13} from "../Errors.ts";13} from "../Errors.ts";
1414
15export function mapAcpToAdapterError(15export 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 @@
1import { RuntimeRequestId, TurnId } from "@t3tools/contracts";1import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import {
...@@ -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(
7878
79export function makeAcpRequestOpenedEvent(input: {79export 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: {
111111
112export function makeAcpRequestResolvedEvent(input: {112export 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: {
134134
135export function makeAcpPlanUpdatedEvent(input: {135export 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: {
159159
160export function makeAcpToolCallEvent(input: {160export 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: {
193193
194export function makeAcpAssistantItemEvent(input: {194export 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: {
215215
216export function makeAcpContentDeltaEvent(input: {216export 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 @@
1import type { ProviderKind, ThreadId } from "@t3tools/contracts";1import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
2import { Cause, Effect } from "effect";2import { Cause, Effect } from "effect";
3import type * as EffectAcpProtocol from "effect-acp/protocol";3import type * as EffectAcpProtocol from "effect-acp/protocol";
44
...@@ -7,7 +7,7 @@ import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpS...@@ -7,7 +7,7 @@ import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpS
77
8function writeNativeAcpLog(input: {8function 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) {
4444
45export function makeAcpNativeLoggers(input: {45export 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}
2626
27export interface AcpSessionRuntimeOptions {27export 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}
2728
28export interface CursorAcpModelSelectionErrorContext {29export interface CursorAcpModelSelectionErrorContext {
...@@ -34,6 +35,7 @@ export interface CursorAcpModelSelectionErrorContext {...@@ -34,6 +35,7 @@ export interface CursorAcpModelSelectionErrorContext {
34export function buildCursorAcpSpawnInput(35export 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}
4750
...@@ -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 */
23import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
24import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
26import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
27import 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 */
34export 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 */
45export 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 @@
1import type { ProviderKind, ServerProvider } from "@t3tools/contracts";1import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
2import type { Stream } from "effect";2import type { Stream } from "effect";
3import type { ProviderAdapterError } from "./Errors.ts";
4import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
5import type { ServerProviderShape } from "./Services/ServerProvider.ts";3import type { ServerProviderShape } from "./Services/ServerProvider.ts";
64
7export type ProviderSnapshotSource = {5export 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
14type BuiltInProviderServiceMap = Record<ProviderKind, ServerProviderShape>;
15type BuiltInAdapterMap = {
16 readonly codex: ProviderAdapterShape<ProviderAdapterError>;
17 readonly claudeAgent: ProviderAdapterShape<ProviderAdapterError>;
18 readonly opencode: ProviderAdapterShape<ProviderAdapterError>;
19 readonly cursor?: ProviderAdapterShape<ProviderAdapterError>;
20};
21
22export const BUILT_IN_PROVIDER_ORDER = [
23 "codex",
24 "claudeAgent",
25 "opencode",
26 "cursor",
27] as const satisfies ReadonlyArray<ProviderKind>;
28
29export 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
40export 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 @@
1import { describe, it, assert } from "@effect/vitest";1import { describe, it, assert } from "@effect/vitest";
2import type { ServerProvider } from "@t3tools/contracts";2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";3import { createModelCapabilities } from "@t3tools/shared/model";
4import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";4import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";
55
...@@ -21,7 +21,8 @@ interface TestSettings {...@@ -21,7 +21,8 @@ interface TestSettings {
21}21}
2222
23const initialSnapshot: ServerProvider = {23const 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};
3637
37const refreshedSnapshot: ServerProvider = {38const 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* () {
472475
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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { ModelCapabilities } from "@t3tools/contracts";2import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";3import { createModelCapabilities } from "@t3tools/shared/model";
44
5import { providerModelsFromSettings } from "./providerSnapshot.ts";5import { 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 @@
1import type {1import 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}
3839
40export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
41
39export function nonEmptyTrimmed(value: string | undefined): string | undefined {42export 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 {
110113
111export function providerModelsFromSettings(114export 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}
180183
181export function buildServerProvider(input: {184export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import type { ServerProvider } from "@t3tools/contracts";2import {
3 defaultInstanceIdForDriver,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";8import { createModelCapabilities } from "@t3tools/shared/model";
4import { assert, it } from "@effect/vitest";9import { assert, it } from "@effect/vitest";
5import { Effect, FileSystem } from "effect";10import { Effect, FileSystem } from "effect";
611
7import {12import {
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";
1319
14const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });20const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
21const CODEX_DRIVER = ProviderDriverKind.make("codex");
22const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
23const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
1524
16const makeProvider = (25const 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 });
5969
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 );
7888
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 });
139149
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 @@
1import * as nodePath from "node:path";1import {
2import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";2 type ProviderDriverKind,
3import { Cause, Effect, FileSystem, Schema } from "effect";3 type ProviderInstanceId,
4 type ServerProvider,
5 ServerProvider as ServerProviderSchema,
6} from "@t3tools/contracts";
7import { Cause, Effect, FileSystem, Path, Schema } from "effect";
48
5import { writeFileStringAtomically } from "../atomicWrite.ts";9import { writeFileStringAtomically } from "../atomicWrite.ts";
610
7export const PROVIDER_CACHE_IDS = [
8 "codex",
9 "claudeAgent",
10 "opencode",
11 "cursor",
12] as const satisfies ReadonlyArray<ServerProvider["provider"]>;
13
14const decodeProviderStatusCache = Schema.decodeUnknownEffect(11const decodeProviderStatusCache = Schema.decodeUnknownEffect(
15 Schema.fromJsonString(ServerProviderSchema),12 Schema.fromJsonString(ServerProviderSchema),
16);13);
1714
18const providerOrderRank = (provider: ServerProvider["provider"]): number => {
19 const rank = PROVIDER_CACHE_IDS.indexOf(provider);
20 return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
21};
22
23const mergeProviderModels = (15const 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 );
3732
33export 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
38export const hydrateCachedProvider = (input: {40export 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.enabled50 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};
6672
67export 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 */
87export 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 */
104export 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});
71113
72export const readProviderStatusCache = (filePath: string) =>114export 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 */
16import {
17 defaultInstanceIdForDriver,
18 ProviderDriverKind,
19 type ProviderInstanceId,
20} from "@t3tools/contracts";
21import { Effect, PubSub, Record, Result, Stream } from "effect";
22
23import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";
24import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
25import type { ProviderAdapterRegistryShape } from "../Services/ProviderAdapterRegistry.ts";
26
27export 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 */
37export 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 */
13import {
14 ProviderDriverKind,
15 type ProviderInstanceId,
16 type ServerProvider,
17} from "@t3tools/contracts";
18
19import { buildServerProvider } from "./providerSnapshot.ts";
20
21export 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 */
40export 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");
110const defaultThreadId = ThreadId.make("thread-default");112const defaultThreadId = ThreadId.make("thread-default");
111const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token";113const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token";
112const defaultModelSelection = {114const 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;
116const testEnvironmentDescriptor = {118const 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";
16import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";16import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
17import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";17import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
18import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";18import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";
19import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts";
20import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";19import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
21import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";20import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";
22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";
23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";
24import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts";
25import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";
26import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";21import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
27import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";22import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts";
23import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
28import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";24import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
25import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
29import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
30import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
31import { GitCoreLive } from "./git/Layers/GitCore.ts";28import { GitCoreLive } from "./git/Layers/GitCore.ts";
32import { GitHubCliLive } from "./git/Layers/GitHubCli.ts";29import { GitHubCliLive } from "./git/Layers/GitHubCli.ts";
33import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts";30import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts";
34import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration.ts";31import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts";
32import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
35import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";33import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
36import { GitManagerLive } from "./git/Layers/GitManager.ts";34import { GitManagerLive } from "./git/Layers/GitManager.ts";
37import { KeybindingsLive } from "./keybindings.ts";35import { 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);
146144
147const 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, {151const 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);
183155
184const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));156const 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);
192164
193const GitLayerLive = Layer.empty.pipe(165const 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts";2import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
4import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect";4import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect";
55
...@@ -21,8 +21,8 @@ import {...@@ -21,8 +21,8 @@ import {
2121
22it("uses the canonical Codex default for auto-bootstrapped model selection", () => {22it("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});
2828
apps/server/src/serverRuntimeStartup.ts+4-3
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import {1import {
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";
9import {10import {
...@@ -154,8 +155,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(...@@ -154,8 +155,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(
154);155);
155156
156export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({157export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
157 provider: "codex",158 instanceId: ProviderInstanceId.make("codex"),
158 model: DEFAULT_MODEL_BY_PROVIDER.codex,159 model: DEFAULT_MODEL,
159});160});
160161
161export const resolveWelcomeBase = Effect.gen(function* () {162export const resolveWelcomeBase = Effect.gen(function* () {
apps/server/src/serverSettings.test.ts+222-20
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts";2import {
3 DEFAULT_SERVER_SETTINGS,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 ServerSettings,
7 ServerSettingsPatch,
8} from "@t3tools/contracts";
3import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
4import { assert, it } from "@effect/vitest";10import { assert, it } from "@effect/vitest";
5import { Effect, FileSystem, Layer, Schema } from "effect";11import { 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) => {
4955
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 });
5763
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 selection144 // 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 });
142156
...@@ -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) => {
154168
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 );
161177
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;
165265
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) => {
180280
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 });
187287
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 );
194294
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 ServerSettings11 * @module ServerSettings
12 */12 */
13import {13import {
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";
44import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";49import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
45import { fromLenientJson } from "@t3tools/shared/schemaJson";50import { fromLenientJson } from "@t3tools/shared/schemaJson";
46import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";51import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
52import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
53import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
54
55const textEncoder = new TextEncoder();
56const textDecoder = new TextDecoder();
57
58function 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
65function 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
79export 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}
4793
48export interface ServerSettingsShape {94export 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<
106152
107const ServerSettingsJson = fromLenientJson(ServerSettings);153const ServerSettingsJson = fromLenientJson(ServerSettings);
108154
109const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"];155type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
156
157const getLegacyProviderSettings = (
158 settings: ServerSettings,
159 provider: ProviderDriverKind,
160): LegacyProviderSettings | undefined =>
161 (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider];
110162
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 */
117function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {169function 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 }
122182
123 const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled);183 return fallbackTextGenerationProvider(settings);
184}
185
186function 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 }
128192
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}
137203
...@@ -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* () {
233300
234 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);301 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
235302
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) ?? {};
238437
...@@ -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});
355571
356export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings);572export 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 {
45import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";45import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
46import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";46import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
47import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";47import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
48import { ServerSettingsService } from "./serverSettings.ts";48import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
49import { TerminalManager } from "./terminal/Services/Manager.ts";49import { TerminalManager } from "./terminal/Services/Manager.ts";
50import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";50import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
51import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";51import { 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();
518518
...@@ -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 );
985997
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);
9931001
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}
885888
886function createSnapshotWithPlanFollowUpPrompt(options?: {889function 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 });
24402443
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 });
37883915
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 });
38393970
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)", () => {
38603991
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 });
39134052
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)", () => {
39484087
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_ID5735 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_ID5745 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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ThreadId,
8 TurnId,
9} from "@t3tools/contracts";
3import { afterEach, describe, expect, it, vi } from "vitest";10import { afterEach, describe, expect, it, vi } from "vitest";
4import { type EnvironmentState, useStore } from "../store";11import { type EnvironmentState, useStore } from "../store";
5import { type Thread } from "../types";12import { 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 };
453460
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 @@
1import {1import {
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}
228229
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.
229export function deriveLockedProvider(input: {242export 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}
239264
240export async function waitForStartedServerThread(265export async function waitForStartedServerThread(
apps/web/src/components/ChatView.tsx+65-24
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1import {1import {
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 {
115import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";117import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
116import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";118import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
117import { useSettings } from "../hooks/useSettings";119import { useSettings } from "../hooks/useSettings";
118import { resolveAppModelSelection } from "../modelSelection";120import { resolveAppModelSelectionForInstance } from "../modelSelection";
119import { isTerminalFocused } from "../lib/terminalFocus";121import { isTerminalFocused } from "../lib/terminalFocus";
120import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";122import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
121import {123import {
...@@ -302,7 +304,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog...@@ -302,7 +304,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog
302}304}
303305
304function formatOutgoingPrompt(params: {306function 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) {
10311033
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 );
25682584
...@@ -3128,21 +3144,46 @@ export default function ChatView(props: ChatViewProps) {...@@ -3128,21 +3144,46 @@ export default function ChatView(props: ChatViewProps) {
3128 ]);3144 ]);
31293145
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 @@
1import { describe, expect, it, vi } from "vitest";1import { describe, expect, it, vi } from "vitest";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import type { Thread } from "../types";3import type { Thread } from "../types";
4import {4import {
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 @@
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
4import {4import {
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";
10import { useQuery, useQueryClient } from "@tanstack/react-query";11import { useQuery, useQueryClient } from "@tanstack/react-query";
11import { useNavigate, useParams } from "@tanstack/react-router";12import { 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
592export 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
603export 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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
23
3import {4import {
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";
23import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";24import {
25 EnvironmentId,
26 OrchestrationLatestTurn,
27 ProjectId,
28 ProviderInstanceId,
29 ThreadId,
30} from "@t3tools/contracts";
24import {31import {
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";
16import {15import {
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";
97import { proposedPlanTitle } from "../../proposedPlan";98import { proposedPlanTitle } from "../../proposedPlan";
99import { getProviderInteractionModeToggle } from "../../providerModels";
98import {100import {
99 getProviderInteractionModeToggle,101 deriveProviderInstanceEntries,
100 getProviderModels,102 resolveProviderDriverKindForInstanceSelection,
101 resolveSelectableProvider,103 sortProviderInstanceEntries,
102} from "../../providerModels";104 type ProviderInstanceEntry,
105} from "../../providerInstances";
106import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
103import type { UnifiedSettings } from "@t3tools/contracts/settings";107import type { UnifiedSettings } from "@t3tools/contracts/settings";
104import type { SessionPhase, Thread } from "../../types";108import type { SessionPhase, Thread } from "../../types";
105import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";109import 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;
407411
408 // Provider / model412 // 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;
451455
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 state571 // 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 ]);
572610
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 ]);
578676
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 });
587686
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 );
593702
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 ? selectedModelForPicker748 ? selectedModelForPicker
639 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);749 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
640 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);750 }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]);
641751
642 // ------------------------------------------------------------------752 // ------------------------------------------------------------------
643 // Context window753 // 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 <ProviderModelPicker2000 <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.modelPickerIconClassName2011 {...(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 />
19112022
1912 {isComposerFooterCompact ? (2023 {isComposerFooterCompact ? (
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+36-16
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import {1import {
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";
7import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";10import { 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;
5560
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", () => {
178183
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 });
183191
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", () => {
193201
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 });
198209
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", () => {
205216
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 });
210224
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", () => {
222236
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 });
229245
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", () => {
239255
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 });
247265
...@@ -256,9 +274,11 @@ describe("CompactComposerControlsMenu", () => {...@@ -256,9 +274,11 @@ describe("CompactComposerControlsMenu", () => {
256274
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 });
264284
apps/web/src/components/chat/ComposerCommandMenu.tsx+3-3
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import {1import {
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 @@
1import { type ProviderKind } from "@t3tools/contracts";1import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { StarIcon } from "lucide-react";3import { StarIcon } from "lucide-react";
4import {4import {
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";
16export const ModelListRow = memo(function ModelListRow(props: {15export 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;
2941
30 return (42 return (
31 <ComboboxItem43 <ComboboxItem
32 hideIndicator44 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 @@
1import {1import {
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";
7import { resolveSelectableModel } from "@t3tools/shared/model";6import { resolveSelectableModel } from "@t3tools/shared/model";
8import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";7import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
...@@ -22,40 +21,89 @@ import {...@@ -22,40 +21,89 @@ import {
22import { useSettings, useUpdateSettings } from "~/hooks/useSettings";21import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
23import { cn } from "~/lib/utils";22import { cn } from "~/lib/utils";
24import { TooltipProvider } from "../ui/tooltip";23import { TooltipProvider } from "../ui/tooltip";
24import type { ProviderInstanceEntry } from "../../providerInstances";
25import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";
2526
26type ModelPickerItem = {27type 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};
3338
34const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();39const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
3540
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.
44function 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
36export const ModelPickerContent = memo(function ModelPickerContent(props: {55export 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 }, []);
68116
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]);
92140
93 // Create a Set for efficient lookup141 // 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]);
102149
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.providers177 }, [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
113182 // 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 );
129230
130 // Filter models based on search query and selected provider231 // 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;
133234
...@@ -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 );
159270
160 // When searching, we only respect locked provider, ignoring sidebar selection271 // 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 rankedMatches275 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 }
190303
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 }
199314
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",
203318 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 ]);
223330
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 );
237351
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 );
251365
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 };
339467
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 <div520 <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 )}
407533
408 {/* Sidebar (only in unlocked mode) */}534 {/* Sidebar (only in unlocked mode) */}
409 {showSidebar && (535 {showSidebar && (
410 <ModelPickerSidebar536 <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 )}
416544
...@@ -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 autoHighlight551 autoHighlight
424 open552 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 <div565 <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 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";1import { type ProviderInstanceId } from "@t3tools/contracts";
2import { memo } from "react";2import { memo, useMemo } from "react";
3import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";3import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";
4import { Gemini, GithubCopilotIcon } from "../Icons";4import { Gemini, GithubCopilotIcon } from "../Icons";
5import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";5import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
6import { ScrollArea } from "../ui/scroll-area";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
8import { getProviderSnapshot } from "../../providerModels";9import type { ProviderInstanceEntry } from "../../providerInstances";
910
10function 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 */
16function 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}
2832
...@@ -39,172 +43,221 @@ const PICKER_TOOLTIP_SIDE = "left" as const;...@@ -39,172 +43,221 @@ const PICKER_TOOLTIP_SIDE = "left" as const;
39const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";43const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
4044
41export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {45export 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]);
4978
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 <TooltipTrigger86 <div className="flex min-h-full flex-col gap-1 p-1">
58 render={87 {/* Favorites section */}
59 <button88 {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 <TooltipPopup120 </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>
83122
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.providers126 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;
90130
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;
94136
95 const providerTooltip = isDisabled137 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 );
100172
101 const button = (173 const trigger = isDisabled ? (
102 <button174 <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 );
132178
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 button182 <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 })}
138195
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 <TooltipPopup220 <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
155231 render={
156 {/* Gemini button (coming soon) */}232 <span className="relative block w-full">
157 <Tooltip>233 <button
158 <TooltipTrigger234 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 <button237 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 disabled241 >
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 @@
1import { type CSSProperties, memo } from "react";
2import { type ProviderDriverKind } from "@t3tools/contracts";
3
4import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
5import { cn } from "~/lib/utils";
6
7export 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
17export 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 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";3import { createModelCapabilities } from "@t3tools/shared/model";
4import { page, userEvent } from "vitest/browser";4import { 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";
6import { render } from "vitest-browser-react";6import { render } from "vitest-browser-react";
77
8import { ProviderModelPicker } from "./ProviderModelPicker";8import { ProviderModelPicker } from "./ProviderModelPicker";
9import { getCustomModelOptionsByProvider } from "../../modelSelection";9import { getCustomModelOptionsByInstance } from "../../modelSelection";
10import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";10import {
11 deriveProviderInstanceEntries,
12 sortProviderInstanceEntries,
13} from "../../providerInstances";
14import type { ModelEsque } from "./providerIconUtils";
15import {
16 DEFAULT_CLIENT_SETTINGS,
17 DEFAULT_UNIFIED_SETTINGS,
18 type UnifiedSettings,
19} from "@t3tools/contracts/settings";
11import { __resetLocalApiForTests } from "../../localApi";20import { __resetLocalApiForTests } from "../../localApi";
1221
13// Mock the environments/runtime module to provide a mock primary environment connection22// 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) {
93102
94const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [103const 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];
201212
213const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex");
214const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent");
215const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
216
202function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {217function 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 {
217233
218function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider {234function 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}
232249
233async function mountPicker(props: {250async 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 <ProviderModelPicker272 <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 );
262284
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", () => {
304331
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", () => {
325352
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", () => {
347374
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 });
381408
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 );
420475
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 });
426481
427 try {482 try {
...@@ -443,27 +498,115 @@ describe("ProviderModelPicker", () => {...@@ -443,27 +498,115 @@ describe("ProviderModelPicker", () => {
443 }498 }
444 });499 });
445500
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 <ProviderModelPicker603 <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 });
512655
...@@ -531,7 +674,7 @@ describe("ProviderModelPicker", () => {...@@ -531,7 +674,7 @@ describe("ProviderModelPicker", () => {
531674
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", () => {
561704
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 });
568711
569 try {712 try {
...@@ -600,7 +743,7 @@ describe("ProviderModelPicker", () => {...@@ -600,7 +743,7 @@ describe("ProviderModelPicker", () => {
600743
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", () => {
624767
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", () => {
652795
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");
806949
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");
851994
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 );
8911034
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", () => {
9101052
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 });
9171059
918 try {1060 try {
...@@ -995,7 +1137,6 @@ describe("ProviderModelPicker", () => {...@@ -995,7 +1137,6 @@ describe("ProviderModelPicker", () => {
995 ];1137 ];
9961138
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 }
10151156
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 }
10471187
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", () => {
10681207
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 @@
1import {1import {
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";
6import { memo, useEffect, useState } from "react";6import { memo, useEffect, useMemo, useState } from "react";
7import type { VariantProps } from "class-variance-authority";7import type { VariantProps } from "class-variance-authority";
8import { ChevronDownIcon } from "lucide-react";8import { ChevronDownIcon } from "lucide-react";
9import { Button, buttonVariants } from "../ui/button";9import { 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";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import { cn } from "~/lib/utils";12import { cn } from "~/lib/utils";
13import { ModelPickerContent } from "./ModelPickerContent";13import { ModelPickerContent } from "./ModelPickerContent";
14import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
14import {15import {
15 ModelEsque,16 ModelEsque,
16 PROVIDER_ICON_BY_PROVIDER,
17 getTriggerDisplayModelLabel,17 getTriggerDisplayModelLabel,
18 getTriggerDisplayModelName,18 getTriggerDisplayModelName,
19} from "./providerIconUtils";19} from "./providerIconUtils";
20import { setModelPickerOpen } from "../../modelPickerOpenState";20import { setModelPickerOpen } from "../../modelPickerOpenState";
21import type { ProviderInstanceEntry } from "../../providerInstances";
2122
22export const ProviderModelPicker = memo(function ProviderModelPicker(props: {23export 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 provider49 // 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 icon50 // 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;
5374
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]);
6788
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 };
7394
...@@ -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 <ProviderIcon127 {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 <TooltipTrigger139 <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 <ModelPickerContent172 <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";
2import { memo } from "react";2import { memo } from "react";
3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4import { CircleAlertIcon } from "lucide-react";4import { CircleAlertIcon } from "lucide-react";
5import { formatProviderKindLabel } from "../../providerModels";5import { formatProviderDriverKindLabel } from "../../providerModels";
66
7export const ProviderStatusBanner = memo(function ProviderStatusBanner({7export 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 }
1515
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 @@
1import {1import {
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}
7777
78function getSelectedTraits(78function 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}
151151
152function getTraitsSectionVisibility(input: {152function 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}
185185
186export function shouldRenderTraitsControls(input: {186export 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}
196196
197export interface TraitsMenuContentProps {197export 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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type {2import {
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";
8import {8import {
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 and15// 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.
1717
18const PROVIDER: ProviderKind = "codex";18const PROVIDER: ProviderDriverKind = ProviderDriverKind.make("codex");
19const MODEL = "test-model";19const MODEL = "test-model";
2020
21function selectDescriptor(21function selectDescriptor(
apps/web/src/components/chat/composerProviderState.tsx+4-4
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import {1import {
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";
17import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";17import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1818
19export type ComposerProviderStateInput = {19export 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};
2626
27export type ComposerProviderState = {27export 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};
3535
36type TraitsRenderInput = {36type 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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
23
3import type { ComposerCommandItem } from "./ComposerCommandMenu";4import type { ComposerCommandItem } from "./ComposerCommandMenu";
4import { searchSlashCommandItems } from "./composerSlashCommandSearch";5import { searchSlashCommandItems } from "./composerSlashCommandSearch";
56
6describe("searchSlashCommandItems", () => {7describe("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 @@
1import type { ProviderKind } from "@t3tools/contracts";1import type { ProviderDriverKind } from "@t3tools/contracts";
22
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]);
1010
11export function isModelPickerNewModel(provider: ProviderKind, slug: string): boolean {11export 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 @@
1import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
2import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";1import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
32
4type ModelPickerSearchableModel = {3type 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(
4046
41export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string {47export 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 @@
1import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";1import { ProviderDriverKind } from "@t3tools/contracts";
2import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";2import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
3import { PROVIDER_OPTIONS } from "../../session-logic";3import { PROVIDER_OPTIONS } from "../../session-logic";
44
5export const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {5export 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};
1111
12function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {12function 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};
2929
30export function getProviderLabel(provider: ProviderKind, model: ModelEsque): string {
31 const providerLabel = PROVIDER_DISPLAY_NAMES[provider];
32 return model.subProvider ? `${providerLabel} · ${model.subProvider}` : providerLabel;
33}
34
35export function getDisplayModelName(30export 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
3import { CheckIcon } from "lucide-react";
4import { Radio as RadioPrimitive } from "@base-ui/react/radio";
5import { useCallback, useEffect, useMemo, useState } from "react";
6import {
7 ProviderInstanceId,
8 ProviderDriverKind,
9 type ProviderInstanceConfig,
10} from "@t3tools/contracts";
11
12import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
13import { cn } from "../../lib/utils";
14import { normalizeProviderAccentColor } from "../../providerInstances";
15import { Button } from "../ui/button";
16import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons";
17import {
18 Dialog,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogPopup,
23 DialogTitle,
24} from "../ui/dialog";
25import { Badge } from "../ui/badge";
26import { Input } from "../ui/input";
27import { RadioGroup } from "../ui/radio-group";
28import { toastManager } from "../ui/toast";
29import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta";
30
31const 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 */
47function 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
56function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
57 const slug = slugifyLabel(label);
58 return slug ? `${driver}_${slug}` : "";
59}
60
61const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
62const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
63const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
64const 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 */
96function 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
106interface AddProviderInstanceDialogProps {
107 open: boolean;
108 onOpenChange: (open: boolean) => void;
109}
110
111export 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 @@
1import { describe, expect, it } from "vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";
3
4import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard";
5
6describe("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
3import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useState } from "react";
5import {
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
15import { cn } from "../../lib/utils";
16import { normalizeProviderAccentColor } from "../../providerInstances";
17import { Badge } from "../ui/badge";
18import { Button } from "../ui/button";
19import { Collapsible, CollapsibleContent } from "../ui/collapsible";
20import { DraftInput } from "../ui/draft-input";
21import { Switch } from "../ui/switch";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import type { DriverOption } from "./providerDriverMeta";
24import { ProviderModelsSection } from "./ProviderModelsSection";
25import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
26import {
27 PROVIDER_STATUS_STYLES,
28 getProviderSummary,
29 getProviderVersionLabel,
30 type ProviderStatusKey,
31} from "./providerStatus";
32
33const PROVIDER_ACCENT_SWATCHES = [
34 "#2563eb",
35 "#16a34a",
36 "#ea580c",
37 "#dc2626",
38 "#7c3aed",
39 "#0891b2",
40] as const;
41
42const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
43
44const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
45
46let environmentVariableDraftId = 0;
47const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
48
49type EnvironmentDraftRow = {
50 readonly id: string;
51 readonly name: string;
52 readonly value: string;
53 readonly sensitive: boolean;
54 readonly valueRedacted?: boolean;
55};
56
57function 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
70function 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 */
97function 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 */
109function 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 */
126function 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 */
150function 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
161export 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
183function 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
221function 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
309function 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
454interface 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 */
509export 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
3import {
4 ArrowDownIcon,
5 ArrowUpIcon,
6 EyeIcon,
7 EyeOffIcon,
8 InfoIcon,
9 PlusIcon,
10 StarIcon,
11 XIcon,
12} from "lucide-react";
13import { useMemo, useRef, useState } from "react";
14import {
15 ProviderDriverKind,
16 type ProviderInstanceId,
17 type ServerProviderModel,
18} from "@t3tools/contracts";
19import { normalizeModelSlug } from "@t3tools/shared/model";
20
21import { cn } from "../../lib/utils";
22import { sortModelsForProviderInstance } from "../../modelOrdering";
23import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection";
24import { Button } from "../ui/button";
25import { Input } from "../ui/input";
26import { 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 */
33const 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
40interface 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 */
87export 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", () => {
713713
714 await page.getByLabelText("Toggle OpenCode details").click();714 await page.getByLabelText("Toggle OpenCode details").click();
715715
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 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ProviderInstanceConfig,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vitest";
8import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
9
10describe("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 @@
1import type {
2 ProviderDriverKind,
3 ProviderInstanceConfig,
4 ProviderInstanceId,
5 ServerSettings,
6 UnifiedSettings,
7} from "@t3tools/contracts";
8import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
9
10export 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 @@
1import {1import { 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";
11import { useQueryClient } from "@tanstack/react-query";2import { useQueryClient } from "@tanstack/react-query";
12import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";3import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
13import {4import {
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";
20import { scopeThreadRef } from "@t3tools/client-runtime";12import { scopeThreadRef } from "@t3tools/client-runtime";
21import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
22import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";14import { createModelSelection } from "@t3tools/shared/model";
23import { Equal } from "effect";15import { Equal } from "effect";
24import { APP_VERSION } from "../../branding";16import { APP_VERSION } from "../../branding";
25import {17import {
...@@ -41,10 +33,13 @@ import {...@@ -41,10 +33,13 @@ import {
41 useDesktopUpdateState,33 useDesktopUpdateState,
42} from "../../lib/desktopUpdateReactQuery";34} from "../../lib/desktopUpdateReactQuery";
43import {35import {
44 MAX_CUSTOM_MODEL_LENGTH,36 getCustomModelOptionsByInstance,
45 getCustomModelOptionsByProvider,
46 resolveAppModelSelectionState,37 resolveAppModelSelectionState,
47} from "../../modelSelection";38} from "../../modelSelection";
39import {
40 deriveProviderInstanceEntries,
41 sortProviderInstanceEntries,
42} from "../../providerInstances";
48import { ensureLocalApi, readLocalApi } from "../../localApi";43import { ensureLocalApi, readLocalApi } from "../../localApi";
49import { useShallow } from "zustand/react/shallow";44import { useShallow } from "zustand/react/shallow";
50import {45import {
...@@ -53,16 +48,17 @@ import {...@@ -53,16 +48,17 @@ import {
53 useStore,48 useStore,
54} from "../../store";49} from "../../store";
55import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";50import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
56import { cn } from "../../lib/utils";
57import { Badge } from "../ui/badge";
58import { Button } from "../ui/button";51import { Button } from "../ui/button";
59import { Collapsible, CollapsibleContent } from "../ui/collapsible";
60import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";52import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
61import { Input } from "../ui/input";53import { DraftInput } from "../ui/draft-input";
62import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";54import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
63import { Switch } from "../ui/switch";55import { Switch } from "../ui/switch";
64import { stackedThreadToast, toastManager } from "../ui/toast";56import { stackedThreadToast, toastManager } from "../ui/toast";
65import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";57import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
58import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
59import { ProviderInstanceCard } from "./ProviderInstanceCard";
60import { getDriverOption } from "./providerDriverMeta";
61import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
66import {62import {
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";
80import { formatProviderKindLabel } from "../../providerModels";
8176
82const THEME_OPTIONS = [77const 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;
10297
98const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
99
100function 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
109function withoutProviderInstanceFavorites(
110 favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>,
111 instanceId: ProviderInstanceId,
112) {
113 return favorites.filter((favorite) => favorite.provider !== instanceId);
114}
115
103type InstallProviderSettings = {116type 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 = {
117130
118const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [131const 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;
153166
154const 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
169function 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
221function getProviderVersionLabel(version: string | null | undefined) {
222 if (!version) return null;
223 return version.startsWith("v") ? version : `v${version}`;
224}
225
226function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {167function 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;
465426
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 })();
621554
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;
680621
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 > 0623 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;
822629
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 ) : null1047 ) : null
1034 }1048 }
1035 control={1049 control={
1036 <Input1050 <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 <ProviderModelPicker1131 <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 <TraitsPicker1151 <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 <TooltipTrigger1210 <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: [],
12071240 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 <span1259 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 <SettingResetButton1275 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 <label1290 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 path1295 hiddenModels,
1320 </span>1296 })
1321 <Input1297 }
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>
16401311
1312 <AddProviderInstanceDialog
1313 open={isAddInstanceDialogOpen}
1314 onOpenChange={setIsAddInstanceDialogOpen}
1315 />
1316
1641 <SettingsSection title="Advanced">1317 <SettingsSection title="Advanced">
1642 <SettingsRow1318 <SettingsRow
1643 title="Keybindings"1319 title="Keybindings"
apps/web/src/components/settings/providerDriverMeta.ts created+151
...@@ -0,0 +1,151 @@
1import { ProviderDriverKind } from "@t3tools/contracts";
2import { 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 */
11export 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 */
24export 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
39export 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
140export 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 */
148export 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 @@
1import 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 */
7export 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
22export 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 */
31export 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 */
88export 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
3import { useCommitOnBlur } from "~/hooks/useCommitOnBlur";
4import { Input, type InputProps } from "./input";
5
6export 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 */
18export 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";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import {8import {
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";
16import { createModelSelection } from "@t3tools/shared/model";18import { createModelSelection } from "@t3tools/shared/model";
1719
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.
23const CODEX_INSTANCE = ProviderInstanceId.make("codex");
24const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
25const CURSOR_INSTANCE = ProviderInstanceId.make("cursor");
26const CODEX_DRIVER = ProviderDriverKind.make("codex");
27const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
28const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
29
18type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>;30type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>;
19type ProviderOptionSelectionsByProvider = Partial<Record<ProviderKind, ProviderOptionSelectionBag>>;31type ProviderOptionSelectionsByProvider = Partial<Record<string, ProviderOptionSelectionBag>>;
2032
21function toSelections(33function 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}
3345
34function selectionsByProvider(46function 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}
122134
123function modelSelection(135function 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}
130142
131function providerModelOptions(143function 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 );
937949
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", () => {
945959
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"));
949963
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 });
954968
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", () => {
957971
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 );
971985
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 );
975994
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", () => {
9901013
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 );
9971020
998 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: true }));1021 store.setProviderModelOptions(threadRef, CLAUDE_AGENT_DRIVER, toSelections({ thinking: true }));
9991022
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();
10101035
1011 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true }));1036 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4", { fastMode: true }));
10121037
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 );
10181043
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", () => {
10291056
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", () => {
10381065
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 );
10441071
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();
10561085
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 });
10611090
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();
10761109
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 );
10841117
1085 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }));1118 store.setProviderModelOptions(
1119 threadRef,
1120 CLAUDE_AGENT_DRIVER,
1121 toSelections({ thinking: false }),
1122 );
10861123
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 });
10961135
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" } }));
11111150
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 });
11211164
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 );
11321175
1133 store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6"));1176 store.setModelSelection(threadRef, modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"));
11341177
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();
11471190
1148 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));1191 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
11491192
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 });
11531196
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();
11631206
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 );
11711214
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 );
11751223
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});
11861236
...@@ -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();
11971247
1198 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex"));1248 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.3-codex"));
11991249
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});
12051255
...@@ -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();
12131263
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 );
12201270
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();
12321282
1233 store.setStickyModelSelection(modelSelection("codex", "gpt-5.4"));1283 store.setStickyModelSelection(modelSelection(CODEX_DRIVER, "gpt-5.4"));
12341284
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();
12431293
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 );
12521302
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 });
12581308
...@@ -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);
12631313
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);
12661316
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 @@
1import {1import {
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";
26import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";29import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
27import { useMemo } from "react";30import { useMemo } from "react";
28import { getLocalStorageItem } from "./hooks/useLocalStorage";31import { getLocalStorageItem } from "./hooks/useLocalStorage";
29import { resolveAppModelSelection } from "./modelSelection";32import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
30import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";33import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
31import {34import {
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 */
109type ProviderOptionSelectionsByProvider = Partial<121type ProviderOptionSelectionsByProvider = Partial<
110 Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>122 Record<string, ReadonlyArray<ProviderOptionSelection>>
111>;123>;
112124
113type LegacyCodexFields = {125type 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});
189type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;201type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
190202
...@@ -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 | null364 | 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}
389410
390interface ComposerDraftModelState {411interface ComposerDraftModelState {
391 activeProvider: ProviderKind | null;412 activeProvider: ProviderInstanceId | null;
392 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;413 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
393}414}
394415
395function providerSelectionsFromModelSelection(416function 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}
407428
408function modelSelectionByProviderToOptions(429function 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}
427448
428function cloneModelSelectionByProvider(449function 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}
438458
439const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({459const 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[] = [];
451Object.freeze(EMPTY_IMAGES);471Object.freeze(EMPTY_IMAGES);
452Object.freeze(EMPTY_IDS);472Object.freeze(EMPTY_IDS);
453Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);473Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
454const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> =474const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderDriverKind, ModelSelection>> =
455 Object.freeze({});475 Object.freeze({});
456const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({476const 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}
556576
557function normalizeProviderKind(value: unknown): ProviderKind | null {577function normalizeProviderDriverKind(value: unknown): ProviderDriverKind | null {
558 return value === "codex" || value === "claudeAgent" || value === "cursor" || value === "opencode"578 return Schema.is(ProviderDriverKind)(value) ? value : null;
559 ? value579}
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 */
588const 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 */
595function 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}
562600
563/**601/**
...@@ -610,11 +648,11 @@ function coerceProviderOptionSelections(...@@ -610,11 +648,11 @@ function coerceProviderOptionSelections(
610 */648 */
611function normalizeProviderModelOptions(649function 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}
650688
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.
651function normalizeModelSelection(696function 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}
685747
748type 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.
687758
688function legacySyncModelSelectionOptions(759function 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}
698774
699function legacyMergeModelSelectionIntoProviderModelOptions(775function 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}
712792
713function legacyReplaceProviderModelOptions(793function 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 ────────────────────
728808
729function legacyToModelSelectionByProvider(809function 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 === provider820 result[instanceKey] = createModelSelection(
821 instanceKey,
822 modelSelection?.instanceId === instanceKey
741 ? modelSelection.model823 ? 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 | null839 | 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?.model887 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 directly1488 // 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;
13701492
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 format1497 // 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: migrate1503 // 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 }
14111533
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 ...(hasModelData1562 ...(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;
14901612
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 ...(hasModelData1666 ...(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 );
15811707
1582 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats1708 // 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 path1723 // 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 }
16201748
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;
17601888
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.provider2253 return state.stickyActiveProvider === normalized.instanceId
2126 ? state2254 ? 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 them2372 // 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+model2375 // 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 === nextActiveProvider2386 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;
23292465
...@@ -2333,16 +2469,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2333,16 +2469,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23332469
2334 // Update the map entry for this provider2470 // 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 }
23472483
2348 // Handle sticky persistence2484 // 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 }
23692505
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 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import { scopeProjectRef } from "@t3tools/client-runtime";2import { scopeProjectRef } from "@t3tools/client-runtime";
3import { describe, expect, it } from "vitest";3import { describe, expect, it } from "vitest";
44
...@@ -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";
2import {2import {
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 @@
1import { 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 */
18export 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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { DEFAULT_RUNTIME_MODE, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";2import {
3 DEFAULT_RUNTIME_MODE,
4 EnvironmentId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
3import type { Thread } from "../types";9import type { Thread } from "../types";
4import { getLatestThreadForProject, sortThreads } from "./threadSort";10import { getLatestThreadForProject, sortThreads } from "./threadSort";
511
...@@ -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
207209
208const defaultProviders: ReadonlyArray<ServerProvider> = [210const 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 @@
1import { describe, expect, it } from "vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
3
4import {
5 providerModelKey,
6 sortModelsForProviderInstance,
7 sortProviderModelItems,
8} from "./modelOrdering";
9
10const CODEX_WORK_ID = ProviderInstanceId.make("codex_work");
11const CLAUDE_ID = ProviderInstanceId.make("claudeAgent");
12
13describe("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 @@
1import type { ProviderInstanceId } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Order from "effect/Order";
4
5export interface ModelSlugItem {
6 readonly slug: string;
7}
8
9export interface ProviderModelItem extends ModelSlugItem {
10 readonly instanceId: ProviderInstanceId;
11}
12
13export function providerModelKey(instanceId: ProviderInstanceId, slug: string): string {
14 return `${instanceId}:${slug}`;
15}
16
17function rankByValue(values: ReadonlyArray<string>): ReadonlyMap<string, number> {
18 return new Map(Arr.map(values, (value, index) => [value, index] as const));
19}
20
21function toSet(
22 values: ReadonlySet<string> | ReadonlyArray<string> | undefined,
23): ReadonlySet<string> {
24 return values instanceof Set ? values : new Set(values ?? []);
25}
26
27function 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
31function byTrueFirst<T>(predicate: (item: T) => boolean): Order.Order<T> {
32 return Order.mapInput(Order.flip(Order.Boolean), predicate);
33}
34
35export 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
58export 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 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
3import { describe, expect, it } from "vitest";
4import { deriveProviderInstanceEntries } from "./providerInstances";
5import {
6 getAppModelOptionsForInstance,
7 resolveAppModelSelectionForInstance,
8 resolveAppModelSelectionState,
9} from "./modelSelection";
10
11function 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
41function 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
57describe("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 @@
1import {1import {
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";
7import {10import {
...@@ -17,9 +20,52 @@ import {...@@ -17,9 +20,52 @@ import {
17 resolveSelectableProvider,20 resolveSelectableProvider,
18} from "./providerModels";21} from "./providerModels";
19import { ModelEsque } from "./components/chat/providerIconUtils";22import { ModelEsque } from "./components/chat/providerIconUtils";
23import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances";
24import { sortModelsForProviderInstance } from "./modelOrdering";
2025
21const MAX_CUSTOM_MODEL_COUNT = 32;26const MAX_CUSTOM_MODEL_COUNT = 32;
22export const MAX_CUSTOM_MODEL_LENGTH = 256;27export const MAX_CUSTOM_MODEL_LENGTH = 256;
28const 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 */
46function 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}
2369
24export interface AppModelOption {70export 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}
3177
78function 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
89function 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
101function 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
32export function normalizeCustomModelSlugs(115export 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(
61export function getAppModelOptions(144export 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 );
83157
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 }
97176
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 !selectedModelMatchesExistingName184 * 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 */
194export 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 }
113214
114 return options;215 return applyInstanceModelPreferences(
216 options,
217 readInstanceModelPreferences(settings, entry.instanceId),
218 );
115}219}
116220
117export function resolveAppModelSelection(221export 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}
130234
131export function getCustomModelOptionsByProvider(235export 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,259export 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}
164271
165export function resolveAppModelSelectionState(272export 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;
174311
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 });
186323
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 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3import {
4 deriveProviderInstanceEntries,
5 resolveSelectableProviderInstance,
6 resolveProviderDriverKindForInstanceSelection,
7} from "./providerInstances";
8
9function 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
33describe("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
47describe("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
96describe("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 */
15import {
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
25import { 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 */
33export 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 */
68function 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
78function driverKindLabel(driverKind: ProviderDriverKind): string {
79 return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind);
80}
81
82export 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 */
104function 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 */
129export 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 */
162export 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 */
191export 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 */
202export 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 */
215export 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 */
238export 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 @@
1import {1import {
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
10const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({13const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
11 optionDescriptors: [],14 optionDescriptors: [],
12});15});
16const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
1317
14export function formatProviderKindLabel(provider: ProviderKind): string {18export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string {
15 return provider19 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 {
2125
22export function getProviderModels(26export 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}
2832
29export function getProviderSnapshot(33export 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}
3540
36export function getProviderDisplayName(41export 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}
4348
44export function getProviderInteractionModeToggle(49export 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}
5055
51export function isProviderEnabled(56export 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}
6065
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.
61export function resolveSelectableProvider(69export 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}
7179
72export function getProviderModelCapabilities(80export 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(
8088
81export function getDefaultServerModel(89export 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 @@
1import {1import {
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>();
4042
41const defaultProviders: ReadonlyArray<ServerProvider> = [43const 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";
2424
25export type ProviderPickerKind = ProviderKind;25export type ProviderPickerKind = ProviderDriverKind;
2626
27export const PROVIDER_OPTIONS: Array<{27export 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];
3949
40export interface WorkLogEntry {50export interface WorkLogEntry {
apps/web/src/store.test.ts+14-13
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { scopeThreadRef } from "@t3tools/client-runtime";
2import {2import {
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";
21import { ProviderKind } from "@t3tools/contracts";21import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts";
22import type { ThreadId, TurnId } from "@t3tools/contracts";22import type { ThreadId, TurnId } from "@t3tools/contracts";
23import { Schema } from "effect";23import { Schema } from "effect";
24import { resolveModelSlugForProvider } from "@t3tools/shared/model";24import { 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}
131131
132function 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.
135function 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}
140144
...@@ -145,6 +149,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>):...@@ -145,6 +149,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>):
145function mapSession(session: OrchestrationSession): ThreadSession {149function 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}
10021007
1003function toLegacyProvider(providerName: string | null): ProviderKind {1008function 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}
10091014
1010function attachmentPreviewRoutePath(attachmentId: string): string {1015function 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}
159160
160export interface ThreadSession {161export 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 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";4import { 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
3This guide is for people who want to use more than one Claude setup in T3 Code.
4
5Common 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
14Use the default provider.
15
16Log in with Claude Code normally:
17
18```bash
19claude auth login
20```
21
22In T3 Code Settings, your Claude provider can stay like this:
23
24```text
25Display name: Claude
26Binary path: claude
27Claude HOME path: empty
28```
29
30An empty `Claude HOME path` means T3 Code uses your normal home directory.
31
32## I Want Work And Personal Claude Accounts
33
34Use a different Claude home for each account.
35
36Example:
37
38```text
39default home work account
40~/.claude_personal_home personal account
41```
42
43### Set Up The First Account
44
45Log in normally:
46
47```bash
48claude auth login
49```
50
51In T3 Code Settings:
52
53```text
54Display name: Claude Work
55Binary path: claude
56Claude HOME path: empty
57```
58
59### Set Up The Second Account
60
61Log in with a separate home:
62
63```bash
64mkdir -p ~/.claude_personal_home
65HOME=~/.claude_personal_home claude auth login
66```
67
68Then add another Claude provider in T3 Code:
69
70```text
71Display name: Claude Personal
72Binary path: claude
73Claude HOME path: ~/.claude_personal_home
74```
75
76Use the email shown in Settings to confirm each provider is using the intended account. Emails are
77blurred by default; click the blurred email to reveal it.
78
79## Can I Switch Claude Accounts In An Existing Thread?
80
81Usually, no.
82
83T3 Code only offers Claude providers that use the same Claude home for an existing thread. A
84different Claude home is treated as a different Claude environment.
85
86This is different from the recommended Codex setup. Claude Code keeps account and local state across
87multiple files under its home directory, so T3 Code keeps separate Claude homes isolated instead of
88trying to share part of the state.
89
90## I Want To Use OpenRouter
91
92Use this when you want Claude Code to talk to OpenRouter directly, without running a local router.
93This is the simplest external-provider setup.
94
95OpenRouter provides a Claude Code integration through Claude's Anthropic-compatible environment
96variables.
97
98### Configure A Claude OpenRouter Provider
99
100Add or edit a Claude provider in T3 Code Settings:
101
102```text
103Display name: Claude OpenRouter
104Binary path: claude
105Claude HOME path: ~/.claude_openrouter_home
106```
107
108In that provider's Environment variables section, add:
109
110```text
111ANTHROPIC_BASE_URL https://openrouter.ai/api
112ANTHROPIC_AUTH_TOKEN sk-or-... Sensitive
113ANTHROPIC_API_KEY Empty value
114```
115
116Mark `ANTHROPIC_AUTH_TOKEN` as sensitive. T3 Code stores the value as a server secret and does not
117send it back to the app after saving.
118
119If you want this setup isolated from your normal Claude account, create that home first:
120
121```bash
122mkdir -p ~/.claude_openrouter_home
123```
124
125If you previously used the same Claude home with a normal Anthropic login, run `/logout` in a Claude
126Code session for that home before using OpenRouter. Otherwise Claude Code may keep using cached
127Anthropic credentials instead of the OpenRouter token.
128
129### Pick OpenRouter Models
130
131OpenRouter can route Claude Code's default model roles to OpenRouter model IDs.
132
133Example:
134
135```text
136ANTHROPIC_DEFAULT_OPUS_MODEL anthropic/claude-opus-4.6
137ANTHROPIC_DEFAULT_SONNET_MODEL anthropic/claude-sonnet-4.6
138ANTHROPIC_DEFAULT_HAIKU_MODEL anthropic/claude-haiku-4.5
139CLAUDE_CODE_SUBAGENT_MODEL anthropic/claude-sonnet-4.6
140```
141
142Add those to the same provider's Environment variables section if you want stable model choices.
143
144### Verify OpenRouter Is Being Used
145
146Open a Claude session and run:
147
148```text
149/status
150```
151
152You should see the Anthropic base URL set to:
153
154```text
155https://openrouter.ai/api
156```
157
158You 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
167OpenRouter'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
172Claude Code Router is useful when you want a local routing layer with more control than a direct
173OpenRouter setup.
174
175T3 Code does not need a special Claude Code Router provider. Treat the router as a Claude
176environment.
177
178Use this when you want Claude Code Router to decide which upstream model or provider handles Claude
179requests.
180
181High-level flow:
182
1831. Start Claude Code Router.
1842. Add or configure a Claude provider in T3 Code.
1853. Put the router's required variables on that provider instance.
186
187Configure a Claude provider:
188
189```text
190Display name: Claude Router
191Binary path: claude
192Claude HOME path: ~/.claude_router_home
193```
194
195Then copy the variables that `ccr activate` would export into the provider's Environment variables
196section. Mark tokens and API keys as sensitive.
197
198If you want the router-backed setup to stay separate from your normal Claude account, create and log
199in with a dedicated home first:
200
201```bash
202mkdir -p ~/.claude_router_home
203ccr start
204ccr activate
205HOME=~/.claude_router_home claude auth login
206```
207
208Claude Code Router's setup can change over time. Use its upstream README for the current install and
209configuration steps: <https://github.com/musistudio/claude-code-router>.
210
211## I Want Different Claude Settings, Not A Different Account
212
213Create another Claude provider with the same account if you want a named preset.
214
215Examples:
216
217- "Claude Default"
218- "Claude Router"
219- "Claude Experimental"
220
221If the preset needs different Claude files, give it a different `Claude HOME path`. If it needs
222different API keys, base URLs, or router settings, use Environment variables.
223
224Do not put environment variable assignments in `Launch arguments`.
docs/providers/codex.md created+140
...@@ -0,0 +1,140 @@
1# Codex
2
3This guide is for people who want to use more than one Codex account in T3 Code.
4
5Common 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
14Use the default provider.
15
16In Settings, your Codex provider can stay like this:
17
18```text
19Display name: Codex
20CODEX_HOME path: ~/.codex
21Shadow home path: empty
22```
23
24Log in with Codex normally:
25
26```bash
27codex login
28```
29
30## I Want Work And Personal Codex Accounts
31
32Use one real Codex home and one shadow home.
33
34Recommended setup:
35
36```text
37~/.codex shared Codex home
38~/.codex_p second account auth
39```
40
41The 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
49Log in normally:
50
51```bash
52codex login
53```
54
55This is the account used by `~/.codex`.
56
57In T3 Code Settings, name it something obvious:
58
59```text
60Display name: Codex Work
61CODEX_HOME path: ~/.codex
62Shadow home path: empty
63```
64
65### Set Up The Second Account
66
67Log in with a separate Codex home:
68
69```bash
70mkdir -p ~/.codex_p
71CODEX_HOME=~/.codex_p codex login
72```
73
74In T3 Code Settings, add another Codex provider:
75
76```text
77Display name: Codex Personal
78CODEX_HOME path: ~/.codex
79Shadow home path: ~/.codex_p
80```
81
82The important part is that both providers use the same `CODEX_HOME path`, but only the second one
83has a `Shadow home path`.
84
85## Which Account Am I Using?
86
87Open Settings and look at the provider row.
88
89T3 Code shows the authenticated email for providers that report one. Emails are blurred by default;
90click the blurred email to reveal it.
91
92Use 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
96Use the provider's Environment variables section in Settings.
97
98This is useful when a Codex-compatible setup needs account-specific variables. Add the variables to
99the provider instance that should receive them, and mark API keys or tokens as sensitive. Sensitive
100values 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
104Yes, when both Codex providers share the same `CODEX_HOME path`.
105
106For example:
107
108```text
109Codex Work CODEX_HOME path: ~/.codex
110Codex Personal CODEX_HOME path: ~/.codex, Shadow home path: ~/.codex_p
111```
112
113Those two providers are considered compatible for continuation, so the locked model picker can show
114both.
115
116If you add a third Codex provider with a completely different `CODEX_HOME path`, T3 Code treats it
117as a different workspace. It will not be offered for existing threads created under `~/.codex`.
118
119## If Both Accounts Look The Same
120
121If two Codex providers show the same account or the same unexpected model list:
122
1231. Check the email in Settings.
1242. Refresh provider status.
1253. Confirm the second provider has `Shadow home path` set.
1264. Confirm the shadow directory has its own `auth.json`.
1275. If you copied `~/.codex` into the shadow directory, remove everything except `auth.json`.
128
129Example cleanup:
130
131```bash
132find ~/.codex_p -mindepth 1 ! -name auth.json -exec rm -rf {} +
133```
134
135## When To Use A Separate CODEX_HOME
136
137Use a totally separate `CODEX_HOME path` only when you want a separate Codex workspace.
138
139That means separate sessions and less account switching inside old threads. Most dual-account users
140should 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";
4export * from "./ipc.ts";4export * from "./ipc.ts";
5export * from "./terminal.ts";5export * from "./terminal.ts";
6export * from "./provider.ts";6export * from "./provider.ts";
7export * from "./providerInstance.ts";
7export * from "./providerRuntime.ts";8export * from "./providerRuntime.ts";
8export * from "./model.ts";9export * from "./model.ts";
9export * from "./keybindings.ts";10export * 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";
28import type { ProviderInstanceId } from "./providerInstance.ts";
28import type {29import 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 @@
1import { Effect, Schema, SchemaTransformation } from "effect";1import { Effect, Schema, SchemaTransformation } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import type { ProviderKind } from "./orchestration.ts";3import { ProviderDriverKind } from "./providerInstance.ts";
44
5export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]);5export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]);
6export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type;6export 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});
126export type ModelCapabilities = typeof ModelCapabilities.Type;126export type ModelCapabilities = typeof ModelCapabilities.Type;
127127
128export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {128const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
129 codex: "gpt-5.4",129const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
130 claudeAgent: "claude-sonnet-4-6",130const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
131 cursor: "auto",131const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
132 opencode: "openai/gpt-5",132
133};133export const DEFAULT_MODEL = "gpt-5.4";
134export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini";
134135
135export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;136export 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};
136142
137/** Per-provider text generation model defaults. */143/** Per-provider text generation model defaults. */
138export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {144export 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};
144152
145export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = {153export 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};
183193
184// ── Provider display names ────────────────────────────────────────────194// ── Provider display names ────────────────────────────────────────────
185195
186export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {196export 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";
5import {5import {
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";
24import { ProviderInstanceId } from "./providerInstance.ts";
2325
24const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);26const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
25const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);27const 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);
146148
...@@ -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);
160162
...@@ -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 });
279281
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);
284286
...@@ -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);
298300
...@@ -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 });
410412
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
638const decodeModelSelection = Schema.decodeUnknownEffect(ModelSelection);
639const encodeModelSelection = Schema.encodeUnknownEffect(ModelSelection);
640
641it.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
654it.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
664it.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
675it.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
689it.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
705it.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 @@
1import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";1import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect";
2import { ProviderOptionSelections } from "./model.ts";2import { ProviderOptionSelections } from "./model.ts";
3import { RepositoryIdentity } from "./environment.ts";3import { RepositoryIdentity } from "./environment.ts";
4import {4import {
...@@ -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";
18import { ProviderInstanceId } from "./providerInstance.ts";
1819
19export const ORCHESTRATION_WS_METHODS = {20export 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;
2728
28export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]);
29export type ProviderKind = typeof ProviderKind.Type;
30export const ProviderApprovalPolicy = Schema.Literals([29export 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]);
42export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;41export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
4342
44export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";43/**
4544 * `ModelSelection` — selection of a model on a configured provider instance.
46export 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.
51export type CodexModelSelection = typeof CodexModelSelection.Type;50 *
5251 * Wire legacy: persisted selections produced before the driver/instance
53export 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 */
58const ModelSelectionWire = Schema.Struct({
59 instanceId: ProviderInstanceId,
55 model: TrimmedNonEmptyString,60 model: TrimmedNonEmptyString,
56 options: Schema.optionalKey(ProviderOptionSelections),61 options: Schema.optionalKey(ProviderOptionSelections),
57});62});
58export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5963
60export 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});68const ModelSelectionSource = Schema.Struct({
65export type CursorModelSelection = typeof CursorModelSelection.Type;69 provider: Schema.optional(Schema.Unknown),
66export 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
71export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type;75export const ModelSelection = ModelSelectionSource.pipe(
7276 Schema.decodeTo(
73export 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);
79export type ModelSelection = typeof ModelSelection.Type;109export type ModelSelection = typeof ModelSelection.Type;
80110
81export const RuntimeMode = Schema.Literals([111export 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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import { Schema } from "effect";
33
4import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts";4import {
5 ProviderEvent,
6 ProviderSendTurnInput,
7 ProviderSession,
8 ProviderSessionStartInput,
9} from "./provider.ts";
510
6const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);11const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
7const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);12const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
13const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession);
14const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent);
815
9function getOptionValue(16function 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 });
4145
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 });
7879
...@@ -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});
99116
...@@ -111,13 +128,10 @@ describe("ProviderSendTurnInput", () => {...@@ -111,13 +128,10 @@ describe("ProviderSendTurnInput", () => {
111 },128 },
112 });129 });
113130
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 });
122136
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 });
135149
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
156describe("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";
24import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
2525
26const ProviderSessionStatus = Schema.Literals([26const 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]);
3333
34export const ProviderSession = Schema.Struct({34export 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;
4852
49export const ProviderSessionStartInput = Schema.Struct({53export 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",
108export const ProviderEvent = Schema.Struct({114export 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 @@
1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";
3
4import {
5 ProviderDriverKind,
6 ProviderInstanceConfig,
7 ProviderInstanceConfigMap,
8 ProviderInstanceId,
9 ProviderInstanceRef,
10} from "./providerInstance.ts";
11
12const decodeProviderDriverKind = Schema.decodeUnknownSync(ProviderDriverKind);
13const decodeProviderInstanceId = Schema.decodeUnknownSync(ProviderInstanceId);
14const decodeProviderInstanceRef = Schema.decodeUnknownSync(ProviderInstanceRef);
15const decodeProviderInstanceConfig = Schema.decodeUnknownSync(ProviderInstanceConfig);
16const decodeProviderInstanceConfigMap = Schema.decodeUnknownSync(ProviderInstanceConfigMap);
17
18describe("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
59describe("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
88describe("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
160describe("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 */
36import { Effect, Schema } from "effect";
37import { TrimmedNonEmptyString } from "./baseSchemas.ts";
38
39const 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 */
48const PROVIDER_SLUG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
49const ENVIRONMENT_VARIABLE_NAME_MAX_CHARS = 128;
50const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
51
52const 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 */
69export const ProviderDriverKind = slugSchema.pipe(Schema.brand("ProviderDriverKind"));
70export type ProviderDriverKind = typeof ProviderDriverKind.Type;
71
72const isProviderDriverKindValue = Schema.is(ProviderDriverKind);
73export 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 */
81export const ProviderInstanceId = slugSchema.pipe(Schema.brand("ProviderInstanceId"));
82export 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 */
90export const ProviderInstanceRef = Schema.Struct({
91 instanceId: ProviderInstanceId,
92 driver: ProviderDriverKind,
93});
94export type ProviderInstanceRef = typeof ProviderInstanceRef.Type;
95
96export const ProviderInstanceEnvironmentVariableName = TrimmedNonEmptyString.check(
97 Schema.isMaxLength(ENVIRONMENT_VARIABLE_NAME_MAX_CHARS),
98 Schema.isPattern(ENVIRONMENT_VARIABLE_NAME_PATTERN),
99);
100export type ProviderInstanceEnvironmentVariableName =
101 typeof ProviderInstanceEnvironmentVariableName.Type;
102
103export 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});
109export type ProviderInstanceEnvironmentVariable = typeof ProviderInstanceEnvironmentVariable.Type;
110
111export const ProviderInstanceEnvironment = Schema.Array(ProviderInstanceEnvironmentVariable);
112export 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 */
123export 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});
131export 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 */
137export const ProviderInstanceConfigMap = Schema.Record(ProviderInstanceId, ProviderInstanceConfig);
138export 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 */
147export 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";
6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
77
8describe("ProviderRuntimeEvent", () => {8describe("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";
15import { ProviderKind } from "./orchestration.ts";15import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
1616
17const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;17const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
18const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);18const 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");
245245
246const ProviderRuntimeEventBase = Schema.Struct({246const 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";
51import { ProviderInstanceId } from "./providerInstance.ts";
51import {52import {
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});
141142
142export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, {143export 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});
146155
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";
6const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);6const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
77
8describe("ServerProvider", () => {8describe("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 {
11import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts";11import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts";
12import { EditorId } from "./editor.ts";12import { EditorId } from "./editor.ts";
13import { ModelCapabilities } from "./model.ts";13import { ModelCapabilities } from "./model.ts";
14import { ProviderKind } from "./orchestration.ts";14import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
15import { ServerSettings } from "./settings.ts";15import { ServerSettings } from "./settings.ts";
1616
17const KeybindingsMalformedConfigIssue = Schema.Struct({17const 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});
51export type ServerProviderAuth = typeof ServerProviderAuth.Type;52export type ServerProviderAuth = typeof ServerProviderAuth.Type;
5253
...@@ -83,10 +84,42 @@ export const ServerProviderSkill = Schema.Struct({...@@ -83,10 +84,42 @@ export const ServerProviderSkill = Schema.Struct({
83});84});
84export type ServerProviderSkill = typeof ServerProviderSkill.Type;85export type ServerProviderSkill = typeof ServerProviderSkill.Type;
8586
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 */
104export const ServerProviderAvailability = Schema.Literals(["available", "unavailable"]);
105export type ServerProviderAvailability = typeof ServerProviderAvailability.Type;
106
107export const ServerProviderContinuation = Schema.Struct({
108 groupKey: TrimmedNonEmptyString,
109});
110export type ServerProviderContinuation = typeof ServerProviderContinuation.Type;
111
86export const ServerProvider = Schema.Struct({112export 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;
106export const ServerProviders = Schema.Array(ServerProvider);148export const ServerProviders = Schema.Array(ServerProvider);
107export type ServerProviders = typeof ServerProviders.Type;149export type ServerProviders = typeof ServerProviders.Type;
108150
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 */
157export const isProviderAvailable = (snapshot: ServerProvider): boolean =>
158 snapshot.availability !== "unavailable";
159
109export const ServerObservability = Schema.Struct({160export 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 @@
1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";
3
4import { ProviderInstanceId } from "./providerInstance.ts";
5import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";
6
7const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
8const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
9
10describe("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
66describe("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";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";3import * as SchemaTransformation from "effect/SchemaTransformation";
4import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";4import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
5import {5import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";
6 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,6import { ModelSelection } from "./orchestration.ts";
7 ProviderOptionSelections,7import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
8} from "./model.ts";
9import { ModelSelection, ProviderKind } from "./orchestration.ts";
108
11// ── Client Settings (local-only) ───────────────────────────────9// ── Client Settings (local-only) ───────────────────────────────
1210
...@@ -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});
88export type CodexSettings = typeof CodexSettings.Type;106export 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;
90export const ClaudeSettings = Schema.Struct({108export 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 ),
134153
135 // Provider specific settings154 // 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});
144export type ServerSettings = typeof ServerSettings.Type;176export 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 = {
168200
169// ── Server Settings Patch (replace with a Schema.deepPartial if available) ──────────────────────────────────────────201// ── Server Settings Patch (replace with a Schema.deepPartial if available) ──────────────────────────────────────────
170202
171const ModelSelectionPatch = Schema.Union([203const 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]);
193208
194const CodexSettingsPatch = Schema.Struct({209const 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});
200216
201const ClaudeSettingsPatch = Schema.Struct({217const 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});
244export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;266export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
245267
...@@ -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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts";2import {
3 DEFAULT_MODEL,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ModelCapabilities,
7} from "@t3tools/contracts";
38
4import {9import {
5 applyClaudePromptEffortPrefix,10 applyClaudePromptEffortPrefix,
...@@ -67,9 +72,10 @@ const claudeCaps: ModelCapabilities = createModelCapabilities({...@@ -67,9 +72,10 @@ const claudeCaps: ModelCapabilities = createModelCapabilities({
6772
68describe("normalizeModelSlug", () => {73describe("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 });
7480
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", () => {
8288
83describe("resolveModelSlugForProvider", () => {89describe("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 });
9098
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});
97105
...@@ -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});
109123
...@@ -182,12 +196,12 @@ describe("descriptor helpers", () => {...@@ -182,12 +196,12 @@ describe("descriptor helpers", () => {
182196
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 });
198212
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 @@
1import {1import {
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";
1012
13const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
14
11export interface SelectableModelOption {15export 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
230234
231export function normalizeModelSlug(235export 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 }
243247
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}
250254
251export function resolveSelectableModel(255export 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}
283287
284function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string {288function 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}
291295
292export function resolveModelSlugForProvider(296export 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}
311315
312export function createModelSelection(316export 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}
324328
325/**329/**
packages/shared/src/serverSettings.test.ts+70-20
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5} from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";6import { describe, expect, it } from "vitest";
3import { createModelSelection } from "./model.ts";7import { createModelSelection } from "./model.ts";
4import {8import {
...@@ -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 };
6573
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 };
8799
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 };
112128
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(
44function shouldReplaceTextGenerationModelSelection(44function 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}
4949
50function mergeModelSelectionOptionsById(input: {50function 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 }
8289
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.options93 ? selectionPatch.options
...@@ -90,7 +97,7 @@ export function applyServerSettingsPatch(...@@ -90,7 +97,7 @@ export function applyServerSettingsPatch(
90 });97 });
9198
92 return {99 return {
93 ...next,100 ...nextWithReplacements,
94 textGenerationModelSelection: createModelSelection(provider, model, options),101 textGenerationModelSelection: createModelSelection(instanceId, model, options),
95 };102 };
96}103}