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 = {
5454 confirmThreadDelete: false,
5555 diffWordWrap: true,
5656 favorites: [],
57 providerModelPreferences: {},
5758 sidebarProjectGroupingMode: "repository_path",
5859 sidebarProjectGroupingOverrides: {
5960 "environment-1:/tmp/project-a": "separate",
apps/server/integration/OrchestrationEngineHarness.integration.ts+22-22
......@@ -3,7 +3,8 @@ import { execFileSync } from "node:child_process";
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import {
55 ApprovalRequestId,
6 ProviderKind,
6 CodexSettings,
7 ProviderDriverKind,
78 type OrchestrationEvent,
89 type OrchestrationThread,
910} from "@t3tools/contracts";
......@@ -36,13 +37,16 @@ import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/
3637import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
3738import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
3839import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
39import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
40import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
4041import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
4142import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
4243import { ServerSettingsService } from "../src/serverSettings.ts";
4344import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
44import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
45import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
45import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts";
46import {
47 NoOpProviderEventLoggers,
48 ProviderEventLoggers,
49} from "../src/provider/Layers/ProviderEventLoggers.ts";
4650import { ProviderService } from "../src/provider/Services/ProviderService.ts";
4751import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
4852import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
......@@ -214,7 +218,7 @@ export interface OrchestrationIntegrationHarness {
214218}
215219
216220interface MakeOrchestrationIntegrationHarnessOptions {
217 readonly provider?: ProviderKind;
221 readonly provider?: ProviderDriverKind;
218222 readonly realCodex?: boolean;
219223}
220224
......@@ -225,7 +229,7 @@ export const makeOrchestrationIntegrationHarness = (
225229 const path = yield* Path.Path;
226230 const fileSystem = yield* FileSystem.FileSystem;
227231
228 const provider = options?.provider ?? "codex";
232 const provider = options?.provider ?? ProviderDriverKind.make("codex");
229233 const useRealCodex = options?.realCodex === true;
230234 const adapterHarness = useRealCodex
231235 ? null
......@@ -233,13 +237,10 @@ export const makeOrchestrationIntegrationHarness = (
233237 provider,
234238 });
235239 const fakeRegistry = adapterHarness
236 ? Layer.succeed(ProviderAdapterRegistry, {
237 getByProvider: (resolvedProvider) =>
238 resolvedProvider === adapterHarness.provider
239 ? Effect.succeed(adapterHarness.adapter)
240 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
241 listProviders: () => Effect.succeed([adapterHarness.provider]),
242 } as typeof ProviderAdapterRegistry.Service)
240 ? Layer.succeed(
241 ProviderAdapterRegistry,
242 makeAdapterRegistryMock({ [adapterHarness.provider]: adapterHarness.adapter }),
243 )
243244 : null;
244245 const rootDir = yield* fileSystem.makeTempDirectoryScoped({
245246 prefix: "t3-orchestration-integration-",
......@@ -264,31 +265,30 @@ export const makeOrchestrationIntegrationHarness = (
264265 const realCodexRegistry = Layer.effect(
265266 ProviderAdapterRegistry,
266267 Effect.gen(function* () {
267 const codexAdapter = yield* CodexAdapter;
268 return {
269 getByProvider: (resolvedProvider) =>
270 resolvedProvider === "codex"
271 ? Effect.succeed(codexAdapter)
272 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
273 listProviders: () => Effect.succeed(["codex"] as const),
274 } as typeof ProviderAdapterRegistry.Service;
268 const codexSettings = Schema.decodeSync(CodexSettings)({});
269 const codexAdapter = yield* makeCodexAdapter(codexSettings);
270 return makeAdapterRegistryMock({
271 [ProviderDriverKind.make("codex")]: codexAdapter,
272 });
275273 }),
276274 ).pipe(
277 Layer.provide(makeCodexAdapterLive()),
278275 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
279276 Layer.provideMerge(NodeServices.layer),
280277 Layer.provideMerge(providerSessionDirectoryLayer),
281278 );
279 const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers);
282280 const providerLayer = useRealCodex
283281 ? makeProviderServiceLive().pipe(
284282 Layer.provide(providerSessionDirectoryLayer),
285283 Layer.provide(realCodexRegistry),
286284 Layer.provide(AnalyticsService.layerTest),
285 Layer.provide(providerEventLoggersLayer),
287286 )
288287 : makeProviderServiceLive().pipe(
289288 Layer.provide(providerSessionDirectoryLayer),
290289 Layer.provide(fakeRegistry!),
291290 Layer.provide(AnalyticsService.layerTest),
291 Layer.provide(providerEventLoggersLayer),
292292 );
293293
294294 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
apps/server/integration/TestProviderAdapter.integration.ts+10-7
......@@ -10,7 +10,7 @@ import {
1010 ProviderTurnStartResult,
1111 ThreadId,
1212 TurnId,
13 ProviderKind,
13 ProviderDriverKind,
1414} from "@t3tools/contracts";
1515import { Effect, Queue, Stream } from "effect";
1616
......@@ -36,7 +36,7 @@ export interface TestTurnResponse {
3636export type FixtureProviderRuntimeEvent = {
3737 readonly type: string;
3838 readonly eventId: EventId;
39 readonly provider: ProviderKind;
39 readonly provider: ProviderDriverKind;
4040 readonly createdAt: string;
4141 readonly threadId: string;
4242 readonly turnId?: string | undefined;
......@@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti
178178
179179export interface TestProviderAdapterHarness {
180180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
181 readonly provider: ProviderKind;
181 readonly provider: ProviderDriverKind;
182182 readonly queueTurnResponse: (
183183 threadId: ThreadId,
184184 response: TestTurnResponse,
......@@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness {
198198}
199199
200200interface MakeTestProviderAdapterHarnessOptions {
201 readonly provider?: ProviderKind;
201 readonly provider?: ProviderDriverKind;
202202}
203203
204204function nowIso(): string {
......@@ -206,7 +206,7 @@ function nowIso(): string {
206206}
207207
208208function sessionNotFound(
209 provider: ProviderKind,
209 provider: ProviderDriverKind,
210210 threadId: ThreadId,
211211): ProviderAdapterSessionNotFoundError {
212212 return new ProviderAdapterSessionNotFoundError({
......@@ -216,7 +216,7 @@ function sessionNotFound(
216216}
217217
218218function missingSessionEffect(
219 provider: ProviderKind,
219 provider: ProviderDriverKind,
220220 threadId: ThreadId,
221221): Effect.Effect<never, ProviderAdapterError> {
222222 return Effect.fail(sessionNotFound(provider, threadId));
......@@ -224,7 +224,7 @@ function missingSessionEffect(
224224
225225export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>
226226 Effect.gen(function* () {
227 const provider = options?.provider ?? "codex";
227 const provider = options?.provider ?? ProviderDriverKind.make("codex");
228228 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
229229 let sessionCount = 0;
230230 const sessions = new Map<ThreadId, SessionState>();
......@@ -257,6 +257,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
257257
258258 const session: ProviderSession = {
259259 provider,
260 ...(input.providerInstanceId !== undefined
261 ? { providerInstanceId: input.providerInstanceId }
262 : {}),
260263 status: "ready",
261264 runtimeMode: input.runtimeMode,
262265 threadId,
apps/server/integration/fixtures/providerRuntime.ts+2-2
......@@ -1,7 +1,7 @@
1import { EventId, RuntimeRequestId } from "@t3tools/contracts";
1import { EventId, ProviderDriverKind, RuntimeRequestId } from "@t3tools/contracts";
22import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts";
33
4const PROVIDER = "codex" as const;
4const PROVIDER = ProviderDriverKind.make("codex");
55const SESSION_ID = "fixture-session";
66const THREAD_ID = "fixture-thread";
77const TURN_ID = "fixture-turn";
apps/server/integration/orchestrationEngine.integration.test.ts+136-42
......@@ -4,14 +4,17 @@ import path from "node:path";
44import {
55 ApprovalRequestId,
66 CommandId,
7 defaultInstanceIdForDriver,
78 DEFAULT_PROVIDER_INTERACTION_MODE,
9 DEFAULT_MODEL,
810 DEFAULT_MODEL_BY_PROVIDER,
911 EventId,
1012 MessageId,
1113 ProjectId,
12 ProviderKind,
14 ProviderDriverKind,
1315 ThreadId,
1416 ModelSelection,
17 ProviderInstanceId,
1518} from "@t3tools/contracts";
1619import { assert, it } from "@effect/vitest";
1720import { Effect, Option, Schema } from "effect";
......@@ -39,7 +42,9 @@ const PROJECT_ID = asProjectId("project-1");
3942const THREAD_ID = ThreadId.make("thread-1");
4043const FIXTURE_TURN_ID = "fixture-turn";
4144const 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
4449function nowIso() {
4550 return new Date().toISOString();
......@@ -74,7 +79,11 @@ function waitForSync<A>(
7479 });
7580}
7681
77function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") {
82function runtimeBase(
83 eventId: string,
84 createdAt: string,
85 provider: IntegrationProvider = CODEX_PROVIDER,
86) {
7887 return {
7988 eventId: asEventId(eventId),
8089 provider,
......@@ -84,7 +93,7 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr
8493
8594function withHarness<A, E>(
8695 use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>,
87 provider: IntegrationProvider = "codex",
96 provider: IntegrationProvider = CODEX_PROVIDER,
8897) {
8998 return Effect.acquireUseRelease(
9099 makeOrchestrationIntegrationHarness({ provider }),
......@@ -97,7 +106,7 @@ function withRealCodexHarness<A, E>(
97106 use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>,
98107) {
99108 return Effect.acquireUseRelease(
100 makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }),
109 makeOrchestrationIntegrationHarness({ provider: CODEX_PROVIDER, realCodex: true }),
101110 use,
102111 (harness) => harness.dispose,
103112 ).pipe(Effect.provide(NodeServices.layer));
......@@ -106,8 +115,9 @@ function withRealCodexHarness<A, E>(
106115const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
107116 Effect.gen(function* () {
108117 const createdAt = nowIso();
109 const provider = harness.adapterHarness?.provider ?? "codex";
110 const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider];
118 const provider = harness.adapterHarness?.provider ?? CODEX_PROVIDER;
119 const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
120 const instanceId = defaultInstanceIdForDriver(provider);
111121
112122 yield* harness.engine.dispatch({
113123 type: "project.create",
......@@ -116,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
116126 title: "Integration Project",
117127 workspaceRoot: harness.workspaceDir,
118128 defaultModelSelection: {
119 provider,
129 instanceId,
120130 model: defaultModel,
121131 },
122132 createdAt,
......@@ -129,7 +139,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
129139 projectId: PROJECT_ID,
130140 title: "Integration Thread",
131141 modelSelection: {
132 provider,
142 instanceId,
133143 model: defaultModel,
134144 },
135145 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -265,7 +275,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
265275 title: "Integration Project",
266276 workspaceRoot: harness.workspaceDir,
267277 defaultModelSelection: {
268 provider: "codex",
278 instanceId: ProviderInstanceId.make("codex"),
269279 model: "gpt-5.3-codex",
270280 },
271281 createdAt,
......@@ -278,7 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
278288 projectId: PROJECT_ID,
279289 title: "Integration Thread",
280290 modelSelection: {
281 provider: "codex",
291 instanceId: ProviderInstanceId.make("codex"),
282292 model: "gpt-5.3-codex",
283293 },
284294 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -912,20 +922,32 @@ it.live("starts a claudeAgent session on first turn when provider is requested",
912922 events: [
913923 {
914924 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 ),
916930 threadId: THREAD_ID,
917931 turnId: FIXTURE_TURN_ID,
918932 },
919933 {
920934 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 ),
922940 threadId: THREAD_ID,
923941 turnId: FIXTURE_TURN_ID,
924942 delta: "Claude first turn.\n",
925943 },
926944 {
927945 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 ),
929951 threadId: THREAD_ID,
930952 turnId: FIXTURE_TURN_ID,
931953 status: "completed",
......@@ -939,7 +961,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested",
939961 messageId: "msg-user-claude-initial",
940962 text: "Use Claude",
941963 modelSelection: {
942 provider: "claudeAgent",
964 instanceId: ProviderInstanceId.make("claudeAgent"),
943965 model: "claude-sonnet-4-6",
944966 },
945967 });
......@@ -955,7 +977,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested",
955977 );
956978 assert.equal(thread.session?.providerName, "claudeAgent");
957979 }),
958 "claudeAgent",
980 CLAUDE_AGENT_PROVIDER,
959981 ),
960982);
961983
......@@ -969,20 +991,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re
969991 events: [
970992 {
971993 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 ),
973999 threadId: THREAD_ID,
9741000 turnId: FIXTURE_TURN_ID,
9751001 },
9761002 {
9771003 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 ),
9791009 threadId: THREAD_ID,
9801010 turnId: FIXTURE_TURN_ID,
9811011 delta: "Turn before restart.\n",
9821012 },
9831013 {
9841014 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 ),
9861020 threadId: THREAD_ID,
9871021 turnId: FIXTURE_TURN_ID,
9881022 status: "completed",
......@@ -996,7 +1030,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re
9961030 messageId: "msg-user-claude-recover-1",
9971031 text: "Before restart",
9981032 modelSelection: {
999 provider: "claudeAgent",
1033 instanceId: ProviderInstanceId.make("claudeAgent"),
10001034 model: "claude-sonnet-4-6",
10011035 },
10021036 });
......@@ -1018,20 +1052,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re
10181052 events: [
10191053 {
10201054 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 ),
10221060 threadId: THREAD_ID,
10231061 turnId: FIXTURE_TURN_ID,
10241062 },
10251063 {
10261064 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 ),
10281070 threadId: THREAD_ID,
10291071 turnId: FIXTURE_TURN_ID,
10301072 delta: "Turn after restart.\n",
10311073 },
10321074 {
10331075 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 ),
10351081 threadId: THREAD_ID,
10361082 turnId: FIXTURE_TURN_ID,
10371083 status: "completed",
......@@ -1063,7 +1109,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re
10631109 assert.equal(recoveredThread.session?.providerName, "claudeAgent");
10641110 assert.equal(recoveredThread.session?.threadId, "thread-1");
10651111 }),
1066 "claudeAgent",
1112 CLAUDE_AGENT_PROVIDER,
10671113 ),
10681114);
10691115
......@@ -1077,13 +1123,21 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>
10771123 events: [
10781124 {
10791125 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 ),
10811131 threadId: THREAD_ID,
10821132 turnId: FIXTURE_TURN_ID,
10831133 },
10841134 {
10851135 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 ),
10871141 threadId: THREAD_ID,
10881142 turnId: FIXTURE_TURN_ID,
10891143 requestId: APPROVAL_REQUEST_ID,
......@@ -1092,7 +1146,11 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>
10921146 },
10931147 {
10941148 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 ),
10961154 threadId: THREAD_ID,
10971155 turnId: FIXTURE_TURN_ID,
10981156 status: "completed",
......@@ -1106,7 +1164,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>
11061164 messageId: "msg-user-claude-approval",
11071165 text: "Need approval",
11081166 modelSelection: {
1109 provider: "claudeAgent",
1167 instanceId: ProviderInstanceId.make("claudeAgent"),
11101168 model: "claude-sonnet-4-6",
11111169 },
11121170 });
......@@ -1137,7 +1195,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>
11371195 );
11381196 assert.equal(approvalResponses[0]?.decision, "accept");
11391197 }),
1140 "claudeAgent",
1198 CLAUDE_AGENT_PROVIDER,
11411199 ),
11421200);
11431201
......@@ -1151,20 +1209,32 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>
11511209 events: [
11521210 {
11531211 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 ),
11551217 threadId: THREAD_ID,
11561218 turnId: FIXTURE_TURN_ID,
11571219 },
11581220 {
11591221 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 ),
11611227 threadId: THREAD_ID,
11621228 turnId: FIXTURE_TURN_ID,
11631229 delta: "Long running output.\n",
11641230 },
11651231 {
11661232 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 ),
11681238 threadId: THREAD_ID,
11691239 turnId: FIXTURE_TURN_ID,
11701240 status: "completed",
......@@ -1178,7 +1248,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>
11781248 messageId: "msg-user-claude-interrupt",
11791249 text: "Start long turn",
11801250 modelSelection: {
1181 provider: "claudeAgent",
1251 instanceId: ProviderInstanceId.make("claudeAgent"),
11821252 model: "claude-sonnet-4-6",
11831253 },
11841254 });
......@@ -1206,7 +1276,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>
12061276 );
12071277 assert.equal(interruptCalls.length, 1);
12081278 }),
1209 "claudeAgent",
1279 CLAUDE_AGENT_PROVIDER,
12101280 ),
12111281);
12121282
......@@ -1220,20 +1290,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",
12201290 events: [
12211291 {
12221292 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 ),
12241298 threadId: THREAD_ID,
12251299 turnId: FIXTURE_TURN_ID,
12261300 },
12271301 {
12281302 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 ),
12301308 threadId: THREAD_ID,
12311309 turnId: FIXTURE_TURN_ID,
12321310 delta: "README -> v2\n",
12331311 },
12341312 {
12351313 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 ),
12371319 threadId: THREAD_ID,
12381320 turnId: FIXTURE_TURN_ID,
12391321 status: "completed",
......@@ -1251,7 +1333,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",
12511333 messageId: "msg-user-claude-revert-1",
12521334 text: "First Claude edit",
12531335 modelSelection: {
1254 provider: "claudeAgent",
1336 instanceId: ProviderInstanceId.make("claudeAgent"),
12551337 model: "claude-sonnet-4-6",
12561338 },
12571339 });
......@@ -1266,20 +1348,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",
12661348 events: [
12671349 {
12681350 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 ),
12701356 threadId: THREAD_ID,
12711357 turnId: FIXTURE_TURN_ID,
12721358 },
12731359 {
12741360 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 ),
12761366 threadId: THREAD_ID,
12771367 turnId: FIXTURE_TURN_ID,
12781368 delta: "README -> v3\n",
12791369 },
12801370 {
12811371 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 ),
12831377 threadId: THREAD_ID,
12841378 turnId: FIXTURE_TURN_ID,
12851379 status: "completed",
......@@ -1330,6 +1424,6 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",
13301424 );
13311425 assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]);
13321426 }),
1333 "claudeAgent",
1427 CLAUDE_AGENT_PROVIDER,
13341428 ),
13351429);
apps/server/integration/providerService.integration.test.ts+24-13
......@@ -1,13 +1,17 @@
11import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";
2import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
33import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import { it, assert } from "@effect/vitest";
66import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
77
8import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
98import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
9import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
1010import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
11import {
12 NoOpProviderEventLoggers,
13 ProviderEventLoggers,
14} from "../src/provider/Layers/ProviderEventLoggers.ts";
1115import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
1216import {
1317 ProviderService,
......@@ -29,6 +33,8 @@ import {
2933 codexTurnTextFixture,
3034} from "./fixtures/providerRuntime.ts";
3135
36const codexInstanceId = ProviderInstanceId.make("codex");
37
3238const makeWorkspaceDirectory = Effect.gen(function* () {
3339 const fs = yield* FileSystem.FileSystem;
3440 const pathService = yield* Path.Path;
......@@ -47,13 +53,9 @@ const makeIntegrationFixture = Effect.gen(function* () {
4753 const cwd = yield* makeWorkspaceDirectory;
4854 const harness = yield* makeTestProviderAdapterHarness();
4955
50 const registry: typeof ProviderAdapterRegistry.Service = {
51 getByProvider: (provider) =>
52 provider === "codex"
53 ? Effect.succeed(harness.adapter)
54 : Effect.fail(new ProviderUnsupportedError({ provider })),
55 listProviders: () => Effect.succeed(["codex"]),
56 };
56 const registry = makeAdapterRegistryMock({
57 [ProviderDriverKind.make("codex")]: harness.adapter,
58 });
5759
5860 const directoryLayer = ProviderSessionDirectoryLive.pipe(
5961 Layer.provide(ProviderSessionRuntimeRepositoryLive),
......@@ -64,6 +66,7 @@ const makeIntegrationFixture = Effect.gen(function* () {
6466 Layer.succeed(ProviderAdapterRegistry, registry),
6567 ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS),
6668 AnalyticsService.layerTest,
69 Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers),
6770 ).pipe(Layer.provide(SqlitePersistenceMemory));
6871
6972 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));
......@@ -124,7 +127,8 @@ it.live("replays typed runtime fixture events", () =>
124127 const provider = yield* ProviderService;
125128 const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), {
126129 threadId: ThreadId.make("thread-integration-typed"),
127 provider: "codex",
130 provider: ProviderDriverKind.make("codex"),
131 providerInstanceId: codexInstanceId,
128132 cwd: fixture.cwd,
129133 runtimeMode: "full-access",
130134 });
......@@ -142,6 +146,10 @@ it.live("replays typed runtime fixture events", () =>
142146 observedEvents.map((event) => event.type),
143147 codexTurnTextFixture.map((event) => event.type),
144148 );
149 assert.deepEqual(
150 observedEvents.map((event) => event.providerInstanceId),
151 codexTurnTextFixture.map(() => codexInstanceId),
152 );
145153 }).pipe(Effect.provide(fixture.layer));
146154 }).pipe(Effect.provide(NodeServices.layer)),
147155);
......@@ -156,7 +164,8 @@ it.live("replays file-changing fixture turn events", () =>
156164 const provider = yield* ProviderService;
157165 const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), {
158166 threadId: ThreadId.make("thread-integration-tools"),
159 provider: "codex",
167 provider: ProviderDriverKind.make("codex"),
168 providerInstanceId: codexInstanceId,
160169 cwd: fixture.cwd,
161170 runtimeMode: "full-access",
162171 });
......@@ -192,7 +201,8 @@ it.live("runs multi-turn tool/approval flow", () =>
192201 const provider = yield* ProviderService;
193202 const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), {
194203 threadId: ThreadId.make("thread-integration-multi"),
195 provider: "codex",
204 provider: ProviderDriverKind.make("codex"),
205 providerInstanceId: codexInstanceId,
196206 cwd: fixture.cwd,
197207 runtimeMode: "full-access",
198208 });
......@@ -243,7 +253,8 @@ it.live("rolls back provider conversation state only", () =>
243253 const provider = yield* ProviderService;
244254 const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), {
245255 threadId: ThreadId.make("thread-integration-rollback"),
246 provider: "codex",
256 provider: ProviderDriverKind.make("codex"),
257 providerInstanceId: codexInstanceId,
247258 cwd: fixture.cwd,
248259 runtimeMode: "full-access",
249260 });
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+172-139
......@@ -1,24 +1,18 @@
1import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { Effect, FileSystem, Layer, Path, Schema } from "effect";
45import { createModelSelection } from "@t3tools/shared/model";
56import { expect } from "vitest";
67
78import { ServerConfig } from "../../config.ts";
8import { TextGeneration } from "../Services/TextGeneration.ts";
9import { type TextGenerationShape } from "../Services/TextGeneration.ts";
910import { sanitizeThreadTitle } from "../Utils.ts";
10import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
11import { ServerSettingsService } from "../../serverSettings.ts";
11import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
1212
13const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(
14 Layer.provideMerge(ServerSettingsService.layerTest()),
15 Layer.provideMerge(
16 ServerConfig.layerTest(process.cwd(), {
17 prefix: "t3code-claude-text-generation-test-",
18 }),
19 ),
20 Layer.provideMerge(NodeServices.layer),
21);
13const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
14 prefix: "t3code-claude-text-generation-test-",
15}).pipe(Layer.provideMerge(NodeServices.layer));
2216
2317function makeFakeClaudeBinary(dir: string) {
2418 return Effect.gen(function* () {
......@@ -52,6 +46,10 @@ function makeFakeClaudeBinary(dir: string) {
5246 " exit 4",
5347 " }",
5448 "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",
5553 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',
5654 ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',
5755 "fi",
......@@ -73,23 +71,26 @@ function withFakeClaudeEnv<A, E, R>(
7371 argsMustContain?: string;
7472 argsMustNotContain?: string;
7573 stdinMustContain?: string;
74 homeMustBe?: string;
75 claudeConfig?: Partial<ClaudeSettings>;
7676 },
77 effect: Effect.Effect<A, E, R>,
77 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
7878) {
79 return Effect.acquireUseRelease(
80 Effect.gen(function* () {
81 const fs = yield* FileSystem.FileSystem;
82 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
83 const binDir = yield* makeFakeClaudeBinary(tempDir);
84 const previousPath = process.env.PATH;
85 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
86 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
87 const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
88 const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
89 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
90 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
79 return Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
82 const binDir = yield* makeFakeClaudeBinary(tempDir);
83 const previousPath = process.env.PATH;
84 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
85 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
86 const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
87 const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
88 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
89 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(() => {
9394 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
9495 process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
9596
......@@ -122,60 +123,65 @@ function withFakeClaudeEnv<A, E, R>(
122123 } else {
123124 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
124125 }
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) {
143 delete process.env.T3_FAKE_CLAUDE_OUTPUT;
127 if (input.homeMustBe !== undefined) {
128 process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe;
144129 } else {
145 process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput;
130 delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
146131 }
132 }),
133 () =>
134 Effect.sync(() => {
135 process.env.PATH = previousPath;
147136
148 if (previous.previousExitCode === undefined) {
149 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
150 } else {
151 process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode;
152 }
137 if (previousOutput === undefined) {
138 delete process.env.T3_FAKE_CLAUDE_OUTPUT;
139 } else {
140 process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput;
141 }
153142
154 if (previous.previousStderr === undefined) {
155 delete process.env.T3_FAKE_CLAUDE_STDERR;
156 } else {
157 process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr;
158 }
143 if (previousExitCode === undefined) {
144 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
145 } else {
146 process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode;
147 }
159148
160 if (previous.previousArgsMustContain === undefined) {
161 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
162 } else {
163 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain;
164 }
149 if (previousStderr === undefined) {
150 delete process.env.T3_FAKE_CLAUDE_STDERR;
151 } else {
152 process.env.T3_FAKE_CLAUDE_STDERR = previousStderr;
153 }
165154
166 if (previous.previousArgsMustNotContain === undefined) {
167 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
168 } else {
169 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain;
170 }
155 if (previousArgsMustContain === undefined) {
156 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
157 } else {
158 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain;
159 }
171160
172 if (previous.previousStdinMustContain === undefined) {
173 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
174 } else {
175 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain;
176 }
177 }),
178 );
161 if (previousArgsMustNotContain === undefined) {
162 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
163 } else {
164 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain;
165 }
166
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);
179185}
180186
181187it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
......@@ -191,24 +197,23 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
191197 argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
192198 argsMustNotContain: "--effort",
193199 },
194 Effect.gen(function* () {
195 const textGeneration = yield* TextGeneration;
196
197 const generated = yield* textGeneration.generateCommitMessage({
198 cwd: process.cwd(),
199 branch: "feature/claude-effect",
200 stagedSummary: "M README.md",
201 stagedPatch: "diff --git a/README.md b/README.md",
202 modelSelection: {
203 ...createModelSelection("claudeAgent", "claude-haiku-4-5", [
204 { id: "thinking", value: false },
205 { id: "effort", value: "high" },
206 ]),
207 },
208 });
200 (textGeneration) =>
201 Effect.gen(function* () {
202 const generated = yield* textGeneration.generateCommitMessage({
203 cwd: process.cwd(),
204 branch: "feature/claude-effect",
205 stagedSummary: "M README.md",
206 stagedPatch: "diff --git a/README.md b/README.md",
207 modelSelection: {
208 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [
209 { id: "thinking", value: false },
210 { id: "effort", value: "high" },
211 ]),
212 },
213 });
209214
210 expect(generated.subject).toBe("Add important change");
211 }),
215 expect(generated.subject).toBe("Add important change");
216 }),
212217 ),
213218 );
214219
......@@ -223,26 +228,25 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
223228 }),
224229 argsMustContain: '--effort max --settings {"fastMode":true}',
225230 },
226 Effect.gen(function* () {
227 const textGeneration = yield* TextGeneration;
228
229 const generated = yield* textGeneration.generatePrContent({
230 cwd: process.cwd(),
231 baseBranch: "main",
232 headBranch: "feature/claude-effect",
233 commitSummary: "Improve orchestration",
234 diffSummary: "1 file changed",
235 diffPatch: "diff --git a/README.md b/README.md",
236 modelSelection: {
237 ...createModelSelection("claudeAgent", "claude-opus-4-6", [
238 { id: "effort", value: "max" },
239 { id: "fastMode", value: true },
240 ]),
241 },
242 });
231 (textGeneration) =>
232 Effect.gen(function* () {
233 const generated = yield* textGeneration.generatePrContent({
234 cwd: process.cwd(),
235 baseBranch: "main",
236 headBranch: "feature/claude-effect",
237 commitSummary: "Improve orchestration",
238 diffSummary: "1 file changed",
239 diffPatch: "diff --git a/README.md b/README.md",
240 modelSelection: {
241 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [
242 { id: "effort", value: "max" },
243 { id: "fastMode", value: true },
244 ]),
245 },
246 });
243247
244 expect(generated.title).toBe("Improve orchestration flow");
245 }),
248 expect(generated.title).toBe("Improve orchestration flow");
249 }),
246250 ),
247251 );
248252
......@@ -257,27 +261,57 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
257261 }),
258262 stdinMustContain: "You write concise thread titles for coding conversations.",
259263 },
260 Effect.gen(function* () {
261 const textGeneration = yield* TextGeneration;
264 (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({
264 cwd: process.cwd(),
265 message: "Please investigate reconnect failures after restarting the session.",
266 modelSelection: {
267 provider: "claudeAgent",
268 model: "claude-sonnet-4-6",
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 }),
275 expect(generated.title).toBe(
276 sanitizeThreadTitle(
277 '"Reconnect failures after restart because the session state does not recover"',
278 ),
279 );
280 }),
278281 ),
279282 );
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
281315 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
282316 withFakeClaudeEnv(
283317 {
......@@ -287,20 +321,19 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
287321 },
288322 }),
289323 },
290 Effect.gen(function* () {
291 const textGeneration = yield* TextGeneration;
292
293 const generated = yield* textGeneration.generateThreadTitle({
294 cwd: process.cwd(),
295 message: "Name this thread.",
296 modelSelection: {
297 provider: "claudeAgent",
298 model: "claude-sonnet-4-6",
299 },
300 });
324 (textGeneration) =>
325 Effect.gen(function* () {
326 const generated = yield* textGeneration.generateThreadTitle({
327 cwd: process.cwd(),
328 message: "Name this thread.",
329 modelSelection: {
330 instanceId: ProviderInstanceId.make("claudeAgent"),
331 model: "claude-sonnet-4-6",
332 },
333 });
301334
302 expect(generated.title).toBe("New thread");
303 }),
335 expect(generated.title).toBe("New thread");
336 }),
304337 ),
305338 );
306339});
apps/server/src/git/Layers/ClaudeTextGeneration.ts+12-43
......@@ -7,14 +7,14 @@
77 *
88 * @module ClaudeTextGeneration
99 */
10import { Effect, Layer, Option, Schema, Stream } from "effect";
10import { Effect, Option, Schema, Stream } from "effect";
1111import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
13import { ClaudeModelSelection } from "@t3tools/contracts";
13import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";
1414import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1515
1616import { TextGenerationError } from "@t3tools/contracts";
17import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
17import { type TextGenerationShape } from "../Services/TextGeneration.ts";
1818import {
1919 buildBranchNamePrompt,
2020 buildCommitMessagePrompt,
......@@ -38,7 +38,7 @@ import {
3838 resolveClaudeApiModelId,
3939 resolveClaudeEffort,
4040} from "../../provider/Layers/ClaudeProvider.ts";
41import { ServerSettingsService } from "../../serverSettings.ts";
41import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
4242
4343const CLAUDE_TIMEOUT_MS = 180_000;
4444
......@@ -50,9 +50,12 @@ const ClaudeOutputEnvelope = Schema.Struct({
5050 structured_output: Schema.Unknown,
5151});
5252
53const makeClaudeTextGeneration = Effect.gen(function* () {
53export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
54 claudeSettings: ClaudeSettings,
55 environment: NodeJS.ProcessEnv = process.env,
56) {
5457 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
55 const serverSettingsService = yield* Effect.service(ServerSettingsService);
58 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
5659
5760 const readStreamAsString = <E>(
5861 operation: string,
......@@ -88,7 +91,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
8891 cwd: string;
8992 prompt: string;
9093 outputSchemaJson: S;
91 modelSelection: ClaudeModelSelection;
94 modelSelection: ModelSelection;
9295 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
9396 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
9497 const caps = getClaudeModelCapabilities(modelSelection.model);
......@@ -111,14 +114,9 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
111114 ...(fastMode ? { fastMode: true } : {}),
112115 };
113116
114 const claudeSettings = yield* Effect.map(
115 serverSettingsService.getSettings,
116 (settings) => settings.providers.claudeAgent,
117 ).pipe(Effect.catch(() => Effect.undefined));
118
119117 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
120118 const command = ChildProcess.make(
121 claudeSettings?.binaryPath || "claude",
119 claudeSettings.binaryPath || "claude",
122120 [
123121 "-p",
124122 "--output-format",
......@@ -132,6 +130,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
132130 "--dangerously-skip-permissions",
133131 ],
134132 {
133 env: claudeEnvironment,
135134 cwd,
136135 shell: process.platform === "win32",
137136 stdin: {
......@@ -232,13 +231,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
232231 includeBranch: input.includeBranch === true,
233232 });
234233
235 if (input.modelSelection.provider !== "claudeAgent") {
236 return yield* new TextGenerationError({
237 operation: "generateCommitMessage",
238 detail: "Invalid model selection.",
239 });
240 }
241
242234 const generated = yield* runClaudeJson({
243235 operation: "generateCommitMessage",
244236 cwd: input.cwd,
......@@ -267,13 +259,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
267259 diffPatch: input.diffPatch,
268260 });
269261
270 if (input.modelSelection.provider !== "claudeAgent") {
271 return yield* new TextGenerationError({
272 operation: "generatePrContent",
273 detail: "Invalid model selection.",
274 });
275 }
276
277262 const generated = yield* runClaudeJson({
278263 operation: "generatePrContent",
279264 cwd: input.cwd,
......@@ -296,13 +281,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
296281 attachments: input.attachments,
297282 });
298283
299 if (input.modelSelection.provider !== "claudeAgent") {
300 return yield* new TextGenerationError({
301 operation: "generateBranchName",
302 detail: "Invalid model selection.",
303 });
304 }
305
306284 const generated = yield* runClaudeJson({
307285 operation: "generateBranchName",
308286 cwd: input.cwd,
......@@ -324,13 +302,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
324302 attachments: input.attachments,
325303 });
326304
327 if (input.modelSelection.provider !== "claudeAgent") {
328 return yield* new TextGenerationError({
329 operation: "generateThreadTitle",
330 detail: "Invalid model selection.",
331 });
332 }
333
334305 const generated = yield* runClaudeJson({
335306 operation: "generateThreadTitle",
336307 cwd: input.cwd,
......@@ -351,5 +322,3 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
351322 generateThreadTitle,
352323 } satisfies TextGenerationShape;
353324});
354
355export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);
apps/server/src/git/Layers/CodexTextGeneration.test.ts+243-285
......@@ -1,30 +1,24 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, Result } from "effect";
3import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect";
44import { createModelSelection } from "@t3tools/shared/model";
55import { expect } from "vitest";
66
7import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
8
79import { ServerConfig } from "../../config.ts";
8import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
9import { TextGenerationError } from "@t3tools/contracts";
10import { TextGeneration } from "../Services/TextGeneration.ts";
11import { ServerSettingsService } from "../../serverSettings.ts";
12
13const DEFAULT_TEST_MODEL_SELECTION = {
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),
10import { type TextGenerationShape } from "../Services/TextGeneration.ts";
11import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
12
13const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
14 ProviderInstanceId.make("codex"),
15 "gpt-5.4-mini",
2616);
2717
18const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
19 prefix: "t3code-codex-text-generation-test-",
20}).pipe(Layer.provideMerge(NodeServices.layer));
21
2822function makeFakeCodexBinary(
2923 dir: string,
3024 input: {
......@@ -162,36 +156,16 @@ function withFakeCodexEnv<A, E, R>(
162156 stdinMustContain?: string;
163157 stdinMustNotContain?: string;
164158 },
165 effect: Effect.Effect<A, E, R>,
159 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
166160) {
167 return Effect.acquireUseRelease(
168 Effect.gen(function* () {
169 const fs = yield* FileSystem.FileSystem;
170 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
171 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
172 const serverSettings = yield* ServerSettingsService;
173 const previousSettings = yield* serverSettings.getSettings;
174 yield* serverSettings.updateSettings({
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 );
161 return Effect.gen(function* () {
162 const fs = yield* FileSystem.FileSystem;
163 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
164 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
165 const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath });
166 const textGeneration = yield* makeCodexTextGeneration(config);
167 return yield* effectFn(textGeneration);
168 }).pipe(Effect.scoped);
195169}
196170
197171it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
......@@ -205,22 +179,21 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
205179 }),
206180 stdinMustNotContain: "branch must be a short semantic git branch fragment",
207181 },
208 Effect.gen(function* () {
209 const textGeneration = yield* TextGeneration;
210
211 const generated = yield* textGeneration.generateCommitMessage({
212 cwd: process.cwd(),
213 branch: "feature/codex-effect",
214 stagedSummary: "M README.md",
215 stagedPatch: "diff --git a/README.md b/README.md",
216 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
217 });
182 (textGeneration) =>
183 Effect.gen(function* () {
184 const generated = yield* textGeneration.generateCommitMessage({
185 cwd: process.cwd(),
186 branch: "feature/codex-effect",
187 stagedSummary: "M README.md",
188 stagedPatch: "diff --git a/README.md b/README.md",
189 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
190 });
218191
219 expect(generated.subject.length).toBeLessThanOrEqual(72);
220 expect(generated.subject.endsWith(".")).toBe(false);
221 expect(generated.body).toBe("- added migration\n- updated tests");
222 expect(generated.branch).toBeUndefined();
223 }),
192 expect(generated.subject.length).toBeLessThanOrEqual(72);
193 expect(generated.subject.endsWith(".")).toBe(false);
194 expect(generated.body).toBe("- added migration\n- updated tests");
195 expect(generated.branch).toBeUndefined();
196 }),
224197 ),
225198 );
226199
......@@ -237,20 +210,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
237210 requireReasoningEffort: "xhigh",
238211 stdinMustNotContain: "branch must be a short semantic git branch fragment",
239212 },
240 Effect.gen(function* () {
241 const textGeneration = yield* TextGeneration;
242
243 yield* textGeneration.generateCommitMessage({
213 (textGeneration) =>
214 textGeneration.generateCommitMessage({
244215 cwd: process.cwd(),
245216 branch: "feature/codex-effect",
246217 stagedSummary: "M README.md",
247218 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", [
249220 { id: "reasoningEffort", value: "xhigh" },
250221 { id: "fastMode", value: true },
251222 ]),
252 });
253 }),
223 }),
254224 ),
255225 );
256226
......@@ -263,17 +233,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
263233 }),
264234 requireReasoningEffort: "low",
265235 },
266 Effect.gen(function* () {
267 const textGeneration = yield* TextGeneration;
268
269 yield* textGeneration.generateCommitMessage({
236 (textGeneration) =>
237 textGeneration.generateCommitMessage({
270238 cwd: process.cwd(),
271239 branch: "feature/codex-effect",
272240 stagedSummary: "M README.md",
273241 stagedPatch: "diff --git a/README.md b/README.md",
274242 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
275 });
276 }),
243 }),
277244 ),
278245 );
279246
......@@ -287,21 +254,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
287254 }),
288255 stdinMustContain: "branch must be a short semantic git branch fragment",
289256 },
290 Effect.gen(function* () {
291 const textGeneration = yield* TextGeneration;
292
293 const generated = yield* textGeneration.generateCommitMessage({
294 cwd: process.cwd(),
295 branch: "feature/codex-effect",
296 stagedSummary: "M README.md",
297 stagedPatch: "diff --git a/README.md b/README.md",
298 includeBranch: true,
299 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
300 });
257 (textGeneration) =>
258 Effect.gen(function* () {
259 const generated = yield* textGeneration.generateCommitMessage({
260 cwd: process.cwd(),
261 branch: "feature/codex-effect",
262 stagedSummary: "M README.md",
263 stagedPatch: "diff --git a/README.md b/README.md",
264 includeBranch: true,
265 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
266 });
301267
302 expect(generated.subject).toBe("Add important change");
303 expect(generated.branch).toBe("feature/fix/important-system-change");
304 }),
268 expect(generated.subject).toBe("Add important change");
269 expect(generated.branch).toBe("feature/fix/important-system-change");
270 }),
305271 ),
306272 );
307273
......@@ -313,23 +279,22 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
313279 body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n",
314280 }),
315281 },
316 Effect.gen(function* () {
317 const textGeneration = yield* TextGeneration;
318
319 const generated = yield* textGeneration.generatePrContent({
320 cwd: process.cwd(),
321 baseBranch: "main",
322 headBranch: "feature/codex-effect",
323 commitSummary: "feat: improve orchestration flow",
324 diffSummary: "2 files changed",
325 diffPatch: "diff --git a/a.ts b/a.ts",
326 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
327 });
282 (textGeneration) =>
283 Effect.gen(function* () {
284 const generated = yield* textGeneration.generatePrContent({
285 cwd: process.cwd(),
286 baseBranch: "main",
287 headBranch: "feature/codex-effect",
288 commitSummary: "feat: improve orchestration flow",
289 diffSummary: "2 files changed",
290 diffPatch: "diff --git a/a.ts b/a.ts",
291 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
292 });
328293
329 expect(generated.title).toBe("Improve orchestration flow");
330 expect(generated.body.startsWith("## Summary")).toBe(true);
331 expect(generated.body.endsWith("\n\n")).toBe(false);
332 }),
294 expect(generated.title).toBe("Improve orchestration flow");
295 expect(generated.body.startsWith("## Summary")).toBe(true);
296 expect(generated.body.endsWith("\n\n")).toBe(false);
297 }),
333298 ),
334299 );
335300
......@@ -341,17 +306,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
341306 }),
342307 stdinMustNotContain: "Image attachments supplied to the model",
343308 },
344 Effect.gen(function* () {
345 const textGeneration = yield* TextGeneration;
346
347 const generated = yield* textGeneration.generateBranchName({
348 cwd: process.cwd(),
349 message: "Please update session handling.",
350 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
351 });
309 (textGeneration) =>
310 Effect.gen(function* () {
311 const generated = yield* textGeneration.generateBranchName({
312 cwd: process.cwd(),
313 message: "Please update session handling.",
314 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
315 });
352316
353 expect(generated.branch).toBe("feat/session");
354 }),
317 expect(generated.branch).toBe("feat/session");
318 }),
355319 ),
356320 );
357321
......@@ -363,17 +327,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
363327 ' "Investigate websocket reconnect regressions after worktree restore" \nignored line',
364328 }),
365329 },
366 Effect.gen(function* () {
367 const textGeneration = yield* TextGeneration;
368
369 const generated = yield* textGeneration.generateThreadTitle({
370 cwd: process.cwd(),
371 message: "Please investigate websocket reconnect regressions after a worktree restore.",
372 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
373 });
330 (textGeneration) =>
331 Effect.gen(function* () {
332 const generated = yield* textGeneration.generateThreadTitle({
333 cwd: process.cwd(),
334 message: "Please investigate websocket reconnect regressions after a worktree restore.",
335 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
336 });
374337
375 expect(generated.title).toBe("Investigate websocket reconnect regressions aft...");
376 }),
338 expect(generated.title).toBe("Investigate websocket reconnect regressions aft...");
339 }),
377340 ),
378341 );
379342
......@@ -384,17 +347,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
384347 title: ' """ """ ',
385348 }),
386349 },
387 Effect.gen(function* () {
388 const textGeneration = yield* TextGeneration;
389
390 const generated = yield* textGeneration.generateThreadTitle({
391 cwd: process.cwd(),
392 message: "Name this thread.",
393 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
394 });
350 (textGeneration) =>
351 Effect.gen(function* () {
352 const generated = yield* textGeneration.generateThreadTitle({
353 cwd: process.cwd(),
354 message: "Name this thread.",
355 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
356 });
395357
396 expect(generated.title).toBe("New thread");
397 }),
358 expect(generated.title).toBe("New thread");
359 }),
398360 ),
399361 );
400362
......@@ -405,17 +367,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
405367 title: ` "' hello world '" `,
406368 }),
407369 },
408 Effect.gen(function* () {
409 const textGeneration = yield* TextGeneration;
410
411 const generated = yield* textGeneration.generateThreadTitle({
412 cwd: process.cwd(),
413 message: "Name this thread.",
414 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
415 });
370 (textGeneration) =>
371 Effect.gen(function* () {
372 const generated = yield* textGeneration.generateThreadTitle({
373 cwd: process.cwd(),
374 message: "Name this thread.",
375 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
376 });
416377
417 expect(generated.title).toBe("hello world");
418 }),
378 expect(generated.title).toBe("hello world");
379 }),
419380 ),
420381 );
421382
......@@ -427,17 +388,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
427388 }),
428389 stdinMustNotContain: "Attachment metadata:",
429390 },
430 Effect.gen(function* () {
431 const textGeneration = yield* TextGeneration;
432
433 const generated = yield* textGeneration.generateBranchName({
434 cwd: process.cwd(),
435 message: "Fix timeout behavior.",
436 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
437 });
391 (textGeneration) =>
392 Effect.gen(function* () {
393 const generated = yield* textGeneration.generateBranchName({
394 cwd: process.cwd(),
395 message: "Fix timeout behavior.",
396 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
397 });
438398
439 expect(generated.branch).toBe("fix/session-timeout");
440 }),
399 expect(generated.branch).toBe("fix/session-timeout");
400 }),
441401 ),
442402 );
443403
......@@ -450,56 +410,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
450410 requireImage: true,
451411 stdinMustContain: "Attachment metadata:",
452412 },
453 Effect.gen(function* () {
454 const fs = yield* FileSystem.FileSystem;
455 const path = yield* Path.Path;
456 const { attachmentsDir } = yield* ServerConfig;
457 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
458 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
459 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
460 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
461
462 const textGeneration = yield* TextGeneration;
463 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({
413 (textGeneration) =>
414 Effect.gen(function* () {
415 const fs = yield* FileSystem.FileSystem;
416 const path = yield* Path.Path;
417 const { attachmentsDir } = yield* ServerConfig;
418 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
419 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
420 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
421 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
422
423 const generated = yield* textGeneration.generateBranchName({
503424 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
504425 cwd: process.cwd(),
505426 message: "Fix layout bug from screenshot.",
......@@ -512,24 +433,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
512433 sizeBytes: 5,
513434 },
514435 ],
515 })
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 );
436 });
526437
527 expect(generated.branch).toBe("fix/ui-regression");
528 }),
438 expect(generated.branch).toBe("fix/ui-regression");
439 }),
529440 ),
530441 );
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", () =>
533444 withFakeCodexEnv(
534445 {
535446 output: JSON.stringify({
......@@ -537,67 +448,115 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
537448 }),
538449 requireImage: true,
539450 },
540 Effect.gen(function* () {
541 const fs = yield* FileSystem.FileSystem;
542 const path = yield* Path.Path;
543 const { attachmentsDir } = yield* ServerConfig;
544 const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
545 const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`);
546 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));
547
548 const textGeneration = yield* TextGeneration;
549 const result = yield* textGeneration
550 .generateBranchName({
551 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
552 cwd: process.cwd(),
553 message: "Fix layout bug from screenshot.",
554 attachments: [
555 {
556 type: "image",
557 id: missingAttachmentId,
558 name: "outside.png",
559 mimeType: "image/png",
560 sizeBytes: 5,
561 },
562 ],
563 })
564 .pipe(Effect.result);
565
566 expect(Result.isFailure(result)).toBe(true);
567 if (Result.isFailure(result)) {
568 expect(result.failure).toBeInstanceOf(TextGenerationError);
569 expect(result.failure.message).toContain("missing --image input");
570 }
571 }),
451 (textGeneration) =>
452 Effect.gen(function* () {
453 const fs = yield* FileSystem.FileSystem;
454 const path = yield* Path.Path;
455 const { attachmentsDir } = yield* ServerConfig;
456 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
457 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
458 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
459 yield* fs.writeFile(imagePath, Buffer.from("hello"));
460
461 const generated = yield* textGeneration
462 .generateBranchName({
463 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
464 cwd: process.cwd(),
465 message: "Fix layout bug from screenshot.",
466 attachments: [
467 {
468 type: "image",
469 id: attachmentId,
470 name: "bug.png",
471 mimeType: "image/png",
472 sizeBytes: 5,
473 },
474 ],
475 })
476 .pipe(
477 Effect.tap(() =>
478 fs.stat(imagePath).pipe(
479 Effect.map((fileInfo) => {
480 expect(fileInfo.type).toBe("File");
481 }),
482 ),
483 ),
484 Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))),
485 );
486
487 expect(generated.branch).toBe("fix/ui-regression");
488 }),
572489 ),
573490 );
574491
575 it.effect(
576 "fails with typed TextGenerationError when codex returns wrong branch payload shape",
577 () =>
578 withFakeCodexEnv(
579 {
580 output: JSON.stringify({
581 title: "This is not a branch payload",
582 }),
583 },
492 it.effect("ignores missing attachment ids for codex image inputs", () =>
493 withFakeCodexEnv(
494 {
495 output: JSON.stringify({
496 branch: "fix/ui-regression",
497 }),
498 requireImage: true,
499 },
500 (textGeneration) =>
584501 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
587509 const result = yield* textGeneration
588510 .generateBranchName({
589 cwd: process.cwd(),
590 message: "Fix websocket reconnect flake",
591511 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 ],
592523 })
593524 .pipe(Effect.result);
594525
595526 expect(Result.isFailure(result)).toBe(true);
596527 if (Result.isFailure(result)) {
597528 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");
599530 }
600531 }),
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 }),
601560 ),
602561 );
603562
......@@ -608,27 +567,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
608567 exitCode: 1,
609568 stderr: "codex execution failed",
610569 },
611 Effect.gen(function* () {
612 const textGeneration = yield* TextGeneration;
570 (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* textGeneration
615 .generateCommitMessage({
616 cwd: process.cwd(),
617 branch: "feature/codex-error",
618 stagedSummary: "M README.md",
619 stagedPatch: "diff --git a/README.md b/README.md",
620 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
621 })
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 }),
582 expect(Result.isFailure(result)).toBe(true);
583 if (Result.isFailure(result)) {
584 expect(result.failure).toBeInstanceOf(TextGenerationError);
585 expect(result.failure.message).toContain(
586 "Codex CLI command failed: codex execution failed",
587 );
588 }
589 }),
632590 ),
633591 );
634592});
apps/server/src/git/Layers/CodexTextGeneration.ts+34-63
......@@ -1,9 +1,7 @@
1import { randomUUID } from "node:crypto";
2
3import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
1import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect";
42import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
53
6import { CodexModelSelection } from "@t3tools/contracts";
4import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";
75import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
86
97import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -14,7 +12,6 @@ import {
1412 type BranchNameGenerationInput,
1513 type ThreadTitleGenerationResult,
1614 type TextGenerationShape,
17 TextGeneration,
1815} from "../Services/TextGeneration.ts";
1916import {
2017 buildBranchNamePrompt,
......@@ -29,7 +26,6 @@ import {
2926 sanitizeThreadTitle,
3027 toJsonSchemaObject,
3128} from "../Utils.ts";
32import { ServerSettingsService } from "../../serverSettings.ts";
3329import {
3430 getModelSelectionBooleanOptionValue,
3531 getModelSelectionStringOptionValue,
......@@ -37,12 +33,18 @@ import {
3733
3834const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
3935const 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) {
4144 const fileSystem = yield* FileSystem.FileSystem;
4245 const path = yield* Path.Path;
4346 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4447 const serverConfig = yield* Effect.service(ServerConfig);
45 const serverSettingsService = yield* Effect.service(ServerSettingsService);
4648
4749 type MaterializedImageAttachments = {
4850 readonly imagePaths: ReadonlyArray<string>;
......@@ -68,21 +70,23 @@ const makeCodexTextGeneration = Effect.gen(function* () {
6870 prefix: string,
6971 content: string,
7072 ): Effect.Effect<string, TextGenerationError, Scope.Scope> => {
71 return fileSystem
72 .makeTempFileScoped({
73 prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
74 })
75 .pipe(
76 Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)),
77 Effect.mapError(
78 (cause) =>
79 new TextGenerationError({
80 operation,
81 detail: `Failed to write temp file`,
82 cause,
83 }),
84 ),
85 );
73 return Effect.gen(function* () {
74 const tempFileId = yield* Random.nextUUIDv4;
75 return yield* fileSystem
76 .makeTempFileScoped({
77 prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`,
78 })
79 .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)));
80 }).pipe(
81 Effect.mapError(
82 (cause) =>
83 new TextGenerationError({
84 operation,
85 detail: `Failed to write temp file`,
86 cause,
87 }),
88 ),
89 );
8690 };
8791
8892 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
......@@ -143,7 +147,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
143147 outputSchemaJson: S;
144148 imagePaths?: ReadonlyArray<string>;
145149 cleanupPaths?: ReadonlyArray<string>;
146 modelSelection: CodexModelSelection;
150 modelSelection: ModelSelection;
147151 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
148152 const schemaPath = yield* writeTempFile(
149153 operation,
......@@ -152,17 +156,12 @@ const makeCodexTextGeneration = Effect.gen(function* () {
152156 );
153157 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
160159 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
161160 const reasoningEffort =
162161 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
163162 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
164163 const command = ChildProcess.make(
165 codexSettings?.binaryPath || "codex",
164 codexConfig.binaryPath || "codex",
166165 [
167166 "exec",
168167 "--ephemeral",
......@@ -185,10 +184,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
185184 ],
186185 {
187186 env: {
188 ...process.env,
189 ...(codexSettings?.homePath
190 ? { CODEX_HOME: expandHomePath(codexSettings.homePath) }
191 : {}),
187 ...environment,
188 ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}),
192189 },
193190 cwd,
194191 shell: process.platform === "win32",
......@@ -288,13 +285,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
288285 includeBranch: input.includeBranch === true,
289286 });
290287
291 if (input.modelSelection.provider !== "codex") {
292 return yield* new TextGenerationError({
293 operation: "generateCommitMessage",
294 detail: "Invalid model selection.",
295 });
296 }
297
298288 const generated = yield* runCodexJson({
299289 operation: "generateCommitMessage",
300290 cwd: input.cwd,
......@@ -323,13 +313,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
323313 diffPatch: input.diffPatch,
324314 });
325315
326 if (input.modelSelection.provider !== "codex") {
327 return yield* new TextGenerationError({
328 operation: "generatePrContent",
329 detail: "Invalid model selection.",
330 });
331 }
332
333316 const generated = yield* runCodexJson({
334317 operation: "generatePrContent",
335318 cwd: input.cwd,
......@@ -356,13 +339,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
356339 attachments: input.attachments,
357340 });
358341
359 if (input.modelSelection.provider !== "codex") {
360 return yield* new TextGenerationError({
361 operation: "generateBranchName",
362 detail: "Invalid model selection.",
363 });
364 }
365
366342 const generated = yield* runCodexJson({
367343 operation: "generateBranchName",
368344 cwd: input.cwd,
......@@ -389,13 +365,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
389365 attachments: input.attachments,
390366 });
391367
392 if (input.modelSelection.provider !== "codex") {
393 return yield* new TextGenerationError({
394 operation: "generateThreadTitle",
395 detail: "Invalid model selection.",
396 });
397 }
398
399368 const generated = yield* runCodexJson({
400369 operation: "generateThreadTitle",
401370 cwd: input.cwd,
......@@ -418,4 +387,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
418387 } satisfies TextGenerationShape;
419388});
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
55
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { it } from "@effect/vitest";
8import { Effect, Layer } from "effect";
8import { Effect, Layer, Schema } from "effect";
99import { createModelSelection } from "@t3tools/shared/model";
1010import { expect } from "vitest";
1111
12import { ServerSettingsError } from "@t3tools/contracts";
12import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
1313
1414import { ServerConfig } from "../../config.ts";
15import { TextGeneration } from "../Services/TextGeneration.ts";
16import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
17import { ServerSettingsService } from "../../serverSettings.ts";
15import { type TextGenerationShape } from "../Services/TextGeneration.ts";
16import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
1817
1918const __dirname = path.dirname(fileURLToPath(import.meta.url));
2019const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
......@@ -23,15 +22,9 @@ function shellSingleQuote(value: string): string {
2322 return `'${value.replaceAll("'", `'"'"'`)}'`;
2423}
2524
26const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(
27 Layer.provideMerge(ServerSettingsService.layerTest()),
28 Layer.provideMerge(
29 ServerConfig.layerTest(process.cwd(), {
30 prefix: "t3code-cursor-text-generation-test-",
31 }),
32 ),
33 Layer.provideMerge(NodeServices.layer),
34);
25const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
26 prefix: "t3code-cursor-text-generation-test-",
27}).pipe(Layer.provideMerge(NodeServices.layer));
3528
3629function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
3730 const binDir = path.join(dir, "bin");
......@@ -57,44 +50,20 @@ function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
5750
5851function withFakeAcpAgent<A, E, R>(
5952 env: Record<string, string>,
60 effect: Effect.Effect<A, E, R>,
61): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> {
53 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
54) {
6255 return Effect.gen(function* () {
6356 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
64 const agentPath = makeAcpAgentWrapper(tempDir, env);
65 const serverSettings = yield* ServerSettingsService;
66 const previousSettings = yield* serverSettings.getSettings;
67
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 ),
57 yield* Effect.addFinalizer(() =>
58 Effect.sync(() => {
59 rmSync(tempDir, { recursive: true, force: true });
60 }),
9661 );
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);
9867}
9968
10069function waitForFileContent(path: string): Effect.Effect<string> {
......@@ -126,85 +95,86 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
12695 body: "- verify cursor acp model config path",
12796 }),
12897 },
129 Effect.gen(function* () {
130 const textGeneration = yield* TextGeneration;
131
132 const generated = yield* textGeneration.generateCommitMessage({
133 cwd: process.cwd(),
134 branch: "feature/cursor-text-generation",
135 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
136 stagedPatch:
137 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
138 modelSelection: {
139 ...createModelSelection("cursor", "gpt-5.4", [
140 { id: "reasoning", value: "xhigh" },
141 { id: "fastMode", value: true },
142 { id: "contextWindow", value: "1m" },
143 ]),
144 },
145 });
98 (textGeneration) =>
99 Effect.gen(function* () {
100 const generated = yield* textGeneration.generateCommitMessage({
101 cwd: process.cwd(),
102 branch: "feature/cursor-text-generation",
103 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
104 stagedPatch:
105 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
106 modelSelection: {
107 ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [
108 { id: "reasoning", value: "xhigh" },
109 { id: "fastMode", value: true },
110 { id: "contextWindow", value: "1m" },
111 ]),
112 },
113 });
146114
147 expect(generated.subject).toBe("Add generated commit message");
148 expect(generated.body).toBe("- verify cursor acp model config path");
115 expect(generated.subject).toBe("Add generated commit message");
116 expect(generated.body).toBe("- verify cursor acp model config path");
149117
150 const requests = readFileSync(requestLogPath, "utf8")
151 .trim()
152 .split("\n")
153 .filter((line) => line.length > 0)
154 .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
118 const requests = readFileSync(requestLogPath, "utf8")
119 .trim()
120 .split("\n")
121 .filter((line) => line.length > 0)
122 .map(
123 (line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> },
124 );
155125
156 expect(
157 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
158 ).toMatchObject({
159 _meta: {
160 parameterizedModelPicker: true,
161 },
162 });
163 expect(
164 requests.some(
165 (request) =>
166 request.method === "session/set_config_option" &&
167 request.params?.configId === "model" &&
168 request.params?.value === "gpt-5.4",
169 ),
170 ).toBe(true);
171 expect(
172 requests.some(
173 (request) =>
174 request.method === "session/set_config_option" &&
175 request.params?.configId === "reasoning" &&
176 request.params?.value === "extra-high",
177 ),
178 ).toBe(true);
179 expect(
180 requests.some(
181 (request) =>
182 request.method === "session/set_config_option" &&
183 request.params?.configId === "context" &&
184 request.params?.value === "1m",
185 ),
186 ).toBe(true);
187 expect(
188 requests.some(
189 (request) =>
190 request.method === "session/set_config_option" &&
191 request.params?.configId === "fast" &&
192 request.params?.value === "true",
193 ),
194 ).toBe(true);
195 expect(
196 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
197 ).toEqual(
198 expect.arrayContaining([
199 expect.objectContaining({
200 type: "text",
201 text: expect.stringContaining("Staged patch:"),
202 }),
203 ]),
204 );
126 expect(
127 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
128 ).toMatchObject({
129 _meta: {
130 parameterizedModelPicker: true,
131 },
132 });
133 expect(
134 requests.some(
135 (request) =>
136 request.method === "session/set_config_option" &&
137 request.params?.configId === "model" &&
138 request.params?.value === "gpt-5.4",
139 ),
140 ).toBe(true);
141 expect(
142 requests.some(
143 (request) =>
144 request.method === "session/set_config_option" &&
145 request.params?.configId === "reasoning" &&
146 request.params?.value === "extra-high",
147 ),
148 ).toBe(true);
149 expect(
150 requests.some(
151 (request) =>
152 request.method === "session/set_config_option" &&
153 request.params?.configId === "context" &&
154 request.params?.value === "1m",
155 ),
156 ).toBe(true);
157 expect(
158 requests.some(
159 (request) =>
160 request.method === "session/set_config_option" &&
161 request.params?.configId === "fast" &&
162 request.params?.value === "true",
163 ),
164 ).toBe(true);
165 expect(
166 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
167 ).toEqual(
168 expect.arrayContaining([
169 expect.objectContaining({
170 type: "text",
171 text: expect.stringContaining("Staged patch:"),
172 }),
173 ]),
174 );
205175
206 rmSync(requestLogDir, { recursive: true, force: true });
207 }),
176 rmSync(requestLogDir, { recursive: true, force: true });
177 }),
208178 );
209179 });
210180
......@@ -214,23 +184,22 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
214184 T3_ACP_PROMPT_RESPONSE_TEXT:
215185 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
216186 },
217 Effect.gen(function* () {
218 const textGeneration = yield* TextGeneration;
219
220 const generated = yield* textGeneration.generateCommitMessage({
221 cwd: process.cwd(),
222 branch: "feature/cursor-noisy-json",
223 stagedSummary: "M README.md",
224 stagedPatch: "diff --git a/README.md b/README.md",
225 modelSelection: {
226 provider: "cursor",
227 model: "composer-2",
228 },
229 });
187 (textGeneration) =>
188 Effect.gen(function* () {
189 const generated = yield* textGeneration.generateCommitMessage({
190 cwd: process.cwd(),
191 branch: "feature/cursor-noisy-json",
192 stagedSummary: "M README.md",
193 stagedPatch: "diff --git a/README.md b/README.md",
194 modelSelection: {
195 instanceId: ProviderInstanceId.make("cursor"),
196 model: "composer-2",
197 },
198 });
230199
231 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
232 expect(generated.body).toBe("");
233 }),
200 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
201 expect(generated.body).toBe("");
202 }),
234203 ),
235204 );
236205
......@@ -241,20 +210,19 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
241210 title: '"Trim reconnect spinner status after resume."',
242211 }),
243212 },
244 Effect.gen(function* () {
245 const textGeneration = yield* TextGeneration;
246
247 const generated = yield* textGeneration.generateThreadTitle({
248 cwd: process.cwd(),
249 message: "Fix the reconnect spinner after a resumed session.",
250 modelSelection: {
251 provider: "cursor",
252 model: "composer-2",
253 },
254 });
213 (textGeneration) =>
214 Effect.gen(function* () {
215 const generated = yield* textGeneration.generateThreadTitle({
216 cwd: process.cwd(),
217 message: "Fix the reconnect spinner after a resumed session.",
218 modelSelection: {
219 instanceId: ProviderInstanceId.make("cursor"),
220 model: "composer-2",
221 },
222 });
255223
256 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
257 }),
224 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
225 }),
258226 ),
259227 );
260228
......@@ -270,28 +238,27 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
270238 body: "",
271239 }),
272240 },
273 Effect.gen(function* () {
274 const textGeneration = yield* TextGeneration;
275
276 const generated = yield* textGeneration.generateCommitMessage({
277 cwd: process.cwd(),
278 branch: "feature/cursor-runtime-close",
279 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
280 stagedPatch:
281 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
282 modelSelection: {
283 provider: "cursor",
284 model: "composer-2",
285 },
286 });
241 (textGeneration) =>
242 Effect.gen(function* () {
243 const generated = yield* textGeneration.generateCommitMessage({
244 cwd: process.cwd(),
245 branch: "feature/cursor-runtime-close",
246 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
247 stagedPatch:
248 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
249 modelSelection: {
250 instanceId: ProviderInstanceId.make("cursor"),
251 model: "composer-2",
252 },
253 });
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);
291 expect(exitLog).toContain("exit:0");
257 const exitLog = yield* waitForFileContent(exitLogPath);
258 expect(exitLog).toContain("exit:0");
292259
293 rmSync(exitLogDir, { recursive: true, force: true });
294 }),
260 rmSync(exitLogDir, { recursive: true, force: true });
261 }),
295262 );
296263 });
297264});
apps/server/src/git/Layers/CursorTextGeneration.ts+15-41
......@@ -1,14 +1,13 @@
1import { Effect, Layer, Option, Ref, Schema } from "effect";
1import { Effect, Option, Ref, Schema } from "effect";
22import { ChildProcessSpawner } from "effect/unstable/process";
33
4import { CursorModelSelection } from "@t3tools/contracts";
4import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
55import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
66
77import { TextGenerationError } from "@t3tools/contracts";
88import {
99 type ThreadTitleGenerationResult,
1010 type TextGenerationShape,
11 TextGeneration,
1211} from "../Services/TextGeneration.ts";
1312import {
1413 buildBranchNamePrompt,
......@@ -26,7 +25,6 @@ import {
2625 applyCursorAcpModelSelection,
2726 makeCursorAcpRuntime,
2827} from "../../provider/acp/CursorAcpSupport.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";
3028
3129const CURSOR_TIMEOUT_MS = 180_000;
3230
......@@ -55,9 +53,15 @@ function isTextGenerationError(error: unknown): error is TextGenerationError {
5553 );
5654}
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) {
5964 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
60 const serverSettingsService = yield* Effect.service(ServerSettingsService);
6165
6266 const runCursorJson = <S extends Schema.Top>({
6367 operation,
......@@ -74,17 +78,13 @@ const makeCursorTextGeneration = Effect.gen(function* () {
7478 cwd: string;
7579 prompt: string;
7680 outputSchemaJson: S;
77 modelSelection: CursorModelSelection;
81 modelSelection: ModelSelection;
7882 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
7983 Effect.gen(function* () {
80 const cursorSettings = yield* Effect.map(
81 serverSettingsService.getSettings,
82 (settings) => settings.providers.cursor,
83 ).pipe(Effect.catch(() => Effect.undefined));
84
8584 const outputRef = yield* Ref.make("");
8685 const runtime = yield* makeCursorAcpRuntime({
8786 cursorSettings,
87 environment,
8888 childProcessSpawner: commandSpawner,
8989 cwd,
9090 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
......@@ -186,13 +186,6 @@ const makeCursorTextGeneration = Effect.gen(function* () {
186186 includeBranch: input.includeBranch === true,
187187 });
188188
189 if (input.modelSelection.provider !== "cursor") {
190 return yield* new TextGenerationError({
191 operation: "generateCommitMessage",
192 detail: "Invalid model selection.",
193 });
194 }
195
196189 const generated = yield* runCursorJson({
197190 operation: "generateCommitMessage",
198191 cwd: input.cwd,
......@@ -221,13 +214,6 @@ const makeCursorTextGeneration = Effect.gen(function* () {
221214 diffPatch: input.diffPatch,
222215 });
223216
224 if (input.modelSelection.provider !== "cursor") {
225 return yield* new TextGenerationError({
226 operation: "generatePrContent",
227 detail: "Invalid model selection.",
228 });
229 }
230
231217 const generated = yield* runCursorJson({
232218 operation: "generatePrContent",
233219 cwd: input.cwd,
......@@ -250,13 +236,6 @@ const makeCursorTextGeneration = Effect.gen(function* () {
250236 attachments: input.attachments,
251237 });
252238
253 if (input.modelSelection.provider !== "cursor") {
254 return yield* new TextGenerationError({
255 operation: "generateBranchName",
256 detail: "Invalid model selection.",
257 });
258 }
259
260239 const generated = yield* runCursorJson({
261240 operation: "generateBranchName",
262241 cwd: input.cwd,
......@@ -278,13 +257,6 @@ const makeCursorTextGeneration = Effect.gen(function* () {
278257 attachments: input.attachments,
279258 });
280259
281 if (input.modelSelection.provider !== "cursor") {
282 return yield* new TextGenerationError({
283 operation: "generateThreadTitle",
284 detail: "Invalid model selection.",
285 });
286 }
287
288260 const generated = yield* runCursorJson({
289261 operation: "generateThreadTitle",
290262 cwd: input.cwd,
......@@ -306,4 +278,6 @@ const makeCursorTextGeneration = Effect.gen(function* () {
306278 } satisfies TextGenerationShape;
307279});
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 @@
1import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import { it } from "@effect/vitest";
3import { Duration, Effect, Layer } from "effect";
4import { Duration, Effect, Layer, Schema } from "effect";
45import { TestClock } from "effect/testing";
56import { NetService } from "@t3tools/shared/Net";
67import { beforeEach, expect } from "vitest";
78
89import { ServerConfig } from "../../config.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
1010import {
1111 OpenCodeRuntime,
1212 OpenCodeRuntimeError,
1313 type OpenCodeRuntimeShape,
1414} from "../../provider/opencodeRuntime.ts";
15import { TextGeneration } from "../Services/TextGeneration.ts";
16import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
15import { type TextGenerationShape } from "../Services/TextGeneration.ts";
16import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";
1717
1818const runtimeMock = {
1919 state: {
......@@ -97,23 +97,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
9797};
9898
9999const DEFAULT_TEST_MODEL_SELECTION = {
100 provider: "opencode" as const,
100 instanceId: ProviderInstanceId.make("opencode"),
101101 model: "openai/gpt-5",
102102};
103103
104104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
105105
106const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
107 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
108 Layer.provideMerge(
109 ServerSettingsService.layerTest({
110 providers: {
111 opencode: {
112 binaryPath: "fake-opencode",
113 },
114 },
115 }),
116 ),
106const OpenCodeTextGenerationTestLayer = Layer.succeed(
107 OpenCodeRuntime,
108 OpenCodeRuntimeTestDouble,
109).pipe(
117110 Layer.provideMerge(
118111 ServerConfig.layerTest(process.cwd(), {
119112 prefix: "t3code-opencode-text-generation-test-",
......@@ -123,19 +116,10 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
123116 Layer.provideMerge(NodeServices.layer),
124117);
125118
126const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(
127 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
128 Layer.provideMerge(
129 ServerSettingsService.layerTest({
130 providers: {
131 opencode: {
132 binaryPath: "fake-opencode",
133 serverUrl: "http://127.0.0.1:9999",
134 serverPassword: "secret-password",
135 },
136 },
137 }),
138 ),
119const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed(
120 OpenCodeRuntime,
121 OpenCodeRuntimeTestDouble,
122).pipe(
139123 Layer.provideMerge(
140124 ServerConfig.layerTest(process.cwd(), {
141125 prefix: "t3code-opencode-text-generation-existing-server-test-",
......@@ -145,6 +129,25 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive
145129 Layer.provideMerge(NodeServices.layer),
146130);
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
148151beforeEach(() => {
149152 runtimeMock.reset();
150153});
......@@ -157,155 +160,40 @@ const advanceIdleClock = Effect.gen(function* () {
157160
158161it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => {
159162 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
160 Effect.gen(function* () {
161 const textGeneration = yield* TextGeneration;
162
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({
163 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
164 Effect.gen(function* () {
165 yield* textGeneration.generateCommitMessage({
229166 cwd: process.cwd(),
230167 branch: "feature/opencode-reuse",
231168 stagedSummary: "M README.md",
232169 stagedPatch: "diff --git a/README.md b/README.md",
233170 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
234 })
235 .pipe(Effect.flip);
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({
171 });
172 yield* textGeneration.generateCommitMessage({
289173 cwd: process.cwd(),
290174 branch: "feature/opencode-reuse",
291175 stagedSummary: "M README.md",
292176 stagedPatch: "diff --git a/README.md b/README.md",
293177 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
294 })
295 .pipe(Effect.flip);
178 });
296179
297 expect(error.message).toContain("Model did not produce structured output");
298 }),
180 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
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())),
299192 );
300});
301193
302it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
303 "OpenCodeTextGenerationLive with configured server URL",
304 (it) => {
305 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
194 it.effect("starts a new server after the warm server idles out", () =>
195 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
306196 Effect.gen(function* () {
307 const textGeneration = yield* TextGeneration;
308
309197 yield* textGeneration.generateCommitMessage({
310198 cwd: process.cwd(),
311199 branch: "feature/opencode-reuse",
......@@ -313,6 +201,9 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
313201 stagedPatch: "diff --git a/README.md b/README.md",
314202 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
315203 });
204
205 yield* advanceIdleClock;
206
316207 yield* textGeneration.generateCommitMessage({
317208 cwd: process.cwd(),
318209 branch: "feature/opencode-reuse",
......@@ -321,20 +212,135 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
321212 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
322213 });
323214
324 expect(runtimeMock.state.startCalls).toEqual([]);
215 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
325216 expect(runtimeMock.state.promptUrls).toEqual([
326 "http://127.0.0.1:9999",
327 "http://127.0.0.1:9999",
328 ]);
329 expect(runtimeMock.state.authHeaders).toEqual([
330 `Basic ${btoa("opencode:secret-password")}`,
331 `Basic ${btoa("opencode:secret-password")}`,
217 "http://127.0.0.1:4301",
218 "http://127.0.0.1:4302",
332219 ]);
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([]);
337 }).pipe(Effect.provide(TestClock.layer())),
245 it.effect("parses JSON returned as plain text output", () =>
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())),
338344 );
339345 },
340346);
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+15-62
......@@ -1,24 +1,24 @@
1import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
1import { Effect, Exit, Fiber, Schema, Scope } from "effect";
22import * as Semaphore from "effect/Semaphore";
33
44import {
55 TextGenerationError,
66 type ChatAttachment,
7 type OpenCodeModelSelection,
7 type ModelSelection,
8 type OpenCodeSettings,
89} from "@t3tools/contracts";
910import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1011import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1112
1213import { ServerConfig } from "../../config.ts";
1314import { resolveAttachmentPath } from "../../attachmentStore.ts";
14import { ServerSettingsService } from "../../serverSettings.ts";
1515import {
1616 buildBranchNamePrompt,
1717 buildCommitMessagePrompt,
1818 buildPrContentPrompt,
1919 buildThreadTitlePrompt,
2020} from "../Prompts.ts";
21import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
21import { type TextGenerationShape } from "../Services/TextGeneration.ts";
2222import {
2323 extractJsonObject,
2424 sanitizeCommitSubject,
......@@ -93,9 +93,11 @@ interface SharedOpenCodeTextGenerationServerState {
9393 idleCloseFiber: Fiber.Fiber<void, never> | null;
9494}
9595
96const makeOpenCodeTextGeneration = Effect.gen(function* () {
96export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
97 openCodeSettings: OpenCodeSettings,
98 environment: NodeJS.ProcessEnv = process.env,
99) {
97100 const serverConfig = yield* ServerConfig;
98 const serverSettingsService = yield* ServerSettingsService;
99101 const openCodeRuntime = yield* OpenCodeRuntime;
100102 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
101103 Scope.close(scope, Exit.void),
......@@ -202,6 +204,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
202204 openCodeRuntime
203205 .startOpenCodeServerProcess({
204206 binaryPath: input.binaryPath,
207 environment,
205208 })
206209 .pipe(
207210 Effect.provideService(Scope.Scope, serverScope),
......@@ -267,7 +270,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
267270 readonly cwd: string;
268271 readonly prompt: string;
269272 readonly outputSchemaJson: S;
270 readonly modelSelection: OpenCodeModelSelection;
273 readonly modelSelection: ModelSelection;
271274 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
272275 }) {
273276 const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
......@@ -278,26 +281,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
278281 });
279282 }
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
301284 const fileParts = toOpenCodeFileParts({
302285 attachments: input.attachments,
303286 resolveAttachmentPath: (attachment) =>
......@@ -310,8 +293,8 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
310293 const client = openCodeRuntime.createOpenCodeSdkClient({
311294 baseUrl: server.url,
312295 directory: input.cwd,
313 ...(settings.serverUrl.length > 0 && settings.serverPassword
314 ? { serverPassword: settings.serverPassword }
296 ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword
297 ? { serverPassword: openCodeSettings.serverPassword }
315298 : {}),
316299 });
317300 const session = await client.session.create({
......@@ -354,11 +337,11 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
354337 });
355338
356339 const rawOutput =
357 settings.serverUrl.length > 0
358 ? yield* runAgainstServer({ url: settings.serverUrl })
340 openCodeSettings.serverUrl.length > 0
341 ? yield* runAgainstServer({ url: openCodeSettings.serverUrl })
359342 : yield* Effect.acquireUseRelease(
360343 acquireSharedServer({
361 binaryPath: settings.binaryPath,
344 binaryPath: openCodeSettings.binaryPath,
362345 operation: input.operation,
363346 }),
364347 runAgainstServer,
......@@ -383,13 +366,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
383366 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
384367 "OpenCodeTextGeneration.generateCommitMessage",
385368 )(function* (input) {
386 if (input.modelSelection.provider !== "opencode") {
387 return yield* new TextGenerationError({
388 operation: "generateCommitMessage",
389 detail: "Invalid model selection.",
390 });
391 }
392
393369 const { prompt, outputSchema } = buildCommitMessagePrompt({
394370 branch: input.branch,
395371 stagedSummary: input.stagedSummary,
......@@ -416,13 +392,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
416392 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
417393 "OpenCodeTextGeneration.generatePrContent",
418394 )(function* (input) {
419 if (input.modelSelection.provider !== "opencode") {
420 return yield* new TextGenerationError({
421 operation: "generatePrContent",
422 detail: "Invalid model selection.",
423 });
424 }
425
426395 const { prompt, outputSchema } = buildPrContentPrompt({
427396 baseBranch: input.baseBranch,
428397 headBranch: input.headBranch,
......@@ -447,13 +416,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
447416 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
448417 "OpenCodeTextGeneration.generateBranchName",
449418 )(function* (input) {
450 if (input.modelSelection.provider !== "opencode") {
451 return yield* new TextGenerationError({
452 operation: "generateBranchName",
453 detail: "Invalid model selection.",
454 });
455 }
456
457419 const { prompt, outputSchema } = buildBranchNamePrompt({
458420 message: input.message,
459421 attachments: input.attachments,
......@@ -475,13 +437,6 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
475437 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
476438 "OpenCodeTextGeneration.generateThreadTitle",
477439 )(function* (input) {
478 if (input.modelSelection.provider !== "opencode") {
479 return yield* new TextGenerationError({
480 operation: "generateThreadTitle",
481 detail: "Invalid model selection.",
482 });
483 }
484
485440 const { prompt, outputSchema } = buildThreadTitlePrompt({
486441 message: input.message,
487442 attachments: input.attachments,
......@@ -507,5 +462,3 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
507462 generateThreadTitle,
508463 } satisfies TextGenerationShape;
509464});
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 @@
11import { assert, describe, it } from "@effect/vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
23import { Effect, Metric } from "effect";
34
45import { withMetrics } from "./Metrics.ts";
......@@ -75,10 +76,11 @@ describe("withMetrics", () => {
7576 Effect.gen(function* () {
7677 const counter = Metric.counter("with_metrics_lazy_total");
7778 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
8082 yield* Effect.sync(() => {
81 provider = "codex";
83 provider = lazyInittedProvider;
8284 }).pipe(
8385 withMetrics({
8486 counter,
......@@ -93,7 +95,7 @@ describe("withMetrics", () => {
9395 const snapshots = yield* Metric.snapshot;
9496 assert.equal(
9597 hasMetricSnapshot(snapshots, "with_metrics_lazy_total", {
96 provider: "codex",
98 provider: lazyInittedProvider,
9799 operation: "lazy",
98100 outcome: "success",
99101 }),
......@@ -101,7 +103,7 @@ describe("withMetrics", () => {
101103 );
102104 assert.equal(
103105 hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", {
104 provider: "codex",
106 provider: lazyInittedProvider,
105107 operation: "lazy",
106108 }),
107109 true,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+38-22
......@@ -3,7 +3,12 @@ import os from "node:os";
33import path from "node:path";
44import { 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";
712import {
813 CommandId,
914 DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -50,7 +55,7 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);
5055type LegacyProviderRuntimeEvent = {
5156 readonly type: string;
5257 readonly eventId: EventId;
53 readonly provider: ProviderKind;
58 readonly provider: ProviderDriverKind;
5459 readonly createdAt: string;
5560 readonly threadId: ThreadId;
5661 readonly turnId?: string | undefined;
......@@ -64,7 +69,7 @@ function createProviderServiceHarness(
6469 cwd: string,
6570 hasSession = true,
6671 sessionCwd = cwd,
67 providerName: ProviderSession["provider"] = "codex",
72 providerName: ProviderSession["provider"] = ProviderDriverKind.make("codex"),
6873) {
6974 const now = new Date().toISOString();
7075 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
......@@ -97,6 +102,17 @@ function createProviderServiceHarness(
97102 stopSession: () => unsupported(),
98103 listSessions,
99104 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 }),
100116 rollbackConversation,
101117 get streamEvents() {
102118 return Stream.fromPubSub(runtimeEventPubSub);
......@@ -243,7 +259,7 @@ describe("CheckpointReactor", () => {
243259 readonly projectWorkspaceRoot?: string;
244260 readonly threadWorktreePath?: string | null;
245261 readonly providerSessionCwd?: string;
246 readonly providerName?: ProviderKind;
262 readonly providerName?: ProviderDriverKind;
247263 readonly gitStatusRefreshCalls?: Array<string>;
248264 }) {
249265 const cwd = createGitRepository();
......@@ -252,7 +268,7 @@ describe("CheckpointReactor", () => {
252268 cwd,
253269 options?.hasSession ?? true,
254270 options?.providerSessionCwd ?? cwd,
255 options?.providerName ?? "codex",
271 options?.providerName ?? ProviderDriverKind.make("codex"),
256272 );
257273 const orchestrationLayer = OrchestrationEngineLive.pipe(
258274 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
......@@ -315,7 +331,7 @@ describe("CheckpointReactor", () => {
315331 title: "Test Project",
316332 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
317333 defaultModelSelection: {
318 provider: "codex",
334 instanceId: ProviderInstanceId.make("codex"),
319335 model: "gpt-5-codex",
320336 },
321337 createdAt,
......@@ -329,7 +345,7 @@ describe("CheckpointReactor", () => {
329345 projectId: asProjectId("project-1"),
330346 title: "Thread",
331347 modelSelection: {
332 provider: "codex",
348 instanceId: ProviderInstanceId.make("codex"),
333349 model: "gpt-5-codex",
334350 },
335351 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -396,7 +412,7 @@ describe("CheckpointReactor", () => {
396412 harness.provider.emit({
397413 type: "turn.started",
398414 eventId: EventId.make("evt-turn-started-1"),
399 provider: "codex",
415 provider: ProviderDriverKind.make("codex"),
400416
401417 createdAt: new Date().toISOString(),
402418 threadId: ThreadId.make("thread-1"),
......@@ -411,7 +427,7 @@ describe("CheckpointReactor", () => {
411427 harness.provider.emit({
412428 type: "turn.completed",
413429 eventId: EventId.make("evt-turn-completed-1"),
414 provider: "codex",
430 provider: ProviderDriverKind.make("codex"),
415431
416432 createdAt: new Date().toISOString(),
417433 threadId: ThreadId.make("thread-1"),
......@@ -457,7 +473,7 @@ describe("CheckpointReactor", () => {
457473 harness.provider.emit({
458474 type: "turn.completed",
459475 eventId: EventId.make("evt-turn-completed-refresh-local-status"),
460 provider: "codex",
476 provider: ProviderDriverKind.make("codex"),
461477 createdAt: new Date().toISOString(),
462478 threadId: ThreadId.make("thread-1"),
463479 turnId: asTurnId("turn-refresh-local-status"),
......@@ -494,7 +510,7 @@ describe("CheckpointReactor", () => {
494510 harness.provider.emit({
495511 type: "turn.started",
496512 eventId: EventId.make("evt-turn-started-main"),
497 provider: "codex",
513 provider: ProviderDriverKind.make("codex"),
498514
499515 createdAt: new Date().toISOString(),
500516 threadId: ThreadId.make("thread-1"),
......@@ -510,7 +526,7 @@ describe("CheckpointReactor", () => {
510526 harness.provider.emit({
511527 type: "turn.completed",
512528 eventId: EventId.make("evt-turn-completed-aux"),
513 provider: "codex",
529 provider: ProviderDriverKind.make("codex"),
514530
515531 createdAt: new Date().toISOString(),
516532 threadId: ThreadId.make("thread-1"),
......@@ -526,7 +542,7 @@ describe("CheckpointReactor", () => {
526542 harness.provider.emit({
527543 type: "turn.completed",
528544 eventId: EventId.make("evt-turn-completed-main"),
529 provider: "codex",
545 provider: ProviderDriverKind.make("codex"),
530546
531547 createdAt: new Date().toISOString(),
532548 threadId: ThreadId.make("thread-1"),
......@@ -544,7 +560,7 @@ describe("CheckpointReactor", () => {
544560 it("captures pre-turn and completion checkpoints for claude runtime events", async () => {
545561 const harness = await createHarness({
546562 seedFilesystemCheckpoints: false,
547 providerName: "claudeAgent",
563 providerName: ProviderDriverKind.make("claudeAgent"),
548564 });
549565 const createdAt = new Date().toISOString();
550566
......@@ -569,7 +585,7 @@ describe("CheckpointReactor", () => {
569585 harness.provider.emit({
570586 type: "turn.started",
571587 eventId: EventId.make("evt-turn-started-claude-1"),
572 provider: "claudeAgent",
588 provider: ProviderDriverKind.make("claudeAgent"),
573589 createdAt: new Date().toISOString(),
574590 threadId: ThreadId.make("thread-1"),
575591 turnId: asTurnId("turn-claude-1"),
......@@ -583,7 +599,7 @@ describe("CheckpointReactor", () => {
583599 harness.provider.emit({
584600 type: "turn.completed",
585601 eventId: EventId.make("evt-turn-completed-claude-1"),
586 provider: "claudeAgent",
602 provider: ProviderDriverKind.make("claudeAgent"),
587603 createdAt: new Date().toISOString(),
588604 threadId: ThreadId.make("thread-1"),
589605 turnId: asTurnId("turn-claude-1"),
......@@ -627,7 +643,7 @@ describe("CheckpointReactor", () => {
627643 harness.provider.emit({
628644 type: "turn.completed",
629645 eventId: EventId.make("evt-turn-completed-missing-baseline"),
630 provider: "codex",
646 provider: ProviderDriverKind.make("codex"),
631647
632648 createdAt: new Date().toISOString(),
633649 threadId: ThreadId.make("thread-1"),
......@@ -716,7 +732,7 @@ describe("CheckpointReactor", () => {
716732 harness.provider.emit({
717733 type: "turn.completed",
718734 eventId: EventId.make("evt-turn-completed-missing-provider-cwd"),
719 provider: "codex",
735 provider: ProviderDriverKind.make("codex"),
720736
721737 createdAt: new Date().toISOString(),
722738 threadId: ThreadId.make("thread-1"),
......@@ -762,7 +778,7 @@ describe("CheckpointReactor", () => {
762778 harness.provider.emit({
763779 type: "checkpoint.captured",
764780 eventId: EventId.make("evt-checkpoint-captured-3"),
765 provider: "codex",
781 provider: ProviderDriverKind.make("codex"),
766782
767783 createdAt: new Date().toISOString(),
768784 threadId: ThreadId.make("thread-1"),
......@@ -812,7 +828,7 @@ describe("CheckpointReactor", () => {
812828 harness.provider.emit({
813829 type: "turn.completed",
814830 eventId: EventId.make("evt-runtime-capture-failure"),
815 provider: "codex",
831 provider: ProviderDriverKind.make("codex"),
816832
817833 createdAt: new Date().toISOString(),
818834 threadId: ThreadId.make("thread-1"),
......@@ -823,7 +839,7 @@ describe("CheckpointReactor", () => {
823839 harness.provider.emit({
824840 type: "turn.started",
825841 eventId: EventId.make("evt-turn-started-after-runtime-failure"),
826 provider: "codex",
842 provider: ProviderDriverKind.make("codex"),
827843
828844 createdAt: new Date().toISOString(),
829845 threadId: ThreadId.make("thread-1"),
......@@ -918,7 +934,7 @@ describe("CheckpointReactor", () => {
918934 });
919935
920936 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") });
922938 const createdAt = new Date().toISOString();
923939
924940 await Effect.runPromise(
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+26-25
......@@ -7,6 +7,7 @@ import {
77 ThreadId,
88 TurnId,
99 type OrchestrationEvent,
10 ProviderInstanceId,
1011} from "@t3tools/contracts";
1112import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";
1213import { describe, expect, it } from "vitest";
......@@ -104,7 +105,7 @@ describe("OrchestrationEngine", () => {
104105 title: "Bootstrap Project",
105106 workspaceRoot: "/tmp/project-bootstrap",
106107 defaultModelSelection: {
107 provider: "codex" as const,
108 instanceId: ProviderInstanceId.make("codex"),
108109 model: "gpt-5-codex",
109110 },
110111 scripts: [],
......@@ -119,7 +120,7 @@ describe("OrchestrationEngine", () => {
119120 projectId: asProjectId("project-bootstrap"),
120121 title: "Bootstrap Thread",
121122 modelSelection: {
122 provider: "codex" as const,
123 instanceId: ProviderInstanceId.make("codex"),
123124 model: "gpt-5-codex",
124125 },
125126 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -198,7 +199,7 @@ describe("OrchestrationEngine", () => {
198199 title: "Project 1",
199200 workspaceRoot: "/tmp/project-1",
200201 defaultModelSelection: {
201 provider: "codex",
202 instanceId: ProviderInstanceId.make("codex"),
202203 model: "gpt-5-codex",
203204 },
204205 createdAt,
......@@ -212,7 +213,7 @@ describe("OrchestrationEngine", () => {
212213 projectId: asProjectId("project-1"),
213214 title: "Thread",
214215 modelSelection: {
215 provider: "codex",
216 instanceId: ProviderInstanceId.make("codex"),
216217 model: "gpt-5-codex",
217218 },
218219 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -258,7 +259,7 @@ describe("OrchestrationEngine", () => {
258259 title: "Project Archive",
259260 workspaceRoot: "/tmp/project-archive",
260261 defaultModelSelection: {
261 provider: "codex",
262 instanceId: ProviderInstanceId.make("codex"),
262263 model: "gpt-5-codex",
263264 },
264265 createdAt,
......@@ -272,7 +273,7 @@ describe("OrchestrationEngine", () => {
272273 projectId: asProjectId("project-archive"),
273274 title: "Archive me",
274275 modelSelection: {
275 provider: "codex",
276 instanceId: ProviderInstanceId.make("codex"),
276277 model: "gpt-5-codex",
277278 },
278279 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -325,7 +326,7 @@ describe("OrchestrationEngine", () => {
325326 title: "Replay Project",
326327 workspaceRoot: "/tmp/project-replay",
327328 defaultModelSelection: {
328 provider: "codex",
329 instanceId: ProviderInstanceId.make("codex"),
329330 model: "gpt-5-codex",
330331 },
331332 createdAt,
......@@ -339,7 +340,7 @@ describe("OrchestrationEngine", () => {
339340 projectId: asProjectId("project-replay"),
340341 title: "replay",
341342 modelSelection: {
342 provider: "codex",
343 instanceId: ProviderInstanceId.make("codex"),
343344 model: "gpt-5-codex",
344345 },
345346 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -383,7 +384,7 @@ describe("OrchestrationEngine", () => {
383384 title: "Stream Project",
384385 workspaceRoot: "/tmp/project-stream",
385386 defaultModelSelection: {
386 provider: "codex",
387 instanceId: ProviderInstanceId.make("codex"),
387388 model: "gpt-5-codex",
388389 },
389390 createdAt,
......@@ -407,7 +408,7 @@ describe("OrchestrationEngine", () => {
407408 projectId: asProjectId("project-stream"),
408409 title: "domain-stream",
409410 modelSelection: {
410 provider: "codex",
411 instanceId: ProviderInstanceId.make("codex"),
411412 model: "gpt-5-codex",
412413 },
413414 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -444,7 +445,7 @@ describe("OrchestrationEngine", () => {
444445 title: "Ack Project",
445446 workspaceRoot: "/tmp/project-ack",
446447 defaultModelSelection: {
447 provider: "codex",
448 instanceId: ProviderInstanceId.make("codex"),
448449 model: "gpt-5-codex",
449450 },
450451 createdAt,
......@@ -459,7 +460,7 @@ describe("OrchestrationEngine", () => {
459460 projectId: asProjectId("project-ack"),
460461 title: "Ack Thread",
461462 modelSelection: {
462 provider: "codex",
463 instanceId: ProviderInstanceId.make("codex"),
463464 model: "gpt-5-codex",
464465 },
465466 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -496,7 +497,7 @@ describe("OrchestrationEngine", () => {
496497 projectId: asProjectId("project-missing"),
497498 title: "Missing Project Thread",
498499 modelSelection: {
499 provider: "codex",
500 instanceId: ProviderInstanceId.make("codex"),
500501 model: "gpt-5-codex",
501502 },
502503 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -533,7 +534,7 @@ describe("OrchestrationEngine", () => {
533534 title: "Turn Diff Project",
534535 workspaceRoot: "/tmp/project-turn-diff",
535536 defaultModelSelection: {
536 provider: "codex",
537 instanceId: ProviderInstanceId.make("codex"),
537538 model: "gpt-5-codex",
538539 },
539540 createdAt,
......@@ -547,7 +548,7 @@ describe("OrchestrationEngine", () => {
547548 projectId: asProjectId("project-turn-diff"),
548549 title: "Turn diff thread",
549550 modelSelection: {
550 provider: "codex",
551 instanceId: ProviderInstanceId.make("codex"),
551552 model: "gpt-5-codex",
552553 },
553554 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -652,7 +653,7 @@ describe("OrchestrationEngine", () => {
652653 title: "Flaky Project",
653654 workspaceRoot: "/tmp/project-flaky",
654655 defaultModelSelection: {
655 provider: "codex",
656 instanceId: ProviderInstanceId.make("codex"),
656657 model: "gpt-5-codex",
657658 },
658659 createdAt,
......@@ -668,7 +669,7 @@ describe("OrchestrationEngine", () => {
668669 projectId: asProjectId("project-flaky"),
669670 title: "flaky-fail",
670671 modelSelection: {
671 provider: "codex",
672 instanceId: ProviderInstanceId.make("codex"),
672673 model: "gpt-5-codex",
673674 },
674675 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -688,7 +689,7 @@ describe("OrchestrationEngine", () => {
688689 projectId: asProjectId("project-flaky"),
689690 title: "flaky-ok",
690691 modelSelection: {
691 provider: "codex",
692 instanceId: ProviderInstanceId.make("codex"),
692693 model: "gpt-5-codex",
693694 },
694695 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -747,7 +748,7 @@ describe("OrchestrationEngine", () => {
747748 title: "Atomic Project",
748749 workspaceRoot: "/tmp/project-atomic",
749750 defaultModelSelection: {
750 provider: "codex",
751 instanceId: ProviderInstanceId.make("codex"),
751752 model: "gpt-5-codex",
752753 },
753754 createdAt,
......@@ -761,7 +762,7 @@ describe("OrchestrationEngine", () => {
761762 projectId: asProjectId("project-atomic"),
762763 title: "atomic",
763764 modelSelection: {
764 provider: "codex",
765 instanceId: ProviderInstanceId.make("codex"),
765766 model: "gpt-5-codex",
766767 },
767768 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -890,7 +891,7 @@ describe("OrchestrationEngine", () => {
890891 title: "Sync Project",
891892 workspaceRoot: "/tmp/project-sync",
892893 defaultModelSelection: {
893 provider: "codex",
894 instanceId: ProviderInstanceId.make("codex"),
894895 model: "gpt-5-codex",
895896 },
896897 createdAt,
......@@ -904,7 +905,7 @@ describe("OrchestrationEngine", () => {
904905 projectId: asProjectId("project-sync"),
905906 title: "sync-before",
906907 modelSelection: {
907 provider: "codex",
908 instanceId: ProviderInstanceId.make("codex"),
908909 model: "gpt-5-codex",
909910 },
910911 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -975,7 +976,7 @@ describe("OrchestrationEngine", () => {
975976 title: "Duplicate Project",
976977 workspaceRoot: "/tmp/project-duplicate",
977978 defaultModelSelection: {
978 provider: "codex",
979 instanceId: ProviderInstanceId.make("codex"),
979980 model: "gpt-5-codex",
980981 },
981982 createdAt,
......@@ -990,7 +991,7 @@ describe("OrchestrationEngine", () => {
990991 projectId: asProjectId("project-duplicate"),
991992 title: "duplicate",
992993 modelSelection: {
993 provider: "codex",
994 instanceId: ProviderInstanceId.make("codex"),
994995 model: "gpt-5-codex",
995996 },
996997 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -1010,7 +1011,7 @@ describe("OrchestrationEngine", () => {
10101011 projectId: asProjectId("project-duplicate"),
10111012 title: "duplicate",
10121013 modelSelection: {
1013 provider: "codex",
1014 instanceId: ProviderInstanceId.make("codex"),
10141015 model: "gpt-5-codex",
10151016 },
10161017 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+17-16
......@@ -7,6 +7,7 @@ import {
77 ProjectId,
88 ThreadId,
99 TurnId,
10 ProviderInstanceId,
1011} from "@t3tools/contracts";
1112import * as NodeServices from "@effect/platform-node/NodeServices";
1213import { assert, it } from "@effect/vitest";
......@@ -92,7 +93,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
9293 projectId: ProjectId.make("project-1"),
9394 title: "Thread 1",
9495 modelSelection: {
95 provider: "codex",
96 instanceId: ProviderInstanceId.make("codex"),
9697 model: "gpt-5-codex",
9798 },
9899 runtimeMode: "full-access",
......@@ -364,7 +365,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
364365 projectId: ProjectId.make("project-clear-attachments"),
365366 title: "Thread Clear Attachments",
366367 modelSelection: {
367 provider: "codex",
368 instanceId: ProviderInstanceId.make("codex"),
368369 model: "gpt-5-codex",
369370 },
370371 runtimeMode: "full-access",
......@@ -492,7 +493,7 @@ it.layer(
492493 projectId: ProjectId.make("project-overwrite"),
493494 title: "Thread Overwrite",
494495 modelSelection: {
495 provider: "codex",
496 instanceId: ProviderInstanceId.make("codex"),
496497 model: "gpt-5-codex",
497498 },
498499 runtimeMode: "full-access",
......@@ -640,7 +641,7 @@ it.layer(
640641 projectId: ProjectId.make("project-rollback"),
641642 title: "Thread Rollback",
642643 modelSelection: {
643 provider: "codex",
644 instanceId: ProviderInstanceId.make("codex"),
644645 model: "gpt-5-codex",
645646 },
646647 runtimeMode: "full-access",
......@@ -769,7 +770,7 @@ it.layer(
769770 projectId: ProjectId.make("project-revert-files"),
770771 title: "Thread Revert Files",
771772 modelSelection: {
772 provider: "codex",
773 instanceId: ProviderInstanceId.make("codex"),
773774 model: "gpt-5-codex",
774775 },
775776 runtimeMode: "full-access",
......@@ -977,7 +978,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
977978 projectId: ProjectId.make("project-delete-files"),
978979 title: "Thread Delete Files",
979980 modelSelection: {
980 provider: "codex",
981 instanceId: ProviderInstanceId.make("codex"),
981982 model: "gpt-5-codex",
982983 },
983984 runtimeMode: "full-access",
......@@ -1140,7 +1141,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
11401141 projectId: ProjectId.make("project-a"),
11411142 title: "Thread A",
11421143 modelSelection: {
1143 provider: "codex",
1144 instanceId: ProviderInstanceId.make("codex"),
11441145 model: "gpt-5-codex",
11451146 },
11461147 runtimeMode: "full-access",
......@@ -1267,7 +1268,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
12671268 projectId: ProjectId.make("project-empty"),
12681269 title: "Thread Empty",
12691270 modelSelection: {
1270 provider: "codex",
1271 instanceId: ProviderInstanceId.make("codex"),
12711272 model: "gpt-5-codex",
12721273 },
12731274 runtimeMode: "full-access",
......@@ -1407,7 +1408,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
14071408 projectId: ProjectId.make("project-conflict"),
14081409 title: "Thread Conflict",
14091410 modelSelection: {
1410 provider: "codex",
1411 instanceId: ProviderInstanceId.make("codex"),
14111412 model: "gpt-5-codex",
14121413 },
14131414 runtimeMode: "full-access",
......@@ -1551,7 +1552,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
15511552 projectId: ProjectId.make("project-stale-approval"),
15521553 title: "Thread Stale Approval",
15531554 modelSelection: {
1554 provider: "codex",
1555 instanceId: ProviderInstanceId.make("codex"),
15551556 model: "gpt-5-codex",
15561557 },
15571558 runtimeMode: "approval-required",
......@@ -1694,7 +1695,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
16941695 projectId: ProjectId.make("project-nonstale-approval"),
16951696 title: "Thread Non-Stale Approval",
16961697 modelSelection: {
1697 provider: "codex",
1698 instanceId: ProviderInstanceId.make("codex"),
16981699 model: "gpt-5-codex",
16991700 },
17001701 runtimeMode: "approval-required",
......@@ -1874,7 +1875,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
18741875 projectId: ProjectId.make("project-revert"),
18751876 title: "Thread Revert",
18761877 modelSelection: {
1877 provider: "codex",
1878 instanceId: ProviderInstanceId.make("codex"),
18781879 model: "gpt-5-codex",
18791880 },
18801881 runtimeMode: "full-access",
......@@ -2195,7 +2196,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
21952196 title: "Live Project",
21962197 workspaceRoot: "/tmp/project-live",
21972198 defaultModelSelection: {
2198 provider: "codex",
2199 instanceId: ProviderInstanceId.make("codex"),
21992200 model: "gpt-5-codex",
22002201 },
22012202 createdAt,
......@@ -2233,7 +2234,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
22332234 title: "Scripts Project",
22342235 workspaceRoot: "/tmp/project-scripts",
22352236 defaultModelSelection: {
2236 provider: "codex",
2237 instanceId: ProviderInstanceId.make("codex"),
22372238 model: "gpt-5-codex",
22382239 },
22392240 createdAt,
......@@ -2253,7 +2254,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
22532254 },
22542255 ],
22552256 defaultModelSelection: {
2256 provider: "codex",
2257 instanceId: ProviderInstanceId.make("codex"),
22572258 model: "gpt-5",
22582259 },
22592260 });
......@@ -2272,7 +2273,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
22722273 {
22732274 scriptsJson:
22742275 '[{"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"}',
22762277 },
22772278 ]);
22782279 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1
......@@ -945,6 +945,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
945945 threadId: event.payload.threadId,
946946 status: event.payload.session.status,
947947 providerName: event.payload.session.providerName,
948 providerInstanceId: event.payload.session.providerInstanceId ?? null,
948949 runtimeMode: event.payload.session.runtimeMode,
949950 activeTurnId: event.payload.session.activeTurnId,
950951 lastError: event.payload.session.lastError,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+13-5
......@@ -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";
210import { assert, it } from "@effect/vitest";
311import { Effect, Layer } from "effect";
412import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -252,7 +260,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
252260 workspaceRoot: "/tmp/project-1",
253261 repositoryIdentity: null,
254262 defaultModelSelection: {
255 provider: "codex",
263 instanceId: ProviderInstanceId.make("codex"),
256264 model: "gpt-5-codex",
257265 },
258266 scripts: [
......@@ -275,7 +283,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
275283 projectId: asProjectId("project-1"),
276284 title: "Thread 1",
277285 modelSelection: {
278 provider: "codex",
286 instanceId: ProviderInstanceId.make("codex"),
279287 model: "gpt-5-codex",
280288 },
281289 interactionMode: "default",
......@@ -363,7 +371,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
363371 workspaceRoot: "/tmp/project-1",
364372 repositoryIdentity: null,
365373 defaultModelSelection: {
366 provider: "codex",
374 instanceId: ProviderInstanceId.make("codex"),
367375 model: "gpt-5-codex",
368376 },
369377 scripts: [
......@@ -385,7 +393,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
385393 projectId: asProjectId("project-1"),
386394 title: "Thread 1",
387395 modelSelection: {
388 provider: "codex",
396 instanceId: ProviderInstanceId.make("codex"),
389397 model: "gpt-5-codex",
390398 },
391399 interactionMode: "default",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+6
......@@ -195,6 +195,7 @@ function mapSessionRow(
195195 threadId: row.threadId,
196196 status: row.status,
197197 providerName: row.providerName,
198 ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}),
198199 runtimeMode: row.runtimeMode,
199200 activeTurnId: row.activeTurnId,
200201 lastError: row.lastError,
......@@ -350,6 +351,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
350351 thread_id AS "threadId",
351352 status,
352353 provider_name AS "providerName",
354 provider_instance_id AS "providerInstanceId",
353355 provider_session_id AS "providerSessionId",
354356 provider_thread_id AS "providerThreadId",
355357 runtime_mode AS "runtimeMode",
......@@ -608,6 +610,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
608610 thread_id AS "threadId",
609611 status,
610612 provider_name AS "providerName",
613 provider_instance_id AS "providerInstanceId",
611614 runtime_mode AS "runtimeMode",
612615 active_turn_id AS "activeTurnId",
613616 last_error AS "lastError",
......@@ -879,6 +882,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
879882 threadId: row.threadId,
880883 status: row.status,
881884 providerName: row.providerName,
885 ...(row.providerInstanceId !== null
886 ? { providerInstanceId: row.providerInstanceId }
887 : {}),
882888 runtimeMode: row.runtimeMode,
883889 activeTurnId: row.activeTurnId,
884890 lastError: row.lastError,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+370-68
......@@ -2,7 +2,13 @@ import fs from "node:fs";
22import os from "node:os";
33import 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";
612import { createModelSelection } from "@t3tools/shared/model";
713import {
814 ApprovalRequestId,
......@@ -37,7 +43,11 @@ import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryI
3743import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3844import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3945import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
40import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
46import {
47 providerErrorLabel,
48 providerErrorLabelFromInstanceHint,
49 ProviderCommandReactorLive,
50} from "./ProviderCommandReactor.ts";
4151import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4252import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
4353import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -98,6 +108,30 @@ describe("ProviderCommandReactor", () => {
98108 createdBaseDirs.clear();
99109 });
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
101135 async function createHarness(input?: {
102136 readonly baseDir?: string;
103137 readonly threadModelSelection?: ModelSelection;
......@@ -112,7 +146,7 @@ describe("ProviderCommandReactor", () => {
112146 let nextSessionIndex = 1;
113147 const runtimeSessions: Array<ProviderSession> = [];
114148 const modelSelection = input?.threadModelSelection ?? {
115 provider: "codex",
149 instanceId: ProviderInstanceId.make("codex"),
116150 model: "gpt-5-codex",
117151 };
118152 const startSession = vi.fn((_: unknown, input: unknown) => {
......@@ -128,8 +162,24 @@ describe("ProviderCommandReactor", () => {
128162 typeof input.threadId === "string"
129163 ? ThreadId.make(input.threadId)
130164 : 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);
131180 const session: ProviderSession = {
132 provider: modelSelection.provider,
181 provider,
182 ...(providerInstanceId ? { providerInstanceId } : {}),
133183 status: "ready" as const,
134184 runtimeMode:
135185 typeof input === "object" &&
......@@ -144,7 +194,9 @@ describe("ProviderCommandReactor", () => {
144194 typeof input.cwd === "string"
145195 ? { cwd: input.cwd }
146196 : {}),
147 ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
197 ...((inputModelSelection?.model ?? modelSelection.model)
198 ? { model: inputModelSelection?.model ?? modelSelection.model }
199 : {}),
148200 threadId,
149201 resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
150202 createdAt: now,
......@@ -236,6 +288,25 @@ describe("ProviderCommandReactor", () => {
236288 Effect.succeed({
237289 sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
238290 }),
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 },
239310 rollbackConversation: () => unsupported(),
240311 get streamEvents() {
241312 return Stream.fromPubSub(runtimeEventPubSub);
......@@ -320,6 +391,7 @@ describe("ProviderCommandReactor", () => {
320391 refreshStatus,
321392 generateBranchName,
322393 generateThreadTitle,
394 runtimeSessions,
323395 stateDir,
324396 drain,
325397 };
......@@ -352,7 +424,7 @@ describe("ProviderCommandReactor", () => {
352424 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
353425 cwd: "/tmp/provider-project",
354426 modelSelection: {
355 provider: "codex",
427 instanceId: ProviderInstanceId.make("codex"),
356428 model: "gpt-5-codex",
357429 },
358430 runtimeMode: "approval-required",
......@@ -574,7 +646,7 @@ describe("ProviderCommandReactor", () => {
574646 text: "hello fast mode",
575647 attachments: [],
576648 },
577 modelSelection: createModelSelection("codex", "gpt-5.3-codex", [
649 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
578650 { id: "reasoningEffort", value: "high" },
579651 { id: "fastMode", value: true },
580652 ]),
......@@ -587,14 +659,14 @@ describe("ProviderCommandReactor", () => {
587659 await waitFor(() => harness.startSession.mock.calls.length === 1);
588660 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
589661 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", [
591663 { id: "reasoningEffort", value: "high" },
592664 { id: "fastMode", value: true },
593665 ]),
594666 });
595667 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
596668 threadId: ThreadId.make("thread-1"),
597 modelSelection: createModelSelection("codex", "gpt-5.3-codex", [
669 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
598670 { id: "reasoningEffort", value: "high" },
599671 { id: "fastMode", value: true },
600672 ]),
......@@ -603,7 +675,10 @@ describe("ProviderCommandReactor", () => {
603675
604676 it("forwards claude effort options through session start and turn send", async () => {
605677 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 },
607682 });
608683 const now = new Date().toISOString();
609684
......@@ -618,9 +693,11 @@ describe("ProviderCommandReactor", () => {
618693 text: "hello with effort",
619694 attachments: [],
620695 },
621 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
622 { id: "effort", value: "max" },
623 ]),
696 modelSelection: createModelSelection(
697 ProviderInstanceId.make("claudeAgent"),
698 "claude-sonnet-4-6",
699 [{ id: "effort", value: "max" }],
700 ),
624701 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
625702 runtimeMode: "approval-required",
626703 createdAt: now,
......@@ -630,21 +707,28 @@ describe("ProviderCommandReactor", () => {
630707 await waitFor(() => harness.startSession.mock.calls.length === 1);
631708 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
632709 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
633 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
634 { id: "effort", value: "max" },
635 ]),
710 modelSelection: createModelSelection(
711 ProviderInstanceId.make("claudeAgent"),
712 "claude-sonnet-4-6",
713 [{ id: "effort", value: "max" }],
714 ),
636715 });
637716 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
638717 threadId: ThreadId.make("thread-1"),
639 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
640 { id: "effort", value: "max" },
641 ]),
718 modelSelection: createModelSelection(
719 ProviderInstanceId.make("claudeAgent"),
720 "claude-sonnet-4-6",
721 [{ id: "effort", value: "max" }],
722 ),
642723 });
643724 });
644725
645726 it("forwards claude fast mode options through session start and turn send", async () => {
646727 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 },
648732 });
649733 const now = new Date().toISOString();
650734
......@@ -659,9 +743,11 @@ describe("ProviderCommandReactor", () => {
659743 text: "hello with fast mode",
660744 attachments: [],
661745 },
662 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
663 { id: "fastMode", value: true },
664 ]),
746 modelSelection: createModelSelection(
747 ProviderInstanceId.make("claudeAgent"),
748 "claude-opus-4-6",
749 [{ id: "fastMode", value: true }],
750 ),
665751 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
666752 runtimeMode: "approval-required",
667753 createdAt: now,
......@@ -671,15 +757,19 @@ describe("ProviderCommandReactor", () => {
671757 await waitFor(() => harness.startSession.mock.calls.length === 1);
672758 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
673759 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
674 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
675 { id: "fastMode", value: true },
676 ]),
760 modelSelection: createModelSelection(
761 ProviderInstanceId.make("claudeAgent"),
762 "claude-opus-4-6",
763 [{ id: "fastMode", value: true }],
764 ),
677765 });
678766 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
679767 threadId: ThreadId.make("thread-1"),
680 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
681 { id: "fastMode", value: true },
682 ]),
768 modelSelection: createModelSelection(
769 ProviderInstanceId.make("claudeAgent"),
770 "claude-opus-4-6",
771 [{ id: "fastMode", value: true }],
772 ),
683773 });
684774 });
685775
......@@ -766,15 +856,15 @@ describe("ProviderCommandReactor", () => {
766856 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
767857 threadId: ThreadId.make("thread-1"),
768858 modelSelection: {
769 provider: "codex",
859 instanceId: ProviderInstanceId.make("codex"),
770860 model: "gpt-5-codex",
771861 },
772862 });
773863 });
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 () => {
776866 const harness = await createHarness({
777 threadModelSelection: { provider: "codex", model: "gpt-5-codex" },
867 threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
778868 });
779869 const now = new Date().toISOString();
780870
......@@ -790,7 +880,7 @@ describe("ProviderCommandReactor", () => {
790880 attachments: [],
791881 },
792882 modelSelection: {
793 provider: "claudeAgent",
883 instanceId: ProviderInstanceId.make("claudeAgent"),
794884 model: "claude-opus-4-6",
795885 },
796886 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -799,29 +889,25 @@ describe("ProviderCommandReactor", () => {
799889 }),
800890 );
801891
802 await waitFor(async () => {
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 });
892 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
810893
811 expect(harness.startSession).not.toHaveBeenCalled();
812 expect(harness.sendTurn).not.toHaveBeenCalled();
894 expect(harness.startSession).toHaveBeenCalledTimes(1);
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
814904 const readModel = await Effect.runPromise(harness.engine.getReadModel());
815905 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"));
817908 expect(
818909 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
819 ).toMatchObject({
820 summary: "Provider turn start failed",
821 payload: {
822 detail: expect.stringContaining("cannot switch to 'claudeAgent'"),
823 },
824 });
910 ).toBeUndefined();
825911 });
826912
827913 it("reuses the same provider session when runtime mode is unchanged", async () => {
......@@ -870,9 +956,74 @@ describe("ProviderCommandReactor", () => {
870956 expect(harness.stopSession.mock.calls.length).toBe(0);
871957 });
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
8731021 it("restarts the provider session when the thread workspace changes", async () => {
8741022 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 },
8761027 });
8771028 const now = new Date().toISOString();
8781029
......@@ -933,7 +1084,7 @@ describe("ProviderCommandReactor", () => {
9331084 cwd: "/tmp/provider-project-worktree",
9341085 resumeCursor: { opaque: "resume-1" },
9351086 modelSelection: {
936 provider: "claudeAgent",
1087 instanceId: ProviderInstanceId.make("claudeAgent"),
9371088 model: "claude-sonnet-4-6",
9381089 },
9391090 runtimeMode: "approval-required",
......@@ -942,7 +1093,10 @@ describe("ProviderCommandReactor", () => {
9421093
9431094 it("restarts claude sessions when claude effort changes", async () => {
9441095 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 },
9461100 });
9471101 const now = new Date().toISOString();
9481102
......@@ -957,9 +1111,11 @@ describe("ProviderCommandReactor", () => {
9571111 text: "first claude turn",
9581112 attachments: [],
9591113 },
960 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
961 { id: "effort", value: "medium" },
962 ]),
1114 modelSelection: createModelSelection(
1115 ProviderInstanceId.make("claudeAgent"),
1116 "claude-sonnet-4-6",
1117 [{ id: "effort", value: "medium" }],
1118 ),
9631119 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
9641120 runtimeMode: "approval-required",
9651121 createdAt: now,
......@@ -980,9 +1136,11 @@ describe("ProviderCommandReactor", () => {
9801136 text: "second claude turn",
9811137 attachments: [],
9821138 },
983 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
984 { id: "effort", value: "max" },
985 ]),
1139 modelSelection: createModelSelection(
1140 ProviderInstanceId.make("claudeAgent"),
1141 "claude-sonnet-4-6",
1142 [{ id: "effort", value: "max" }],
1143 ),
9861144 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
9871145 runtimeMode: "approval-required",
9881146 createdAt: now,
......@@ -993,9 +1151,11 @@ describe("ProviderCommandReactor", () => {
9931151 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
9941152 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
9951153 resumeCursor: { opaque: "resume-1" },
996 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
997 { id: "effort", value: "max" },
998 ]),
1154 modelSelection: createModelSelection(
1155 ProviderInstanceId.make("claudeAgent"),
1156 "claude-sonnet-4-6",
1157 [{ id: "effort", value: "max" }],
1158 ),
9991159 });
10001160 });
10011161
......@@ -1086,7 +1246,10 @@ describe("ProviderCommandReactor", () => {
10861246
10871247 it("does not inject derived model options when restarting claude on runtime mode changes", async () => {
10881248 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 },
10901253 });
10911254 const now = new Date().toISOString();
10921255
......@@ -1122,7 +1285,7 @@ describe("ProviderCommandReactor", () => {
11221285
11231286 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
11241287 modelSelection: {
1125 provider: "claudeAgent",
1288 instanceId: ProviderInstanceId.make("claudeAgent"),
11261289 model: "claude-opus-4-6",
11271290 },
11281291 runtimeMode: "approval-required",
......@@ -1230,7 +1393,7 @@ describe("ProviderCommandReactor", () => {
12301393 attachments: [],
12311394 },
12321395 modelSelection: {
1233 provider: "claudeAgent",
1396 instanceId: ProviderInstanceId.make("claudeAgent"),
12341397 model: "claude-opus-4-6",
12351398 },
12361399 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -1266,6 +1429,72 @@ describe("ProviderCommandReactor", () => {
12661429 });
12671430 });
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
12691498 it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => {
12701499 const harness = await createHarness();
12711500 const now = new Date().toISOString();
......@@ -1349,7 +1578,7 @@ describe("ProviderCommandReactor", () => {
13491578 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
13501579 threadId: ThreadId.make("thread-1"),
13511580 modelSelection: {
1352 provider: "codex",
1581 instanceId: ProviderInstanceId.make("codex"),
13531582 model: "gpt-5-codex",
13541583 },
13551584 runtimeMode: "approval-required",
......@@ -1359,6 +1588,77 @@ describe("ProviderCommandReactor", () => {
13591588 });
13601589 });
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
13621662 it("reacts to thread.approval.respond by forwarding provider approval response", async () => {
13631663 const harness = await createHarness();
13641664 const now = new Date().toISOString();
......@@ -1451,7 +1751,7 @@ describe("ProviderCommandReactor", () => {
14511751 harness.respondToRequest.mockImplementation(() =>
14521752 Effect.fail(
14531753 new ProviderAdapterRequestError({
1454 provider: "codex",
1754 provider: ProviderDriverKind.make("codex"),
14551755 method: "session/request_permission",
14561756 detail: "Unknown pending permission request: approval-request-1",
14571757 }),
......@@ -1546,7 +1846,7 @@ describe("ProviderCommandReactor", () => {
15461846 harness.respondToUserInput.mockImplementation(() =>
15471847 Effect.fail(
15481848 new ProviderAdapterRequestError({
1549 provider: "claudeAgent",
1849 provider: ProviderDriverKind.make("claudeAgent"),
15501850 method: "item/tool/respondToUserInput",
15511851 detail: "Unknown pending user-input request: user-input-request-1",
15521852 }),
......@@ -1662,6 +1962,7 @@ describe("ProviderCommandReactor", () => {
16621962 threadId: ThreadId.make("thread-1"),
16631963 status: "ready",
16641964 providerName: "codex",
1965 providerInstanceId: ProviderInstanceId.make("codex_work"),
16651966 runtimeMode: "approval-required",
16661967 activeTurnId: null,
16671968 lastError: null,
......@@ -1686,6 +1987,7 @@ describe("ProviderCommandReactor", () => {
16861987 expect(thread?.session).not.toBeNull();
16871988 expect(thread?.session?.status).toBe("stopped");
16881989 expect(thread?.session?.threadId).toBe("thread-1");
1990 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
16891991 expect(thread?.session?.activeTurnId).toBeNull();
16901992 });
16911993});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+144-39
......@@ -4,7 +4,7 @@ import {
44 EventId,
55 type ModelSelection,
66 type OrchestrationEvent,
7 ProviderKind,
7 ProviderDriverKind,
88 type OrchestrationSession,
99 ThreadId,
1010 type ProviderSession,
......@@ -77,6 +77,21 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
7777const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
7878const 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
8095function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
8196 const trimmedCurrentTitle = currentTitle.trim();
8297 if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) {
......@@ -271,42 +286,108 @@ const make = Effect.gen(function* () {
271286 }
272287
273288 const desiredRuntimeMode = thread.runtimeMode;
274 const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)(
275 thread.session?.providerName,
276 )
277 ? thread.session.providerName
278 : undefined;
279289 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;
281300 if (
282 requestedModelSelection !== undefined &&
283 requestedModelSelection.provider !== threadProvider
301 activeThreadSession !== null &&
302 activeSession !== undefined &&
303 (activeThreadSession.providerInstanceId === undefined ||
304 activeSession.providerInstanceId === undefined)
284305 ) {
285306 return yield* new ProviderAdapterRequestError({
286 provider: threadProvider,
307 provider: providerErrorLabel(activeThreadSession.providerName ?? undefined),
287308 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.`,
289310 });
290311 }
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;
292318 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 }
293378 const effectiveCwd = resolveThreadWorkspaceCwd({
294379 thread,
295380 projects: readModel.projects,
296381 });
297382
298 const resolveActiveSession = (threadId: ThreadId) =>
299 providerService
300 .listSessions()
301 .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)));
302
303383 const startProviderSession = (input?: {
304384 readonly resumeCursor?: unknown;
305 readonly provider?: ProviderKind;
385 readonly provider?: ProviderDriverKind;
306386 }) =>
307387 providerService.startSession(threadId, {
308388 threadId,
309389 ...(preferredProvider ? { provider: preferredProvider } : {}),
390 providerInstanceId: desiredInstanceId,
310391 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
311392 modelSelection: desiredModelSelection,
312393 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
......@@ -314,44 +395,55 @@ const make = Effect.gen(function* () {
314395 });
315396
316397 const bindSessionToThread = (session: ProviderSession) =>
317 setThreadSession({
318 threadId,
319 session: {
398 Effect.gen(function* () {
399 if (session.providerInstanceId === undefined) {
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({
320407 threadId,
321 status: mapProviderSessionStatusToOrchestrationStatus(session.status),
322 providerName: session.provider,
323 runtimeMode: desiredRuntimeMode,
324 // Provider turn ids are not orchestration turn ids.
325 activeTurnId: null,
326 lastError: session.lastError ?? null,
327 updatedAt: session.updatedAt,
328 },
329 createdAt,
408 session: {
409 threadId,
410 status: mapProviderSessionStatusToOrchestrationStatus(session.status),
411 providerName: session.provider,
412 providerInstanceId: session.providerInstanceId,
413 runtimeMode: desiredRuntimeMode,
414 // Provider turn ids are not orchestration turn ids.
415 activeTurnId: null,
416 lastError: session.lastError ?? null,
417 updatedAt: session.updatedAt,
418 },
419 createdAt,
420 });
330421 });
331422
332 const activeSession = yield* resolveActiveSession(threadId);
333423 const existingSessionThreadId =
334424 thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null;
335425 if (existingSessionThreadId) {
336426 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
337427 const cwdChanged = effectiveCwd !== activeSession?.cwd;
338 const sessionModelSwitch =
339 currentProvider === undefined
340 ? "in-session"
341 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;
428 const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId))
429 .sessionModelSwitch;
342430 const modelChanged =
343431 requestedModelSelection !== undefined &&
344432 requestedModelSelection.model !== activeSession?.model;
433 const instanceChanged =
434 requestedModelSelection !== undefined &&
435 activeSession?.providerInstanceId !== requestedModelSelection.instanceId;
345436 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported";
346437 const previousModelSelection = threadModelSelections.get(threadId);
347438 const shouldRestartForModelSelectionChange =
348 currentProvider === "claudeAgent" &&
439 preferredProvider === "claudeAgent" &&
349440 requestedModelSelection !== undefined &&
350441 !Equal.equals(previousModelSelection, requestedModelSelection);
351442
352443 if (
353444 !runtimeModeChanged &&
354445 !cwdChanged &&
446 !instanceChanged &&
355447 !shouldRestartForModelChange &&
356448 !shouldRestartForModelSelectionChange
357449 ) {
......@@ -364,8 +456,10 @@ const make = Effect.gen(function* () {
364456 yield* Effect.logInfo("provider command reactor restarting provider session", {
365457 threadId,
366458 existingSessionThreadId,
367 currentProvider,
368 desiredProvider: desiredModelSelection.provider,
459 currentProvider: activeSession?.provider,
460 currentInstanceId,
461 desiredInstanceId,
462 desiredProvider: desiredModelSelection.instanceId,
369463 currentRuntimeMode: thread.session?.runtimeMode,
370464 desiredRuntimeMode: thread.runtimeMode,
371465 runtimeModeChanged,
......@@ -373,6 +467,7 @@ const make = Effect.gen(function* () {
373467 desiredCwd: effectiveCwd,
374468 cwdChanged,
375469 modelChanged,
470 instanceChanged,
376471 shouldRestartForModelChange,
377472 shouldRestartForModelSelectionChange,
378473 hasResumeCursor: resumeCursor !== undefined,
......@@ -429,7 +524,14 @@ const make = Effect.gen(function* () {
429524 const sessionModelSwitch =
430525 activeSession === undefined
431526 ? "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;
433535 const requestedModelSelection =
434536 input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection;
435537 const modelForTurn =
......@@ -800,6 +902,9 @@ const make = Effect.gen(function* () {
800902 threadId: thread.id,
801903 status: "stopped",
802904 providerName: thread.session?.providerName ?? null,
905 ...(thread.session?.providerInstanceId !== undefined
906 ? { providerInstanceId: thread.session.providerInstanceId }
907 : {}),
803908 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
804909 activeTurnId: null,
805910 lastError: thread.session?.lastError ?? null,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+122-107
......@@ -2,10 +2,12 @@ import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
44
5import type {
5import {
66 OrchestrationReadModel,
7 ProviderDriverKind,
78 ProviderRuntimeEvent,
89 ProviderSession,
10 ProviderInstanceId,
911} from "@t3tools/contracts";
1012import {
1113 ApprovalRequestId,
......@@ -98,6 +100,19 @@ function createProviderServiceHarness() {
98100 stopSession: () => unsupported(),
99101 listSessions: () => Effect.succeed([...runtimeSessions]),
100102 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 },
101116 rollbackConversation: () => unsupported(),
102117 get streamEvents() {
103118 return Stream.fromPubSub(runtimeEventPubSub);
......@@ -232,7 +247,7 @@ describe("ProviderRuntimeIngestion", () => {
232247 title: "Provider Project",
233248 workspaceRoot,
234249 defaultModelSelection: {
235 provider: "codex",
250 instanceId: ProviderInstanceId.make("codex"),
236251 model: "gpt-5-codex",
237252 },
238253 createdAt,
......@@ -246,7 +261,7 @@ describe("ProviderRuntimeIngestion", () => {
246261 projectId: asProjectId("project-1"),
247262 title: "Thread",
248263 modelSelection: {
249 provider: "codex",
264 instanceId: ProviderInstanceId.make("codex"),
250265 model: "gpt-5-codex",
251266 },
252267 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -274,7 +289,7 @@ describe("ProviderRuntimeIngestion", () => {
274289 }),
275290 );
276291 provider.setSession({
277 provider: "codex",
292 provider: ProviderDriverKind.make("codex"),
278293 status: "ready",
279294 runtimeMode: "approval-required",
280295 threadId: ThreadId.make("thread-1"),
......@@ -297,7 +312,7 @@ describe("ProviderRuntimeIngestion", () => {
297312 harness.emit({
298313 type: "turn.started",
299314 eventId: asEventId("evt-turn-started"),
300 provider: "codex",
315 provider: ProviderDriverKind.make("codex"),
301316 threadId: asThreadId("thread-1"),
302317 createdAt: now,
303318 turnId: asTurnId("turn-1"),
......@@ -311,7 +326,7 @@ describe("ProviderRuntimeIngestion", () => {
311326 harness.emit({
312327 type: "turn.completed",
313328 eventId: asEventId("evt-turn-completed"),
314 provider: "codex",
329 provider: ProviderDriverKind.make("codex"),
315330 threadId: asThreadId("thread-1"),
316331 createdAt: new Date().toISOString(),
317332 turnId: asTurnId("turn-1"),
......@@ -339,7 +354,7 @@ describe("ProviderRuntimeIngestion", () => {
339354 harness.emit({
340355 type: "session.state.changed",
341356 eventId: asEventId("evt-session-state-waiting"),
342 provider: "codex",
357 provider: ProviderDriverKind.make("codex"),
343358 threadId: asThreadId("thread-1"),
344359 createdAt: waitingAt,
345360 payload: {
......@@ -358,7 +373,7 @@ describe("ProviderRuntimeIngestion", () => {
358373 harness.emit({
359374 type: "session.state.changed",
360375 eventId: asEventId("evt-session-state-error"),
361 provider: "codex",
376 provider: ProviderDriverKind.make("codex"),
362377 threadId: asThreadId("thread-1"),
363378 createdAt: new Date().toISOString(),
364379 payload: {
......@@ -380,7 +395,7 @@ describe("ProviderRuntimeIngestion", () => {
380395 harness.emit({
381396 type: "session.state.changed",
382397 eventId: asEventId("evt-session-state-stopped"),
383 provider: "codex",
398 provider: ProviderDriverKind.make("codex"),
384399 threadId: asThreadId("thread-1"),
385400 createdAt: new Date().toISOString(),
386401 payload: {
......@@ -401,7 +416,7 @@ describe("ProviderRuntimeIngestion", () => {
401416 harness.emit({
402417 type: "session.state.changed",
403418 eventId: asEventId("evt-session-state-ready"),
404 provider: "codex",
419 provider: ProviderDriverKind.make("codex"),
405420 threadId: asThreadId("thread-1"),
406421 createdAt: new Date().toISOString(),
407422 payload: {
......@@ -427,7 +442,7 @@ describe("ProviderRuntimeIngestion", () => {
427442 harness.emit({
428443 type: "turn.started",
429444 eventId: asEventId("evt-turn-started-midturn-lifecycle"),
430 provider: "codex",
445 provider: ProviderDriverKind.make("codex"),
431446 createdAt: now,
432447 threadId: asThreadId("thread-1"),
433448 turnId: asTurnId("turn-midturn-lifecycle"),
......@@ -443,14 +458,14 @@ describe("ProviderRuntimeIngestion", () => {
443458 harness.emit({
444459 type: "thread.started",
445460 eventId: asEventId("evt-thread-started-midturn-lifecycle"),
446 provider: "codex",
461 provider: ProviderDriverKind.make("codex"),
447462 createdAt: new Date().toISOString(),
448463 threadId: asThreadId("thread-1"),
449464 });
450465 harness.emit({
451466 type: "session.started",
452467 eventId: asEventId("evt-session-started-midturn-lifecycle"),
453 provider: "codex",
468 provider: ProviderDriverKind.make("codex"),
454469 createdAt: new Date().toISOString(),
455470 threadId: asThreadId("thread-1"),
456471 });
......@@ -464,7 +479,7 @@ describe("ProviderRuntimeIngestion", () => {
464479 harness.emit({
465480 type: "turn.completed",
466481 eventId: asEventId("evt-turn-completed-midturn-lifecycle"),
467 provider: "codex",
482 provider: ProviderDriverKind.make("codex"),
468483 createdAt: new Date().toISOString(),
469484 threadId: asThreadId("thread-1"),
470485 turnId: asTurnId("turn-midturn-lifecycle"),
......@@ -502,7 +517,7 @@ describe("ProviderRuntimeIngestion", () => {
502517 harness.emit({
503518 type: "turn.started",
504519 eventId: asEventId("evt-turn-started-claude-placeholder"),
505 provider: "claudeAgent",
520 provider: ProviderDriverKind.make("claudeAgent"),
506521 createdAt: new Date().toISOString(),
507522 threadId: asThreadId("thread-1"),
508523 turnId: asTurnId("turn-claude-placeholder"),
......@@ -518,7 +533,7 @@ describe("ProviderRuntimeIngestion", () => {
518533 harness.emit({
519534 type: "turn.completed",
520535 eventId: asEventId("evt-turn-completed-claude-placeholder"),
521 provider: "claudeAgent",
536 provider: ProviderDriverKind.make("claudeAgent"),
522537 createdAt: new Date().toISOString(),
523538 threadId: asThreadId("thread-1"),
524539 turnId: asTurnId("turn-claude-placeholder"),
......@@ -538,7 +553,7 @@ describe("ProviderRuntimeIngestion", () => {
538553 harness.emit({
539554 type: "turn.started",
540555 eventId: asEventId("evt-turn-started-primary"),
541 provider: "codex",
556 provider: ProviderDriverKind.make("codex"),
542557 createdAt: now,
543558 threadId: asThreadId("thread-1"),
544559 turnId: asTurnId("turn-primary"),
......@@ -553,7 +568,7 @@ describe("ProviderRuntimeIngestion", () => {
553568 harness.emit({
554569 type: "turn.completed",
555570 eventId: asEventId("evt-turn-completed-aux"),
556 provider: "codex",
571 provider: ProviderDriverKind.make("codex"),
557572 createdAt: new Date().toISOString(),
558573 threadId: asThreadId("thread-1"),
559574 turnId: asTurnId("turn-aux"),
......@@ -569,7 +584,7 @@ describe("ProviderRuntimeIngestion", () => {
569584 harness.emit({
570585 type: "turn.completed",
571586 eventId: asEventId("evt-turn-completed-primary"),
572 provider: "codex",
587 provider: ProviderDriverKind.make("codex"),
573588 createdAt: new Date().toISOString(),
574589 threadId: asThreadId("thread-1"),
575590 turnId: asTurnId("turn-primary"),
......@@ -589,7 +604,7 @@ describe("ProviderRuntimeIngestion", () => {
589604 harness.emit({
590605 type: "turn.started",
591606 eventId: asEventId("evt-turn-started-guarded"),
592 provider: "codex",
607 provider: ProviderDriverKind.make("codex"),
593608 createdAt: now,
594609 threadId: asThreadId("thread-1"),
595610 turnId: asTurnId("turn-guarded-main"),
......@@ -605,7 +620,7 @@ describe("ProviderRuntimeIngestion", () => {
605620 harness.emit({
606621 type: "turn.completed",
607622 eventId: asEventId("evt-turn-completed-guarded-other"),
608 provider: "codex",
623 provider: ProviderDriverKind.make("codex"),
609624 createdAt: new Date().toISOString(),
610625 threadId: asThreadId("thread-1"),
611626 turnId: asTurnId("turn-guarded-other"),
......@@ -621,7 +636,7 @@ describe("ProviderRuntimeIngestion", () => {
621636 harness.emit({
622637 type: "turn.completed",
623638 eventId: asEventId("evt-turn-completed-guarded-main"),
624 provider: "codex",
639 provider: ProviderDriverKind.make("codex"),
625640 createdAt: new Date().toISOString(),
626641 threadId: asThreadId("thread-1"),
627642 turnId: asTurnId("turn-guarded-main"),
......@@ -641,7 +656,7 @@ describe("ProviderRuntimeIngestion", () => {
641656 harness.emit({
642657 type: "content.delta",
643658 eventId: asEventId("evt-message-delta-1"),
644 provider: "codex",
659 provider: ProviderDriverKind.make("codex"),
645660 createdAt: now,
646661 threadId: asThreadId("thread-1"),
647662 turnId: asTurnId("turn-2"),
......@@ -654,7 +669,7 @@ describe("ProviderRuntimeIngestion", () => {
654669 harness.emit({
655670 type: "content.delta",
656671 eventId: asEventId("evt-message-delta-2"),
657 provider: "codex",
672 provider: ProviderDriverKind.make("codex"),
658673 createdAt: now,
659674 threadId: asThreadId("thread-1"),
660675 turnId: asTurnId("turn-2"),
......@@ -667,7 +682,7 @@ describe("ProviderRuntimeIngestion", () => {
667682 harness.emit({
668683 type: "item.completed",
669684 eventId: asEventId("evt-message-completed"),
670 provider: "codex",
685 provider: ProviderDriverKind.make("codex"),
671686 createdAt: now,
672687 threadId: asThreadId("thread-1"),
673688 turnId: asTurnId("turn-2"),
......@@ -698,7 +713,7 @@ describe("ProviderRuntimeIngestion", () => {
698713 harness.emit({
699714 type: "item.completed",
700715 eventId: asEventId("evt-assistant-item-completed-no-delta"),
701 provider: "codex",
716 provider: ProviderDriverKind.make("codex"),
702717 createdAt: now,
703718 threadId: asThreadId("thread-1"),
704719 turnId: asTurnId("turn-no-delta"),
......@@ -730,7 +745,7 @@ describe("ProviderRuntimeIngestion", () => {
730745 harness.emit({
731746 type: "item.completed",
732747 eventId: asEventId("evt-tool-completed-with-data"),
733 provider: "cursor",
748 provider: ProviderDriverKind.make("cursor"),
734749 createdAt: now,
735750 threadId: asThreadId("thread-1"),
736751 turnId: asTurnId("turn-tool-completed"),
......@@ -786,7 +801,7 @@ describe("ProviderRuntimeIngestion", () => {
786801 harness.emit({
787802 type: "item.completed",
788803 eventId: asEventId("evt-command-completed"),
789 provider: "cursor",
804 provider: ProviderDriverKind.make("cursor"),
790805 createdAt: now,
791806 threadId: asThreadId("thread-1"),
792807 turnId: asTurnId("turn-command-completed"),
......@@ -828,7 +843,7 @@ describe("ProviderRuntimeIngestion", () => {
828843 harness.emit({
829844 type: "item.completed",
830845 eventId: asEventId("evt-read-path-completed"),
831 provider: "cursor",
846 provider: ProviderDriverKind.make("cursor"),
832847 createdAt: now,
833848 threadId: asThreadId("thread-1"),
834849 turnId: asTurnId("turn-read-path"),
......@@ -870,7 +885,7 @@ describe("ProviderRuntimeIngestion", () => {
870885 harness.emit({
871886 type: "turn.proposed.completed",
872887 eventId: asEventId("evt-plan-item-completed"),
873 provider: "codex",
888 provider: ProviderDriverKind.make("codex"),
874889 createdAt: now,
875890 threadId: asThreadId("thread-1"),
876891 turnId: asTurnId("turn-plan-final"),
......@@ -909,7 +924,7 @@ describe("ProviderRuntimeIngestion", () => {
909924 projectId: asProjectId("project-1"),
910925 title: "Plan Source",
911926 modelSelection: {
912 provider: "codex",
927 instanceId: ProviderInstanceId.make("codex"),
913928 model: "gpt-5-codex",
914929 },
915930 interactionMode: "plan",
......@@ -944,7 +959,7 @@ describe("ProviderRuntimeIngestion", () => {
944959 projectId: asProjectId("project-1"),
945960 title: "Plan Target",
946961 modelSelection: {
947 provider: "codex",
962 instanceId: ProviderInstanceId.make("codex"),
948963 model: "gpt-5-codex",
949964 },
950965 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -972,7 +987,7 @@ describe("ProviderRuntimeIngestion", () => {
972987 }),
973988 );
974989 harness.setProviderSession({
975 provider: "codex",
990 provider: ProviderDriverKind.make("codex"),
976991 status: "ready",
977992 runtimeMode: "approval-required",
978993 threadId: targetThreadId,
......@@ -984,7 +999,7 @@ describe("ProviderRuntimeIngestion", () => {
984999 harness.emit({
9851000 type: "turn.proposed.completed",
9861001 eventId: asEventId("evt-plan-source-completed"),
987 provider: "codex",
1002 provider: ProviderDriverKind.make("codex"),
9881003 createdAt,
9891004 threadId: sourceThreadId,
9901005 turnId: sourceTurnId,
......@@ -1054,7 +1069,7 @@ describe("ProviderRuntimeIngestion", () => {
10541069 harness.emit({
10551070 type: "turn.started",
10561071 eventId: asEventId("evt-plan-target-started"),
1057 provider: "codex",
1072 provider: ProviderDriverKind.make("codex"),
10581073 createdAt: new Date().toISOString(),
10591074 threadId: targetThreadId,
10601075 turnId: targetTurnId,
......@@ -1096,7 +1111,7 @@ describe("ProviderRuntimeIngestion", () => {
10961111 projectId: asProjectId("project-1"),
10971112 title: "Plan Source",
10981113 modelSelection: {
1099 provider: "codex",
1114 instanceId: ProviderInstanceId.make("codex"),
11001115 model: "gpt-5-codex",
11011116 },
11021117 interactionMode: "plan",
......@@ -1124,7 +1139,7 @@ describe("ProviderRuntimeIngestion", () => {
11241139 }),
11251140 );
11261141 harness.setProviderSession({
1127 provider: "codex",
1142 provider: ProviderDriverKind.make("codex"),
11281143 status: "running",
11291144 runtimeMode: "approval-required",
11301145 threadId: targetThreadId,
......@@ -1136,7 +1151,7 @@ describe("ProviderRuntimeIngestion", () => {
11361151 harness.emit({
11371152 type: "turn.started",
11381153 eventId: asEventId("evt-turn-started-already-running"),
1139 provider: "codex",
1154 provider: ProviderDriverKind.make("codex"),
11401155 createdAt,
11411156 threadId: targetThreadId,
11421157 turnId: activeTurnId,
......@@ -1153,7 +1168,7 @@ describe("ProviderRuntimeIngestion", () => {
11531168 harness.emit({
11541169 type: "turn.proposed.completed",
11551170 eventId: asEventId("evt-plan-source-completed-guarded"),
1156 provider: "codex",
1171 provider: ProviderDriverKind.make("codex"),
11571172 createdAt,
11581173 threadId: sourceThreadId,
11591174 turnId: sourceTurnId,
......@@ -1206,7 +1221,7 @@ describe("ProviderRuntimeIngestion", () => {
12061221 harness.emit({
12071222 type: "turn.started",
12081223 eventId: asEventId("evt-turn-started-stale-plan-implementation"),
1209 provider: "codex",
1224 provider: ProviderDriverKind.make("codex"),
12101225 createdAt: new Date().toISOString(),
12111226 threadId: targetThreadId,
12121227 turnId: staleTurnId,
......@@ -1249,7 +1264,7 @@ describe("ProviderRuntimeIngestion", () => {
12491264 projectId: asProjectId("project-1"),
12501265 title: "Plan Source",
12511266 modelSelection: {
1252 provider: "codex",
1267 instanceId: ProviderInstanceId.make("codex"),
12531268 model: "gpt-5-codex",
12541269 },
12551270 interactionMode: "plan",
......@@ -1284,7 +1299,7 @@ describe("ProviderRuntimeIngestion", () => {
12841299 projectId: asProjectId("project-1"),
12851300 title: "Plan Target",
12861301 modelSelection: {
1287 provider: "codex",
1302 instanceId: ProviderInstanceId.make("codex"),
12881303 model: "gpt-5-codex",
12891304 },
12901305 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -1315,7 +1330,7 @@ describe("ProviderRuntimeIngestion", () => {
13151330 harness.emit({
13161331 type: "turn.proposed.completed",
13171332 eventId: asEventId("evt-plan-source-completed-unrelated"),
1318 provider: "codex",
1333 provider: ProviderDriverKind.make("codex"),
13191334 createdAt,
13201335 threadId: sourceThreadId,
13211336 turnId: sourceTurnId,
......@@ -1366,7 +1381,7 @@ describe("ProviderRuntimeIngestion", () => {
13661381 );
13671382
13681383 harness.setProviderSession({
1369 provider: "codex",
1384 provider: ProviderDriverKind.make("codex"),
13701385 status: "running",
13711386 runtimeMode: "approval-required",
13721387 threadId: targetThreadId,
......@@ -1378,7 +1393,7 @@ describe("ProviderRuntimeIngestion", () => {
13781393 harness.emit({
13791394 type: "turn.started",
13801395 eventId: asEventId("evt-turn-started-unrelated-plan-implementation"),
1381 provider: "codex",
1396 provider: ProviderDriverKind.make("codex"),
13821397 createdAt: new Date().toISOString(),
13831398 threadId: targetThreadId,
13841399 turnId: replayedTurnId,
......@@ -1405,7 +1420,7 @@ describe("ProviderRuntimeIngestion", () => {
14051420 harness.emit({
14061421 type: "turn.started",
14071422 eventId: asEventId("evt-turn-started-plan-buffer"),
1408 provider: "codex",
1423 provider: ProviderDriverKind.make("codex"),
14091424 createdAt: now,
14101425 threadId: asThreadId("thread-1"),
14111426 turnId: asTurnId("turn-plan-buffer"),
......@@ -1420,7 +1435,7 @@ describe("ProviderRuntimeIngestion", () => {
14201435 harness.emit({
14211436 type: "turn.proposed.delta",
14221437 eventId: asEventId("evt-plan-delta-1"),
1423 provider: "codex",
1438 provider: ProviderDriverKind.make("codex"),
14241439 createdAt: now,
14251440 threadId: asThreadId("thread-1"),
14261441 turnId: asTurnId("turn-plan-buffer"),
......@@ -1431,7 +1446,7 @@ describe("ProviderRuntimeIngestion", () => {
14311446 harness.emit({
14321447 type: "turn.proposed.delta",
14331448 eventId: asEventId("evt-plan-delta-2"),
1434 provider: "codex",
1449 provider: ProviderDriverKind.make("codex"),
14351450 createdAt: now,
14361451 threadId: asThreadId("thread-1"),
14371452 turnId: asTurnId("turn-plan-buffer"),
......@@ -1442,7 +1457,7 @@ describe("ProviderRuntimeIngestion", () => {
14421457 harness.emit({
14431458 type: "turn.completed",
14441459 eventId: asEventId("evt-turn-completed-plan-buffer"),
1445 provider: "codex",
1460 provider: ProviderDriverKind.make("codex"),
14461461 createdAt: now,
14471462 threadId: asThreadId("thread-1"),
14481463 turnId: asTurnId("turn-plan-buffer"),
......@@ -1471,7 +1486,7 @@ describe("ProviderRuntimeIngestion", () => {
14711486 harness.emit({
14721487 type: "turn.started",
14731488 eventId: asEventId("evt-turn-started-buffered"),
1474 provider: "codex",
1489 provider: ProviderDriverKind.make("codex"),
14751490 createdAt: now,
14761491 threadId: asThreadId("thread-1"),
14771492 turnId: asTurnId("turn-buffered"),
......@@ -1485,7 +1500,7 @@ describe("ProviderRuntimeIngestion", () => {
14851500 harness.emit({
14861501 type: "content.delta",
14871502 eventId: asEventId("evt-message-delta-buffered"),
1488 provider: "codex",
1503 provider: ProviderDriverKind.make("codex"),
14891504 createdAt: now,
14901505 threadId: asThreadId("thread-1"),
14911506 turnId: asTurnId("turn-buffered"),
......@@ -1508,7 +1523,7 @@ describe("ProviderRuntimeIngestion", () => {
15081523 harness.emit({
15091524 type: "item.completed",
15101525 eventId: asEventId("evt-message-completed-buffered"),
1511 provider: "codex",
1526 provider: ProviderDriverKind.make("codex"),
15121527 createdAt: now,
15131528 threadId: asThreadId("thread-1"),
15141529 turnId: asTurnId("turn-buffered"),
......@@ -1539,7 +1554,7 @@ describe("ProviderRuntimeIngestion", () => {
15391554 harness.emit({
15401555 type: "turn.started",
15411556 eventId: asEventId("evt-turn-started-buffered-request-flush"),
1542 provider: "codex",
1557 provider: ProviderDriverKind.make("codex"),
15431558 createdAt: now,
15441559 threadId: asThreadId("thread-1"),
15451560 turnId: asTurnId("turn-buffered-request-flush"),
......@@ -1554,7 +1569,7 @@ describe("ProviderRuntimeIngestion", () => {
15541569 harness.emit({
15551570 type: "content.delta",
15561571 eventId: asEventId("evt-message-delta-buffered-request-flush"),
1557 provider: "codex",
1572 provider: ProviderDriverKind.make("codex"),
15581573 createdAt: now,
15591574 threadId: asThreadId("thread-1"),
15601575 turnId: asTurnId("turn-buffered-request-flush"),
......@@ -1567,7 +1582,7 @@ describe("ProviderRuntimeIngestion", () => {
15671582 harness.emit({
15681583 type: "request.opened",
15691584 eventId: asEventId("evt-request-opened-buffered-request-flush"),
1570 provider: "codex",
1585 provider: ProviderDriverKind.make("codex"),
15711586 createdAt: now,
15721587 threadId: asThreadId("thread-1"),
15731588 turnId: asTurnId("turn-buffered-request-flush"),
......@@ -1599,7 +1614,7 @@ describe("ProviderRuntimeIngestion", () => {
15991614 harness.emit({
16001615 type: "turn.started",
16011616 eventId: asEventId("evt-turn-started-buffered-user-input-flush"),
1602 provider: "codex",
1617 provider: ProviderDriverKind.make("codex"),
16031618 createdAt: now,
16041619 threadId: asThreadId("thread-1"),
16051620 turnId: asTurnId("turn-buffered-user-input-flush"),
......@@ -1614,7 +1629,7 @@ describe("ProviderRuntimeIngestion", () => {
16141629 harness.emit({
16151630 type: "content.delta",
16161631 eventId: asEventId("evt-message-delta-buffered-user-input-flush"),
1617 provider: "codex",
1632 provider: ProviderDriverKind.make("codex"),
16181633 createdAt: now,
16191634 threadId: asThreadId("thread-1"),
16201635 turnId: asTurnId("turn-buffered-user-input-flush"),
......@@ -1627,7 +1642,7 @@ describe("ProviderRuntimeIngestion", () => {
16271642 harness.emit({
16281643 type: "user-input.requested",
16291644 eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"),
1630 provider: "codex",
1645 provider: ProviderDriverKind.make("codex"),
16311646 createdAt: now,
16321647 threadId: asThreadId("thread-1"),
16331648 turnId: asTurnId("turn-buffered-user-input-flush"),
......@@ -1667,7 +1682,7 @@ describe("ProviderRuntimeIngestion", () => {
16671682 harness.emit({
16681683 type: "turn.started",
16691684 eventId: asEventId("evt-turn-started-buffered-whitespace-request"),
1670 provider: "codex",
1685 provider: ProviderDriverKind.make("codex"),
16711686 createdAt: startedAt,
16721687 threadId: asThreadId("thread-1"),
16731688 turnId: asTurnId("turn-buffered-whitespace-request"),
......@@ -1682,7 +1697,7 @@ describe("ProviderRuntimeIngestion", () => {
16821697 harness.emit({
16831698 type: "content.delta",
16841699 eventId: asEventId("evt-message-delta-buffered-whitespace-request"),
1685 provider: "codex",
1700 provider: ProviderDriverKind.make("codex"),
16861701 createdAt: startedAt,
16871702 threadId: asThreadId("thread-1"),
16881703 turnId: asTurnId("turn-buffered-whitespace-request"),
......@@ -1695,7 +1710,7 @@ describe("ProviderRuntimeIngestion", () => {
16951710 harness.emit({
16961711 type: "request.opened",
16971712 eventId: asEventId("evt-request-opened-buffered-whitespace-request"),
1698 provider: "codex",
1713 provider: ProviderDriverKind.make("codex"),
16991714 createdAt: pausedAt,
17001715 threadId: asThreadId("thread-1"),
17011716 turnId: asTurnId("turn-buffered-whitespace-request"),
......@@ -1729,7 +1744,7 @@ describe("ProviderRuntimeIngestion", () => {
17291744 harness.emit({
17301745 type: "turn.started",
17311746 eventId: asEventId("evt-turn-started-buffered-request-append"),
1732 provider: "codex",
1747 provider: ProviderDriverKind.make("codex"),
17331748 createdAt: startedAt,
17341749 threadId: asThreadId("thread-1"),
17351750 turnId: asTurnId("turn-buffered-request-append"),
......@@ -1744,7 +1759,7 @@ describe("ProviderRuntimeIngestion", () => {
17441759 harness.emit({
17451760 type: "content.delta",
17461761 eventId: asEventId("evt-message-delta-buffered-request-append-initial"),
1747 provider: "codex",
1762 provider: ProviderDriverKind.make("codex"),
17481763 createdAt: startedAt,
17491764 threadId: asThreadId("thread-1"),
17501765 turnId: asTurnId("turn-buffered-request-append"),
......@@ -1757,7 +1772,7 @@ describe("ProviderRuntimeIngestion", () => {
17571772 harness.emit({
17581773 type: "request.opened",
17591774 eventId: asEventId("evt-request-opened-buffered-request-append"),
1760 provider: "codex",
1775 provider: ProviderDriverKind.make("codex"),
17611776 createdAt: pausedAt,
17621777 threadId: asThreadId("thread-1"),
17631778 turnId: asTurnId("turn-buffered-request-append"),
......@@ -1780,7 +1795,7 @@ describe("ProviderRuntimeIngestion", () => {
17801795 harness.emit({
17811796 type: "content.delta",
17821797 eventId: asEventId("evt-message-delta-buffered-request-append-followup"),
1783 provider: "codex",
1798 provider: ProviderDriverKind.make("codex"),
17841799 createdAt: resumedAt,
17851800 threadId: asThreadId("thread-1"),
17861801 turnId: asTurnId("turn-buffered-request-append"),
......@@ -1793,7 +1808,7 @@ describe("ProviderRuntimeIngestion", () => {
17931808 harness.emit({
17941809 type: "item.completed",
17951810 eventId: asEventId("evt-message-completed-buffered-request-append"),
1796 provider: "codex",
1811 provider: ProviderDriverKind.make("codex"),
17971812 createdAt: completedAt,
17981813 threadId: asThreadId("thread-1"),
17991814 turnId: asTurnId("turn-buffered-request-append"),
......@@ -1861,7 +1876,7 @@ describe("ProviderRuntimeIngestion", () => {
18611876 harness.emit({
18621877 type: "turn.started",
18631878 eventId: asEventId("evt-turn-started-streaming-request-segment"),
1864 provider: "codex",
1879 provider: ProviderDriverKind.make("codex"),
18651880 createdAt: startedAt,
18661881 threadId: asThreadId("thread-1"),
18671882 turnId: asTurnId("turn-streaming-request-segment"),
......@@ -1876,7 +1891,7 @@ describe("ProviderRuntimeIngestion", () => {
18761891 harness.emit({
18771892 type: "content.delta",
18781893 eventId: asEventId("evt-message-delta-streaming-request-segment-initial"),
1879 provider: "codex",
1894 provider: ProviderDriverKind.make("codex"),
18801895 createdAt: startedAt,
18811896 threadId: asThreadId("thread-1"),
18821897 turnId: asTurnId("turn-streaming-request-segment"),
......@@ -1889,7 +1904,7 @@ describe("ProviderRuntimeIngestion", () => {
18891904 harness.emit({
18901905 type: "request.opened",
18911906 eventId: asEventId("evt-request-opened-streaming-request-segment"),
1892 provider: "codex",
1907 provider: ProviderDriverKind.make("codex"),
18931908 createdAt: pausedAt,
18941909 threadId: asThreadId("thread-1"),
18951910 turnId: asTurnId("turn-streaming-request-segment"),
......@@ -1912,7 +1927,7 @@ describe("ProviderRuntimeIngestion", () => {
19121927 harness.emit({
19131928 type: "content.delta",
19141929 eventId: asEventId("evt-message-delta-streaming-request-segment-followup"),
1915 provider: "codex",
1930 provider: ProviderDriverKind.make("codex"),
19161931 createdAt: resumedAt,
19171932 threadId: asThreadId("thread-1"),
19181933 turnId: asTurnId("turn-streaming-request-segment"),
......@@ -1925,7 +1940,7 @@ describe("ProviderRuntimeIngestion", () => {
19251940 harness.emit({
19261941 type: "item.completed",
19271942 eventId: asEventId("evt-message-completed-streaming-request-segment"),
1928 provider: "codex",
1943 provider: ProviderDriverKind.make("codex"),
19291944 createdAt: completedAt,
19301945 threadId: asThreadId("thread-1"),
19311946 turnId: asTurnId("turn-streaming-request-segment"),
......@@ -1983,7 +1998,7 @@ describe("ProviderRuntimeIngestion", () => {
19831998 harness.emit({
19841999 type: "turn.started",
19852000 eventId: asEventId("evt-turn-started-streaming-mode"),
1986 provider: "codex",
2001 provider: ProviderDriverKind.make("codex"),
19872002 createdAt: now,
19882003 threadId: asThreadId("thread-1"),
19892004 turnId: asTurnId("turn-streaming-mode"),
......@@ -1998,7 +2013,7 @@ describe("ProviderRuntimeIngestion", () => {
19982013 harness.emit({
19992014 type: "content.delta",
20002015 eventId: asEventId("evt-message-delta-streaming-mode"),
2001 provider: "codex",
2016 provider: ProviderDriverKind.make("codex"),
20022017 createdAt: now,
20032018 threadId: asThreadId("thread-1"),
20042019 turnId: asTurnId("turn-streaming-mode"),
......@@ -2025,7 +2040,7 @@ describe("ProviderRuntimeIngestion", () => {
20252040 harness.emit({
20262041 type: "item.completed",
20272042 eventId: asEventId("evt-message-completed-streaming-mode"),
2028 provider: "codex",
2043 provider: ProviderDriverKind.make("codex"),
20292044 createdAt: now,
20302045 threadId: asThreadId("thread-1"),
20312046 turnId: asTurnId("turn-streaming-mode"),
......@@ -2058,7 +2073,7 @@ describe("ProviderRuntimeIngestion", () => {
20582073 harness.emit({
20592074 type: "turn.started",
20602075 eventId: asEventId("evt-turn-started-buffer-spill"),
2061 provider: "codex",
2076 provider: ProviderDriverKind.make("codex"),
20622077 createdAt: now,
20632078 threadId: asThreadId("thread-1"),
20642079 turnId: asTurnId("turn-buffer-spill"),
......@@ -2073,7 +2088,7 @@ describe("ProviderRuntimeIngestion", () => {
20732088 harness.emit({
20742089 type: "content.delta",
20752090 eventId: asEventId("evt-message-delta-buffer-spill"),
2076 provider: "codex",
2091 provider: ProviderDriverKind.make("codex"),
20772092 createdAt: now,
20782093 threadId: asThreadId("thread-1"),
20792094 turnId: asTurnId("turn-buffer-spill"),
......@@ -2086,7 +2101,7 @@ describe("ProviderRuntimeIngestion", () => {
20862101 harness.emit({
20872102 type: "item.completed",
20882103 eventId: asEventId("evt-message-completed-buffer-spill"),
2089 provider: "codex",
2104 provider: ProviderDriverKind.make("codex"),
20902105 createdAt: now,
20912106 threadId: asThreadId("thread-1"),
20922107 turnId: asTurnId("turn-buffer-spill"),
......@@ -2118,7 +2133,7 @@ describe("ProviderRuntimeIngestion", () => {
21182133 harness.emit({
21192134 type: "turn.started",
21202135 eventId: asEventId("evt-turn-started-for-complete-dedup"),
2121 provider: "codex",
2136 provider: ProviderDriverKind.make("codex"),
21222137 createdAt: now,
21232138 threadId: asThreadId("thread-1"),
21242139 turnId: asTurnId("turn-complete-dedup"),
......@@ -2134,7 +2149,7 @@ describe("ProviderRuntimeIngestion", () => {
21342149 harness.emit({
21352150 type: "content.delta",
21362151 eventId: asEventId("evt-message-delta-for-complete-dedup"),
2137 provider: "codex",
2152 provider: ProviderDriverKind.make("codex"),
21382153 createdAt: now,
21392154 threadId: asThreadId("thread-1"),
21402155 turnId: asTurnId("turn-complete-dedup"),
......@@ -2147,7 +2162,7 @@ describe("ProviderRuntimeIngestion", () => {
21472162 harness.emit({
21482163 type: "item.completed",
21492164 eventId: asEventId("evt-message-completed-for-complete-dedup"),
2150 provider: "codex",
2165 provider: ProviderDriverKind.make("codex"),
21512166 createdAt: now,
21522167 threadId: asThreadId("thread-1"),
21532168 turnId: asTurnId("turn-complete-dedup"),
......@@ -2160,7 +2175,7 @@ describe("ProviderRuntimeIngestion", () => {
21602175 harness.emit({
21612176 type: "turn.completed",
21622177 eventId: asEventId("evt-turn-completed-for-complete-dedup"),
2163 provider: "codex",
2178 provider: ProviderDriverKind.make("codex"),
21642179 createdAt: now,
21652180 threadId: asThreadId("thread-1"),
21662181 turnId: asTurnId("turn-complete-dedup"),
......@@ -2204,7 +2219,7 @@ describe("ProviderRuntimeIngestion", () => {
22042219 harness.emit({
22052220 type: "request.opened",
22062221 eventId: asEventId("evt-request-opened"),
2207 provider: "codex",
2222 provider: ProviderDriverKind.make("codex"),
22082223 createdAt: now,
22092224 threadId: asThreadId("thread-1"),
22102225 requestId: ApprovalRequestId.make("req-open"),
......@@ -2217,7 +2232,7 @@ describe("ProviderRuntimeIngestion", () => {
22172232 harness.emit({
22182233 type: "request.resolved",
22192234 eventId: asEventId("evt-request-resolved"),
2220 provider: "codex",
2235 provider: ProviderDriverKind.make("codex"),
22212236 createdAt: now,
22222237 threadId: asThreadId("thread-1"),
22232238 requestId: ApprovalRequestId.make("req-open"),
......@@ -2270,7 +2285,7 @@ describe("ProviderRuntimeIngestion", () => {
22702285 harness.emit({
22712286 type: "runtime.error",
22722287 eventId: asEventId("evt-runtime-error"),
2273 provider: "codex",
2288 provider: ProviderDriverKind.make("codex"),
22742289 createdAt: now,
22752290 threadId: asThreadId("thread-1"),
22762291 turnId: asTurnId("turn-3"),
......@@ -2297,7 +2312,7 @@ describe("ProviderRuntimeIngestion", () => {
22972312 harness.emit({
22982313 type: "runtime.error",
22992314 eventId: asEventId("evt-runtime-error-activity"),
2300 provider: "codex",
2315 provider: ProviderDriverKind.make("codex"),
23012316 createdAt: now,
23022317 threadId: asThreadId("thread-1"),
23032318 turnId: asTurnId("turn-runtime-error-activity"),
......@@ -2328,7 +2343,7 @@ describe("ProviderRuntimeIngestion", () => {
23282343 harness.emit({
23292344 type: "turn.started",
23302345 eventId: asEventId("evt-warning-turn-started"),
2331 provider: "codex",
2346 provider: ProviderDriverKind.make("codex"),
23322347 createdAt: now,
23332348 threadId: asThreadId("thread-1"),
23342349 turnId: asTurnId("turn-warning"),
......@@ -2338,7 +2353,7 @@ describe("ProviderRuntimeIngestion", () => {
23382353 harness.emit({
23392354 type: "runtime.warning",
23402355 eventId: asEventId("evt-warning-runtime"),
2341 provider: "codex",
2356 provider: ProviderDriverKind.make("codex"),
23422357 createdAt: now,
23432358 threadId: asThreadId("thread-1"),
23442359 turnId: asTurnId("turn-warning"),
......@@ -2372,7 +2387,7 @@ describe("ProviderRuntimeIngestion", () => {
23722387 harness.emit({
23732388 type: "session.started",
23742389 eventId: asEventId("evt-session-started"),
2375 provider: "codex",
2390 provider: ProviderDriverKind.make("codex"),
23762391 createdAt: now,
23772392 threadId: asThreadId("thread-1"),
23782393 message: "session started",
......@@ -2380,14 +2395,14 @@ describe("ProviderRuntimeIngestion", () => {
23802395 harness.emit({
23812396 type: "thread.started",
23822397 eventId: asEventId("evt-thread-started"),
2383 provider: "codex",
2398 provider: ProviderDriverKind.make("codex"),
23842399 createdAt: now,
23852400 threadId: asThreadId("thread-1"),
23862401 });
23872402 harness.emit({
23882403 type: "item.started",
23892404 eventId: asEventId("evt-tool-started"),
2390 provider: "codex",
2405 provider: ProviderDriverKind.make("codex"),
23912406 createdAt: now,
23922407 threadId: asThreadId("thread-1"),
23932408 turnId: asTurnId("turn-9"),
......@@ -2424,7 +2439,7 @@ describe("ProviderRuntimeIngestion", () => {
24242439 harness.emit({
24252440 type: "thread.metadata.updated",
24262441 eventId: asEventId("evt-thread-metadata-updated"),
2427 provider: "codex",
2442 provider: ProviderDriverKind.make("codex"),
24282443 createdAt: now,
24292444 threadId: asThreadId("thread-1"),
24302445 payload: {
......@@ -2436,7 +2451,7 @@ describe("ProviderRuntimeIngestion", () => {
24362451 harness.emit({
24372452 type: "turn.plan.updated",
24382453 eventId: asEventId("evt-turn-plan-updated"),
2439 provider: "codex",
2454 provider: ProviderDriverKind.make("codex"),
24402455 createdAt: now,
24412456 threadId: asThreadId("thread-1"),
24422457 turnId: asTurnId("turn-p1"),
......@@ -2452,7 +2467,7 @@ describe("ProviderRuntimeIngestion", () => {
24522467 harness.emit({
24532468 type: "item.updated",
24542469 eventId: asEventId("evt-item-updated"),
2455 provider: "codex",
2470 provider: ProviderDriverKind.make("codex"),
24562471 createdAt: now,
24572472 threadId: asThreadId("thread-1"),
24582473 turnId: asTurnId("turn-p1"),
......@@ -2469,7 +2484,7 @@ describe("ProviderRuntimeIngestion", () => {
24692484 harness.emit({
24702485 type: "runtime.warning",
24712486 eventId: asEventId("evt-runtime-warning"),
2472 provider: "codex",
2487 provider: ProviderDriverKind.make("codex"),
24732488 createdAt: now,
24742489 threadId: asThreadId("thread-1"),
24752490 turnId: asTurnId("turn-p1"),
......@@ -2482,7 +2497,7 @@ describe("ProviderRuntimeIngestion", () => {
24822497 harness.emit({
24832498 type: "turn.diff.updated",
24842499 eventId: asEventId("evt-turn-diff-updated"),
2485 provider: "codex",
2500 provider: ProviderDriverKind.make("codex"),
24862501 createdAt: now,
24872502 threadId: asThreadId("thread-1"),
24882503 turnId: asTurnId("turn-p1"),
......@@ -2558,7 +2573,7 @@ describe("ProviderRuntimeIngestion", () => {
25582573 harness.emit({
25592574 type: "thread.token-usage.updated",
25602575 eventId: asEventId("evt-thread-token-usage-updated"),
2561 provider: "codex",
2576 provider: ProviderDriverKind.make("codex"),
25622577 createdAt: now,
25632578 threadId: asThreadId("thread-1"),
25642579 payload: {
......@@ -2610,7 +2625,7 @@ describe("ProviderRuntimeIngestion", () => {
26102625 harness.emit({
26112626 type: "thread.token-usage.updated",
26122627 eventId: asEventId("evt-thread-token-usage-updated-camel"),
2613 provider: "codex",
2628 provider: ProviderDriverKind.make("codex"),
26142629 createdAt: now,
26152630 threadId: asThreadId("thread-1"),
26162631 payload: {
......@@ -2663,7 +2678,7 @@ describe("ProviderRuntimeIngestion", () => {
26632678 harness.emit({
26642679 type: "thread.token-usage.updated",
26652680 eventId: asEventId("evt-thread-token-usage-updated-claude-window"),
2666 provider: "claudeAgent",
2681 provider: ProviderDriverKind.make("claudeAgent"),
26672682 createdAt: now,
26682683 threadId: asThreadId("thread-1"),
26692684 payload: {
......@@ -2707,7 +2722,7 @@ describe("ProviderRuntimeIngestion", () => {
27072722 harness.emit({
27082723 type: "thread.state.changed",
27092724 eventId: asEventId("evt-thread-compacted"),
2710 provider: "codex",
2725 provider: ProviderDriverKind.make("codex"),
27112726 createdAt: now,
27122727 threadId: asThreadId("thread-1"),
27132728 turnId: asTurnId("turn-1"),
......@@ -2737,7 +2752,7 @@ describe("ProviderRuntimeIngestion", () => {
27372752 harness.emit({
27382753 type: "task.started",
27392754 eventId: asEventId("evt-task-started"),
2740 provider: "codex",
2755 provider: ProviderDriverKind.make("codex"),
27412756 createdAt: now,
27422757 threadId: asThreadId("thread-1"),
27432758 turnId: asTurnId("turn-task-1"),
......@@ -2750,7 +2765,7 @@ describe("ProviderRuntimeIngestion", () => {
27502765 harness.emit({
27512766 type: "task.progress",
27522767 eventId: asEventId("evt-task-progress"),
2753 provider: "codex",
2768 provider: ProviderDriverKind.make("codex"),
27542769 createdAt: now,
27552770 threadId: asThreadId("thread-1"),
27562771 turnId: asTurnId("turn-task-1"),
......@@ -2764,7 +2779,7 @@ describe("ProviderRuntimeIngestion", () => {
27642779 harness.emit({
27652780 type: "task.completed",
27662781 eventId: asEventId("evt-task-completed"),
2767 provider: "codex",
2782 provider: ProviderDriverKind.make("codex"),
27682783 createdAt: now,
27692784 threadId: asThreadId("thread-1"),
27702785 turnId: asTurnId("turn-task-1"),
......@@ -2777,7 +2792,7 @@ describe("ProviderRuntimeIngestion", () => {
27772792 harness.emit({
27782793 type: "turn.proposed.completed",
27792794 eventId: asEventId("evt-task-proposed-plan-completed"),
2780 provider: "codex",
2795 provider: ProviderDriverKind.make("codex"),
27812796 createdAt: now,
27822797 threadId: asThreadId("thread-1"),
27832798 turnId: asTurnId("turn-task-1"),
......@@ -2840,7 +2855,7 @@ describe("ProviderRuntimeIngestion", () => {
28402855 harness.emit({
28412856 type: "user-input.requested",
28422857 eventId: asEventId("evt-user-input-requested"),
2843 provider: "codex",
2858 provider: ProviderDriverKind.make("codex"),
28442859 createdAt: now,
28452860 threadId: asThreadId("thread-1"),
28462861 turnId: asTurnId("turn-user-input"),
......@@ -2865,7 +2880,7 @@ describe("ProviderRuntimeIngestion", () => {
28652880 harness.emit({
28662881 type: "user-input.resolved",
28672882 eventId: asEventId("evt-user-input-resolved"),
2868 provider: "codex",
2883 provider: ProviderDriverKind.make("codex"),
28692884 createdAt: new Date().toISOString(),
28702885 threadId: asThreadId("thread-1"),
28712886 turnId: asTurnId("turn-user-input"),
......@@ -2913,7 +2928,7 @@ describe("ProviderRuntimeIngestion", () => {
29132928 harness.emit({
29142929 type: "content.delta",
29152930 eventId: asEventId("evt-invalid-delta"),
2916 provider: "codex",
2931 provider: ProviderDriverKind.make("codex"),
29172932 createdAt: now,
29182933 threadId: asThreadId("thread-1"),
29192934 turnId: asTurnId("turn-invalid"),
......@@ -2927,7 +2942,7 @@ describe("ProviderRuntimeIngestion", () => {
29272942 harness.emit({
29282943 type: "runtime.error",
29292944 eventId: asEventId("evt-runtime-error-after-failure"),
2930 provider: "codex",
2945 provider: ProviderDriverKind.make("codex"),
29312946 createdAt: new Date().toISOString(),
29322947 threadId: asThreadId("thread-1"),
29332948 turnId: asTurnId("turn-after-failure"),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6
......@@ -1211,6 +1211,9 @@ const make = Effect.gen(function* () {
12111211 threadId: thread.id,
12121212 status,
12131213 providerName: event.provider,
1214 ...(event.providerInstanceId !== undefined
1215 ? { providerInstanceId: event.providerInstanceId }
1216 : {}),
12141217 runtimeMode: thread.session?.runtimeMode ?? "full-access",
12151218 activeTurnId: nextActiveTurnId,
12161219 lastError,
......@@ -1456,6 +1459,9 @@ const make = Effect.gen(function* () {
14561459 threadId: thread.id,
14571460 status: "error",
14581461 providerName: event.provider,
1462 ...(event.providerInstanceId !== undefined
1463 ? { providerInstanceId: event.providerInstanceId }
1464 : {}),
14591465 runtimeMode: thread.session?.runtimeMode ?? "full-access",
14601466 activeTurnId: eventTurnId ?? null,
14611467 lastError: runtimeErrorMessage,
apps/server/src/orchestration/commandInvariants.test.ts+7-6
......@@ -7,6 +7,7 @@ import {
77 ThreadId,
88 type OrchestrationCommand,
99 type OrchestrationReadModel,
10 ProviderInstanceId,
1011} from "@t3tools/contracts";
1112import { Effect } from "effect";
1213
......@@ -29,7 +30,7 @@ const readModel: OrchestrationReadModel = {
2930 title: "Project A",
3031 workspaceRoot: "/tmp/project-a",
3132 defaultModelSelection: {
32 provider: "codex",
33 instanceId: ProviderInstanceId.make("codex"),
3334 model: "gpt-5-codex",
3435 },
3536 scripts: [],
......@@ -42,7 +43,7 @@ const readModel: OrchestrationReadModel = {
4243 title: "Project B",
4344 workspaceRoot: "/tmp/project-b",
4445 defaultModelSelection: {
45 provider: "codex",
46 instanceId: ProviderInstanceId.make("codex"),
4647 model: "gpt-5-codex",
4748 },
4849 scripts: [],
......@@ -57,7 +58,7 @@ const readModel: OrchestrationReadModel = {
5758 projectId: ProjectId.make("project-a"),
5859 title: "Thread A",
5960 modelSelection: {
60 provider: "codex",
61 instanceId: ProviderInstanceId.make("codex"),
6162 model: "gpt-5-codex",
6263 },
6364 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -80,7 +81,7 @@ const readModel: OrchestrationReadModel = {
8081 projectId: ProjectId.make("project-b"),
8182 title: "Thread B",
8283 modelSelection: {
83 provider: "codex",
84 instanceId: ProviderInstanceId.make("codex"),
8485 model: "gpt-5-codex",
8586 },
8687 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -157,7 +158,7 @@ describe("commandInvariants", () => {
157158 projectId: ProjectId.make("project-a"),
158159 title: "new",
159160 modelSelection: {
160 provider: "codex",
161 instanceId: ProviderInstanceId.make("codex"),
161162 model: "gpt-5-codex",
162163 },
163164 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -181,7 +182,7 @@ describe("commandInvariants", () => {
181182 projectId: ProjectId.make("project-a"),
182183 title: "dup",
183184 modelSelection: {
184 provider: "codex",
185 instanceId: ProviderInstanceId.make("codex"),
185186 model: "gpt-5-codex",
186187 },
187188 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
apps/server/src/orchestration/decider.delete.test.ts+3-2
......@@ -7,6 +7,7 @@ import {
77 type OrchestrationCommand,
88 type OrchestrationEvent,
99 type OrchestrationReadModel,
10 ProviderInstanceId,
1011} from "@t3tools/contracts";
1112import { Effect } from "effect";
1213import { describe, expect, it } from "vitest";
......@@ -63,7 +64,7 @@ async function seedReadModel(): Promise<OrchestrationReadModel> {
6364 projectId: asProjectId("project-delete"),
6465 title: "Thread Delete 1",
6566 modelSelection: {
66 provider: "codex",
67 instanceId: ProviderInstanceId.make("codex"),
6768 model: "gpt-5-codex",
6869 },
6970 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -93,7 +94,7 @@ async function seedReadModel(): Promise<OrchestrationReadModel> {
9394 projectId: asProjectId("project-delete"),
9495 title: "Thread Delete 2",
9596 modelSelection: {
96 provider: "codex",
97 instanceId: ProviderInstanceId.make("codex"),
9798 model: "gpt-5-codex",
9899 },
99100 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
apps/server/src/orchestration/decider.projectScripts.test.ts+6-5
......@@ -5,6 +5,7 @@ import {
55 MessageId,
66 ProjectId,
77 ThreadId,
8 ProviderInstanceId,
89} from "@t3tools/contracts";
910import { createModelSelection } from "@t3tools/shared/model";
1011import { describe, expect, it } from "vitest";
......@@ -138,7 +139,7 @@ describe("decider project scripts", () => {
138139 projectId: asProjectId("project-1"),
139140 title: "Thread",
140141 modelSelection: {
141 provider: "codex",
142 instanceId: ProviderInstanceId.make("codex"),
142143 model: "gpt-5-codex",
143144 },
144145 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -163,7 +164,7 @@ describe("decider project scripts", () => {
163164 text: "hello",
164165 attachments: [],
165166 },
166 modelSelection: createModelSelection("codex", "gpt-5.3-codex", [
167 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
167168 { id: "reasoningEffort", value: "high" },
168169 { id: "fastMode", value: true },
169170 ]),
......@@ -188,7 +189,7 @@ describe("decider project scripts", () => {
188189 expect(turnStartEvent.payload).toMatchObject({
189190 threadId: ThreadId.make("thread-1"),
190191 messageId: asMessageId("message-user-1"),
191 modelSelection: createModelSelection("codex", "gpt-5.3-codex", [
192 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
192193 { id: "reasoningEffort", value: "high" },
193194 { id: "fastMode", value: true },
194195 ]),
......@@ -239,7 +240,7 @@ describe("decider project scripts", () => {
239240 projectId: asProjectId("project-1"),
240241 title: "Thread",
241242 modelSelection: {
242 provider: "codex",
243 instanceId: ProviderInstanceId.make("codex"),
243244 model: "gpt-5-codex",
244245 },
245246 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -321,7 +322,7 @@ describe("decider project scripts", () => {
321322 projectId: asProjectId("project-1"),
322323 title: "Thread",
323324 modelSelection: {
324 provider: "codex",
325 instanceId: ProviderInstanceId.make("codex"),
325326 model: "gpt-5-codex",
326327 },
327328 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
apps/server/src/orchestration/projector.test.ts+11-10
......@@ -2,6 +2,7 @@ import {
22 CommandId,
33 EventId,
44 ProjectId,
5 ProviderDriverKind,
56 ThreadId,
67 type OrchestrationEvent,
78} from "@t3tools/contracts";
......@@ -57,7 +58,7 @@ describe("orchestration projector", () => {
5758 projectId: "project-1",
5859 title: "demo",
5960 modelSelection: {
60 provider: "codex",
61 provider: ProviderDriverKind.make("codex"),
6162 model: "gpt-5-codex",
6263 },
6364 runtimeMode: "full-access",
......@@ -77,7 +78,7 @@ describe("orchestration projector", () => {
7778 projectId: "project-1",
7879 title: "demo",
7980 modelSelection: {
80 provider: "codex",
81 instanceId: "codex",
8182 model: "gpt-5-codex",
8283 },
8384 runtimeMode: "full-access",
......@@ -118,7 +119,7 @@ describe("orchestration projector", () => {
118119 projectId: "project-1",
119120 title: "demo",
120121 modelSelection: {
121 provider: "codex",
122 provider: ProviderDriverKind.make("codex"),
122123 model: "gpt-5-codex",
123124 },
124125 branch: null,
......@@ -150,7 +151,7 @@ describe("orchestration projector", () => {
150151 projectId: "project-1",
151152 title: "demo",
152153 modelSelection: {
153 provider: "codex",
154 provider: ProviderDriverKind.make("codex"),
154155 model: "gpt-5-codex",
155156 },
156157 runtimeMode: "full-access",
......@@ -253,7 +254,7 @@ describe("orchestration projector", () => {
253254 projectId: "project-1",
254255 title: "demo",
255256 modelSelection: {
256 provider: "codex",
257 provider: ProviderDriverKind.make("codex"),
257258 model: "gpt-5.3-codex",
258259 },
259260 runtimeMode: "full-access",
......@@ -319,7 +320,7 @@ describe("orchestration projector", () => {
319320 projectId: "project-1",
320321 title: "demo",
321322 modelSelection: {
322 provider: "codex",
323 provider: ProviderDriverKind.make("codex"),
323324 model: "gpt-5.3-codex",
324325 },
325326 runtimeMode: "full-access",
......@@ -376,7 +377,7 @@ describe("orchestration projector", () => {
376377 projectId: "project-1",
377378 title: "demo",
378379 modelSelection: {
379 provider: "codex",
380 provider: ProviderDriverKind.make("codex"),
380381 model: "gpt-5.3-codex",
381382 },
382383 runtimeMode: "full-access",
......@@ -463,7 +464,7 @@ describe("orchestration projector", () => {
463464 projectId: "project-1",
464465 title: "demo",
465466 modelSelection: {
466 provider: "codex",
467 provider: ProviderDriverKind.make("codex"),
467468 model: "gpt-5.3-codex",
468469 },
469470 runtimeMode: "full-access",
......@@ -678,7 +679,7 @@ describe("orchestration projector", () => {
678679 projectId: "project-1",
679680 title: "demo",
680681 modelSelection: {
681 provider: "codex",
682 provider: ProviderDriverKind.make("codex"),
682683 model: "gpt-5.3-codex",
683684 },
684685 runtimeMode: "full-access",
......@@ -831,7 +832,7 @@ describe("orchestration projector", () => {
831832 projectId: "project-1",
832833 title: "capped",
833834 modelSelection: {
834 provider: "codex",
835 provider: ProviderDriverKind.make("codex"),
835836 model: "gpt-5-codex",
836837 },
837838 runtimeMode: "full-access",
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+7-7
......@@ -1,4 +1,4 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
1import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts";
22import { assert, it } from "@effect/vitest";
33import { Effect, Layer, Option } from "effect";
44import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -28,7 +28,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
2828 title: "Null options project",
2929 workspaceRoot: "/tmp/project-null-options",
3030 defaultModelSelection: {
31 provider: "codex",
31 instanceId: ProviderInstanceId.make("codex"),
3232 model: "gpt-5.4",
3333 },
3434 scripts: [],
......@@ -52,7 +52,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
5252 assert.strictEqual(
5353 row.defaultModelSelection,
5454 JSON.stringify({
55 provider: "codex",
55 instanceId: ProviderInstanceId.make("codex"),
5656 model: "gpt-5.4",
5757 }),
5858 );
......@@ -61,7 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
6161 projectId: ProjectId.make("project-null-options"),
6262 });
6363 assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, {
64 provider: "codex",
64 instanceId: ProviderInstanceId.make("codex"),
6565 model: "gpt-5.4",
6666 });
6767 }),
......@@ -77,7 +77,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
7777 projectId: ProjectId.make("project-null-options"),
7878 title: "Null options thread",
7979 modelSelection: {
80 provider: "claudeAgent",
80 instanceId: ProviderInstanceId.make("claudeAgent"),
8181 model: "claude-opus-4-6",
8282 },
8383 runtimeMode: "full-access",
......@@ -110,7 +110,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
110110 assert.strictEqual(
111111 row.modelSelection,
112112 JSON.stringify({
113 provider: "claudeAgent",
113 instanceId: ProviderInstanceId.make("claudeAgent"),
114114 model: "claude-opus-4-6",
115115 }),
116116 );
......@@ -119,7 +119,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
119119 threadId: ThreadId.make("thread-null-options"),
120120 });
121121 assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, {
122 provider: "claudeAgent",
122 instanceId: ProviderInstanceId.make("claudeAgent"),
123123 model: "claude-opus-4-6",
124124 });
125125 }),
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts+4
......@@ -23,6 +23,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
2323 thread_id,
2424 status,
2525 provider_name,
26 provider_instance_id,
2627 runtime_mode,
2728 active_turn_id,
2829 last_error,
......@@ -32,6 +33,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
3233 ${row.threadId},
3334 ${row.status},
3435 ${row.providerName},
36 ${row.providerInstanceId},
3537 ${row.runtimeMode},
3638 ${row.activeTurnId},
3739 ${row.lastError},
......@@ -41,6 +43,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
4143 DO UPDATE SET
4244 status = excluded.status,
4345 provider_name = excluded.provider_name,
46 provider_instance_id = excluded.provider_instance_id,
4447 runtime_mode = excluded.runtime_mode,
4548 active_turn_id = excluded.active_turn_id,
4649 last_error = excluded.last_error,
......@@ -57,6 +60,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
5760 thread_id AS "threadId",
5861 status,
5962 provider_name AS "providerName",
63 provider_instance_id AS "providerInstanceId",
6064 runtime_mode AS "runtimeMode",
6165 active_turn_id AS "activeTurnId",
6266 last_error AS "lastError",
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+5
......@@ -46,6 +46,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
4646 INSERT INTO provider_session_runtime (
4747 thread_id,
4848 provider_name,
49 provider_instance_id,
4950 adapter_key,
5051 runtime_mode,
5152 status,
......@@ -56,6 +57,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
5657 VALUES (
5758 ${runtime.threadId},
5859 ${runtime.providerName},
60 ${runtime.providerInstanceId},
5961 ${runtime.adapterKey},
6062 ${runtime.runtimeMode},
6163 ${runtime.status},
......@@ -66,6 +68,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
6668 ON CONFLICT (thread_id)
6769 DO UPDATE SET
6870 provider_name = excluded.provider_name,
71 provider_instance_id = excluded.provider_instance_id,
6972 adapter_key = excluded.adapter_key,
7073 runtime_mode = excluded.runtime_mode,
7174 status = excluded.status,
......@@ -83,6 +86,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
8386 SELECT
8487 thread_id AS "threadId",
8588 provider_name AS "providerName",
89 provider_instance_id AS "providerInstanceId",
8690 adapter_key AS "adapterKey",
8791 runtime_mode AS "runtimeMode",
8892 status,
......@@ -102,6 +106,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
102106 SELECT
103107 thread_id AS "threadId",
104108 provider_name AS "providerName",
109 provider_instance_id AS "providerInstanceId",
105110 adapter_key AS "adapterKey",
106111 runtime_mode AS "runtimeMode",
107112 status,
apps/server/src/persistence/Migrations.ts+4
......@@ -39,6 +39,8 @@ import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
3939import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
4040import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts";
4141import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";
42import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";
43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
4244
4345/**
4446 * Migration loader with all migrations defined inline.
......@@ -77,6 +79,8 @@ export const migrationEntries = [
7779 [24, "BackfillProjectionThreadShellSummary", Migration0024],
7880 [25, "CleanupInvalidProjectionPendingApprovals", Migration0025],
7981 [26, "CanonicalizeModelSelectionOptions", Migration0026],
82 [27, "ProviderSessionRuntimeInstanceId", Migration0027],
83 [28, "ProjectionThreadSessionInstanceId", Migration0028],
8084] as const;
8185
8286export 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 {
1010 RuntimeMode,
1111 IsoDateTime,
1212 OrchestrationSessionStatus,
13 ProviderInstanceId,
1314 ThreadId,
1415 TurnId,
1516} from "@t3tools/contracts";
......@@ -22,6 +23,7 @@ export const ProjectionThreadSession = Schema.Struct({
2223 threadId: ThreadId,
2324 status: OrchestrationSessionStatus,
2425 providerName: Schema.NullOr(Schema.String),
26 providerInstanceId: Schema.NullOr(ProviderInstanceId),
2527 runtimeMode: RuntimeMode,
2628 activeTurnId: Schema.NullOr(TurnId),
2729 lastError: Schema.NullOr(Schema.String),
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+9
......@@ -7,6 +7,7 @@
77 */
88import {
99 IsoDateTime,
10 ProviderInstanceId,
1011 ProviderSessionRuntimeStatus,
1112 RuntimeMode,
1213 ThreadId,
......@@ -19,6 +20,14 @@ import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts";
1920export const ProviderSessionRuntime = Schema.Struct({
2021 threadId: ThreadId,
2122 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),
2231 adapterKey: Schema.String,
2332 runtimeMode: RuntimeMode,
2433 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
116116 }
117117}
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
119159/**
120160 * ProviderSessionNotFoundError - Provider-facing session not found.
121161 */
......@@ -157,6 +197,7 @@ export type ProviderAdapterError =
157197export type ProviderServiceError =
158198 | ProviderValidationError
159199 | ProviderUnsupportedError
200 | ProviderInstanceNotFoundError
160201 | ProviderSessionNotFoundError
161202 | ProviderSessionDirectoryPersistenceError
162203 | ProviderAdapterError
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+222-117
......@@ -12,21 +12,29 @@ import type {
1212} from "@anthropic-ai/claude-agent-sdk";
1313import {
1414 ApprovalRequestId,
15 ClaudeSettings,
16 ProviderDriverKind,
1517 ProviderItemId,
1618 ProviderRuntimeEvent,
1719 type RuntimeMode,
1820 ThreadId,
21 ProviderInstanceId,
1922} from "@t3tools/contracts";
2023import { createModelSelection } from "@t3tools/shared/model";
2124import { 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
2427import { attachmentRelativePath } from "../../attachmentStore.ts";
2528import { ServerConfig } from "../../config.ts";
2629import { ServerSettingsService } from "../../serverSettings.ts";
2730import { ProviderAdapterValidationError } from "../Errors.ts";
28import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
29import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
31import type { ClaudeAdapterShape } from "../Services/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
3139class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
3240 private readonly queue: Array<SDKMessage> = [];
......@@ -137,6 +145,8 @@ function makeHarness(config?: {
137145 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
138146 readonly cwd?: string;
139147 readonly baseDir?: string;
148 readonly claudeConfig?: Partial<ClaudeSettings>;
149 readonly instanceId?: ProviderInstanceId;
140150}) {
141151 const query = new FakeClaudeQuery();
142152 let createInput:
......@@ -147,6 +157,7 @@ function makeHarness(config?: {
147157 | undefined;
148158
149159 const adapterOptions: ClaudeAdapterLiveOptions = {
160 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
150161 createQuery: (input) => {
151162 createInput = input;
152163 return query;
......@@ -164,7 +175,13 @@ function makeHarness(config?: {
164175 };
165176
166177 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(
168185 Layer.provideMerge(
169186 ServerConfig.layerTest(
170187 config?.cwd ?? "/tmp/claude-adapter-test",
......@@ -247,7 +264,11 @@ describe("ClaudeAdapterLive", () => {
247264 return Effect.gen(function* () {
248265 const adapter = yield* ClaudeAdapter;
249266 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 })
251272 .pipe(Effect.result);
252273
253274 assert.equal(result._tag, "Failure");
......@@ -257,7 +278,7 @@ describe("ClaudeAdapterLive", () => {
257278 assert.deepEqual(
258279 result.failure,
259280 new ProviderAdapterValidationError({
260 provider: "claudeAgent",
281 provider: ProviderDriverKind.make("claudeAgent"),
261282 operation: "startSession",
262283 issue: "Expected provider 'claudeAgent' but received 'codex'.",
263284 }),
......@@ -274,7 +295,7 @@ describe("ClaudeAdapterLive", () => {
274295 const adapter = yield* ClaudeAdapter;
275296 yield* adapter.startSession({
276297 threadId: THREAD_ID,
277 provider: "claudeAgent",
298 provider: ProviderDriverKind.make("claudeAgent"),
278299 runtimeMode: "full-access",
279300 });
280301
......@@ -294,7 +315,7 @@ describe("ClaudeAdapterLive", () => {
294315 const adapter = yield* ClaudeAdapter;
295316 yield* adapter.startSession({
296317 threadId: THREAD_ID,
297 provider: "claudeAgent",
318 provider: ProviderDriverKind.make("claudeAgent"),
298319 runtimeMode: "approval-required",
299320 });
300321
......@@ -314,7 +335,7 @@ describe("ClaudeAdapterLive", () => {
314335 const adapter = yield* ClaudeAdapter;
315336 yield* adapter.startSession({
316337 threadId: THREAD_ID,
317 provider: "claudeAgent",
338 provider: ProviderDriverKind.make("claudeAgent"),
318339 runtimeMode: "full-access",
319340 });
320341
......@@ -333,10 +354,12 @@ describe("ClaudeAdapterLive", () => {
333354 const adapter = yield* ClaudeAdapter;
334355 yield* adapter.startSession({
335356 threadId: THREAD_ID,
336 provider: "claudeAgent",
337 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
338 { id: "effort", value: "max" },
339 ]),
357 provider: ProviderDriverKind.make("claudeAgent"),
358 modelSelection: createModelSelection(
359 ProviderInstanceId.make("claudeAgent"),
360 "claude-opus-4-6",
361 [{ id: "effort", value: "max" }],
362 ),
340363 runtimeMode: "full-access",
341364 });
342365
......@@ -348,15 +371,37 @@ describe("ClaudeAdapterLive", () => {
348371 );
349372 });
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
351396 it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => {
352397 const harness = makeHarness();
353398 return Effect.gen(function* () {
354399 const adapter = yield* ClaudeAdapter;
355400 yield* adapter.startSession({
356401 threadId: THREAD_ID,
357 provider: "claudeAgent",
402 provider: ProviderDriverKind.make("claudeAgent"),
358403 modelSelection: {
359 provider: "claudeAgent",
404 instanceId: ProviderInstanceId.make("claudeAgent"),
360405 model: "claude-opus-4-7",
361406 },
362407 runtimeMode: "full-access",
......@@ -376,10 +421,12 @@ describe("ClaudeAdapterLive", () => {
376421 const adapter = yield* ClaudeAdapter;
377422 yield* adapter.startSession({
378423 threadId: THREAD_ID,
379 provider: "claudeAgent",
380 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-7", [
381 { id: "effort", value: "xhigh" },
382 ]),
424 provider: ProviderDriverKind.make("claudeAgent"),
425 modelSelection: createModelSelection(
426 ProviderInstanceId.make("claudeAgent"),
427 "claude-opus-4-7",
428 [{ id: "effort", value: "xhigh" }],
429 ),
383430 runtimeMode: "full-access",
384431 });
385432
......@@ -397,10 +444,12 @@ describe("ClaudeAdapterLive", () => {
397444 const adapter = yield* ClaudeAdapter;
398445 yield* adapter.startSession({
399446 threadId: THREAD_ID,
400 provider: "claudeAgent",
401 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
402 { id: "effort", value: "max" },
403 ]),
447 provider: ProviderDriverKind.make("claudeAgent"),
448 modelSelection: createModelSelection(
449 ProviderInstanceId.make("claudeAgent"),
450 "claude-sonnet-4-6",
451 [{ id: "effort", value: "max" }],
452 ),
404453 runtimeMode: "full-access",
405454 });
406455
......@@ -418,10 +467,12 @@ describe("ClaudeAdapterLive", () => {
418467 const adapter = yield* ClaudeAdapter;
419468 yield* adapter.startSession({
420469 threadId: THREAD_ID,
421 provider: "claudeAgent",
422 modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [
423 { id: "effort", value: "high" },
424 ]),
470 provider: ProviderDriverKind.make("claudeAgent"),
471 modelSelection: createModelSelection(
472 ProviderInstanceId.make("claudeAgent"),
473 "claude-haiku-4-5",
474 [{ id: "effort", value: "high" }],
475 ),
425476 runtimeMode: "full-access",
426477 });
427478
......@@ -439,10 +490,12 @@ describe("ClaudeAdapterLive", () => {
439490 const adapter = yield* ClaudeAdapter;
440491 yield* adapter.startSession({
441492 threadId: THREAD_ID,
442 provider: "claudeAgent",
443 modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [
444 { id: "thinking", value: false },
445 ]),
493 provider: ProviderDriverKind.make("claudeAgent"),
494 modelSelection: createModelSelection(
495 ProviderInstanceId.make("claudeAgent"),
496 "claude-haiku-4-5",
497 [{ id: "thinking", value: false }],
498 ),
446499 runtimeMode: "full-access",
447500 });
448501
......@@ -462,10 +515,12 @@ describe("ClaudeAdapterLive", () => {
462515 const adapter = yield* ClaudeAdapter;
463516 yield* adapter.startSession({
464517 threadId: THREAD_ID,
465 provider: "claudeAgent",
466 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
467 { id: "thinking", value: false },
468 ]),
518 provider: ProviderDriverKind.make("claudeAgent"),
519 modelSelection: createModelSelection(
520 ProviderInstanceId.make("claudeAgent"),
521 "claude-sonnet-4-6",
522 [{ id: "thinking", value: false }],
523 ),
469524 runtimeMode: "full-access",
470525 });
471526
......@@ -483,10 +538,12 @@ describe("ClaudeAdapterLive", () => {
483538 const adapter = yield* ClaudeAdapter;
484539 yield* adapter.startSession({
485540 threadId: THREAD_ID,
486 provider: "claudeAgent",
487 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
488 { id: "fastMode", value: true },
489 ]),
541 provider: ProviderDriverKind.make("claudeAgent"),
542 modelSelection: createModelSelection(
543 ProviderInstanceId.make("claudeAgent"),
544 "claude-opus-4-6",
545 [{ id: "fastMode", value: true }],
546 ),
490547 runtimeMode: "full-access",
491548 });
492549
......@@ -506,10 +563,12 @@ describe("ClaudeAdapterLive", () => {
506563 const adapter = yield* ClaudeAdapter;
507564 yield* adapter.startSession({
508565 threadId: THREAD_ID,
509 provider: "claudeAgent",
510 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
511 { id: "fastMode", value: true },
512 ]),
566 provider: ProviderDriverKind.make("claudeAgent"),
567 modelSelection: createModelSelection(
568 ProviderInstanceId.make("claudeAgent"),
569 "claude-sonnet-4-6",
570 [{ id: "fastMode", value: true }],
571 ),
513572 runtimeMode: "full-access",
514573 });
515574
......@@ -527,10 +586,12 @@ describe("ClaudeAdapterLive", () => {
527586 const adapter = yield* ClaudeAdapter;
528587 const session = yield* adapter.startSession({
529588 threadId: THREAD_ID,
530 provider: "claudeAgent",
531 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
532 { id: "effort", value: "ultrathink" },
533 ]),
589 provider: ProviderDriverKind.make("claudeAgent"),
590 modelSelection: createModelSelection(
591 ProviderInstanceId.make("claudeAgent"),
592 "claude-sonnet-4-6",
593 [{ id: "effort", value: "ultrathink" }],
594 ),
534595 runtimeMode: "full-access",
535596 });
536597
......@@ -538,9 +599,11 @@ describe("ClaudeAdapterLive", () => {
538599 threadId: session.threadId,
539600 input: "Investigate the edge cases",
540601 attachments: [],
541 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
542 { id: "effort", value: "ultrathink" },
543 ]),
602 modelSelection: createModelSelection(
603 ProviderInstanceId.make("claudeAgent"),
604 "claude-sonnet-4-6",
605 [{ id: "effort", value: "ultrathink" }],
606 ),
544607 });
545608
546609 const createInput = harness.getLastCreateQueryInput();
......@@ -585,7 +648,7 @@ describe("ClaudeAdapterLive", () => {
585648
586649 const session = yield* adapter.startSession({
587650 threadId: THREAD_ID,
588 provider: "claudeAgent",
651 provider: ProviderDriverKind.make("claudeAgent"),
589652 runtimeMode: "full-access",
590653 });
591654
......@@ -630,9 +693,9 @@ describe("ClaudeAdapterLive", () => {
630693
631694 const session = yield* adapter.startSession({
632695 threadId: THREAD_ID,
633 provider: "claudeAgent",
696 provider: ProviderDriverKind.make("claudeAgent"),
634697 modelSelection: {
635 provider: "claudeAgent",
698 instanceId: ProviderInstanceId.make("claudeAgent"),
636699 model: "claude-sonnet-4-5",
637700 },
638701 runtimeMode: "full-access",
......@@ -807,7 +870,7 @@ describe("ClaudeAdapterLive", () => {
807870
808871 const session = yield* adapter.startSession({
809872 threadId: THREAD_ID,
810 provider: "claudeAgent",
873 provider: ProviderDriverKind.make("claudeAgent"),
811874 runtimeMode: "full-access",
812875 });
813876
......@@ -986,7 +1049,7 @@ describe("ClaudeAdapterLive", () => {
9861049
9871050 const session = yield* adapter.startSession({
9881051 threadId: THREAD_ID,
989 provider: "claudeAgent",
1052 provider: ProviderDriverKind.make("claudeAgent"),
9901053 runtimeMode: "full-access",
9911054 });
9921055
......@@ -1077,7 +1140,7 @@ describe("ClaudeAdapterLive", () => {
10771140
10781141 const session = yield* adapter.startSession({
10791142 threadId: THREAD_ID,
1080 provider: "claudeAgent",
1143 provider: ProviderDriverKind.make("claudeAgent"),
10811144 runtimeMode: "full-access",
10821145 });
10831146
......@@ -1153,7 +1216,7 @@ describe("ClaudeAdapterLive", () => {
11531216
11541217 const session = yield* adapter.startSession({
11551218 threadId: THREAD_ID,
1156 provider: "claudeAgent",
1219 provider: ProviderDriverKind.make("claudeAgent"),
11571220 runtimeMode: "full-access",
11581221 });
11591222
......@@ -1219,7 +1282,7 @@ describe("ClaudeAdapterLive", () => {
12191282
12201283 yield* adapter.startSession({
12211284 threadId: THREAD_ID,
1222 provider: "claudeAgent",
1285 provider: ProviderDriverKind.make("claudeAgent"),
12231286 runtimeMode: "full-access",
12241287 });
12251288
......@@ -1270,13 +1333,19 @@ describe("ClaudeAdapterLive", () => {
12701333
12711334 it.effect("closes the previous session before replacing an existing thread session", () => {
12721335 const queries: FakeClaudeQuery[] = [];
1273 const layer = makeClaudeAdapterLive({
1274 createQuery: () => {
1275 const query = new FakeClaudeQuery();
1276 queries.push(query);
1277 return query;
1278 },
1279 }).pipe(
1336 const layer = Layer.effect(
1337 ClaudeAdapter,
1338 Effect.gen(function* () {
1339 const claudeConfig = Schema.decodeSync(ClaudeSettings)({});
1340 return yield* makeClaudeAdapter(claudeConfig, {
1341 createQuery: () => {
1342 const query = new FakeClaudeQuery();
1343 queries.push(query);
1344 return query;
1345 },
1346 });
1347 }),
1348 ).pipe(
12801349 Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")),
12811350 Layer.provideMerge(ServerSettingsService.layerTest()),
12821351 Layer.provideMerge(NodeServices.layer),
......@@ -1292,13 +1361,13 @@ describe("ClaudeAdapterLive", () => {
12921361
12931362 const firstSession = yield* adapter.startSession({
12941363 threadId: THREAD_ID,
1295 provider: "claudeAgent",
1364 provider: ProviderDriverKind.make("claudeAgent"),
12961365 runtimeMode: "full-access",
12971366 });
12981367
12991368 const secondSession = yield* adapter.startSession({
13001369 threadId: THREAD_ID,
1301 provider: "claudeAgent",
1370 provider: ProviderDriverKind.make("claudeAgent"),
13021371 runtimeMode: "full-access",
13031372 resumeCursor: firstSession.resumeCursor,
13041373 });
......@@ -1347,21 +1416,27 @@ describe("ClaudeAdapterLive", () => {
13471416
13481417 let promptConsumerError: unknown = undefined;
13491418
1350 const layer = makeClaudeAdapterLive({
1351 createQuery: (input) => {
1352 // Simulate the SDK consuming the prompt iterable
1353 (async () => {
1354 try {
1355 for await (const _message of input.prompt) {
1356 /* SDK processes user messages */
1357 }
1358 } catch (error) {
1359 promptConsumerError = error;
1360 }
1361 })();
1362 return query;
1363 },
1364 }).pipe(
1419 const layer = Layer.effect(
1420 ClaudeAdapter,
1421 Effect.gen(function* () {
1422 const claudeConfig = Schema.decodeSync(ClaudeSettings)({});
1423 return yield* makeClaudeAdapter(claudeConfig, {
1424 createQuery: (input) => {
1425 // Simulate the SDK consuming the prompt iterable
1426 (async () => {
1427 try {
1428 for await (const _message of input.prompt) {
1429 /* SDK processes user messages */
1430 }
1431 } catch (error) {
1432 promptConsumerError = error;
1433 }
1434 })();
1435 return query;
1436 },
1437 });
1438 }),
1439 ).pipe(
13651440 Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")),
13661441 Layer.provideMerge(ServerSettingsService.layerTest()),
13671442 Layer.provideMerge(NodeServices.layer),
......@@ -1379,7 +1454,7 @@ describe("ClaudeAdapterLive", () => {
13791454
13801455 yield* adapter.startSession({
13811456 threadId: THREAD_ID,
1382 provider: "claudeAgent",
1457 provider: ProviderDriverKind.make("claudeAgent"),
13831458 runtimeMode: "full-access",
13841459 });
13851460
......@@ -1416,7 +1491,7 @@ describe("ClaudeAdapterLive", () => {
14161491
14171492 yield* adapter.startSession({
14181493 threadId: THREAD_ID,
1419 provider: "claudeAgent",
1494 provider: ProviderDriverKind.make("claudeAgent"),
14201495 runtimeMode: "full-access",
14211496 });
14221497
......@@ -1463,7 +1538,7 @@ describe("ClaudeAdapterLive", () => {
14631538
14641539 yield* adapter.startSession({
14651540 threadId: THREAD_ID,
1466 provider: "claudeAgent",
1541 provider: ProviderDriverKind.make("claudeAgent"),
14671542 runtimeMode: "full-access",
14681543 });
14691544
......@@ -1517,7 +1592,7 @@ describe("ClaudeAdapterLive", () => {
15171592
15181593 yield* adapter.startSession({
15191594 threadId: THREAD_ID,
1520 provider: "claudeAgent",
1595 provider: ProviderDriverKind.make("claudeAgent"),
15211596 runtimeMode: "full-access",
15221597 });
15231598
......@@ -1584,7 +1659,7 @@ describe("ClaudeAdapterLive", () => {
15841659
15851660 yield* adapter.startSession({
15861661 threadId: THREAD_ID,
1587 provider: "claudeAgent",
1662 provider: ProviderDriverKind.make("claudeAgent"),
15881663 runtimeMode: "full-access",
15891664 });
15901665
......@@ -1649,7 +1724,7 @@ describe("ClaudeAdapterLive", () => {
16491724
16501725 yield* adapter.startSession({
16511726 threadId: THREAD_ID,
1652 provider: "claudeAgent",
1727 provider: ProviderDriverKind.make("claudeAgent"),
16531728 runtimeMode: "full-access",
16541729 });
16551730
......@@ -1730,7 +1805,7 @@ describe("ClaudeAdapterLive", () => {
17301805
17311806 const session = yield* adapter.startSession({
17321807 threadId: THREAD_ID,
1733 provider: "claudeAgent",
1808 provider: ProviderDriverKind.make("claudeAgent"),
17341809 runtimeMode: "full-access",
17351810 });
17361811
......@@ -1821,7 +1896,7 @@ describe("ClaudeAdapterLive", () => {
18211896
18221897 const session = yield* adapter.startSession({
18231898 threadId: THREAD_ID,
1824 provider: "claudeAgent",
1899 provider: ProviderDriverKind.make("claudeAgent"),
18251900 runtimeMode: "full-access",
18261901 });
18271902
......@@ -1987,7 +2062,7 @@ describe("ClaudeAdapterLive", () => {
19872062
19882063 const session = yield* adapter.startSession({
19892064 threadId: THREAD_ID,
1990 provider: "claudeAgent",
2065 provider: ProviderDriverKind.make("claudeAgent"),
19912066 runtimeMode: "full-access",
19922067 });
19932068
......@@ -2056,7 +2131,7 @@ describe("ClaudeAdapterLive", () => {
20562131
20572132 const session = yield* adapter.startSession({
20582133 threadId: THREAD_ID,
2059 provider: "claudeAgent",
2134 provider: ProviderDriverKind.make("claudeAgent"),
20602135 runtimeMode: "full-access",
20612136 });
20622137
......@@ -2278,7 +2353,7 @@ describe("ClaudeAdapterLive", () => {
22782353
22792354 const session = yield* adapter.startSession({
22802355 threadId: THREAD_ID,
2281 provider: "claudeAgent",
2356 provider: ProviderDriverKind.make("claudeAgent"),
22822357 runtimeMode: "full-access",
22832358 });
22842359 assert.equal(session.threadId, THREAD_ID);
......@@ -2351,7 +2426,7 @@ describe("ClaudeAdapterLive", () => {
23512426
23522427 const session = yield* adapter.startSession({
23532428 threadId: THREAD_ID,
2354 provider: "claudeAgent",
2429 provider: ProviderDriverKind.make("claudeAgent"),
23552430 runtimeMode: "approval-required",
23562431 });
23572432
......@@ -2460,7 +2535,7 @@ describe("ClaudeAdapterLive", () => {
24602535
24612536 const session = yield* adapter.startSession({
24622537 threadId: THREAD_ID,
2463 provider: "claudeAgent",
2538 provider: ProviderDriverKind.make("claudeAgent"),
24642539 runtimeMode: "approval-required",
24652540 });
24662541
......@@ -2533,7 +2608,7 @@ describe("ClaudeAdapterLive", () => {
25332608
25342609 const session = yield* adapter.startSession({
25352610 threadId: RESUME_THREAD_ID,
2536 provider: "claudeAgent",
2611 provider: ProviderDriverKind.make("claudeAgent"),
25372612 resumeCursor: {
25382613 threadId: "resume-thread-1",
25392614 resume: "550e8400-e29b-41d4-a716-446655440000",
......@@ -2575,7 +2650,7 @@ describe("ClaudeAdapterLive", () => {
25752650
25762651 yield* adapter.startSession({
25772652 threadId: RESUME_THREAD_ID,
2578 provider: "claudeAgent",
2653 provider: ProviderDriverKind.make("claudeAgent"),
25792654 resumeCursor: {
25802655 threadId: RESUME_THREAD_ID,
25812656 resume: durableSessionId,
......@@ -2658,7 +2733,7 @@ describe("ClaudeAdapterLive", () => {
26582733
26592734 const session = yield* adapter.startSession({
26602735 threadId: THREAD_ID,
2661 provider: "claudeAgent",
2736 provider: ProviderDriverKind.make("claudeAgent"),
26622737 runtimeMode: "full-access",
26632738 });
26642739
......@@ -2692,7 +2767,7 @@ describe("ClaudeAdapterLive", () => {
26922767
26932768 const session = yield* adapter.startSession({
26942769 threadId: THREAD_ID,
2695 provider: "claudeAgent",
2770 provider: ProviderDriverKind.make("claudeAgent"),
26962771 runtimeMode: "full-access",
26972772 });
26982773
......@@ -2772,14 +2847,14 @@ describe("ClaudeAdapterLive", () => {
27722847
27732848 const session = yield* adapter.startSession({
27742849 threadId: THREAD_ID,
2775 provider: "claudeAgent",
2850 provider: ProviderDriverKind.make("claudeAgent"),
27762851 runtimeMode: "full-access",
27772852 });
27782853 yield* adapter.sendTurn({
27792854 threadId: session.threadId,
27802855 input: "hello",
27812856 modelSelection: {
2782 provider: "claudeAgent",
2857 instanceId: ProviderInstanceId.make("claudeAgent"),
27832858 model: "claude-opus-4-6",
27842859 },
27852860 attachments: [],
......@@ -2792,6 +2867,34 @@ describe("ClaudeAdapterLive", () => {
27922867 );
27932868 });
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
27952898 it.effect(
27962899 "does not re-set the Claude model when the session already uses the same effective API model",
27972900 () => {
......@@ -2799,13 +2902,13 @@ describe("ClaudeAdapterLive", () => {
27992902 return Effect.gen(function* () {
28002903 const adapter = yield* ClaudeAdapter;
28012904 const modelSelection = {
2802 provider: "claudeAgent" as const,
2905 instanceId: ProviderInstanceId.make("claudeAgent"),
28032906 model: "claude-opus-4-6",
28042907 };
28052908
28062909 const session = yield* adapter.startSession({
28072910 threadId: THREAD_ID,
2808 provider: "claudeAgent",
2911 provider: ProviderDriverKind.make("claudeAgent"),
28092912 modelSelection,
28102913 runtimeMode: "full-access",
28112914 });
......@@ -2838,23 +2941,25 @@ describe("ClaudeAdapterLive", () => {
28382941
28392942 const session = yield* adapter.startSession({
28402943 threadId: THREAD_ID,
2841 provider: "claudeAgent",
2944 provider: ProviderDriverKind.make("claudeAgent"),
28422945 runtimeMode: "full-access",
28432946 });
28442947
28452948 yield* adapter.sendTurn({
28462949 threadId: session.threadId,
28472950 input: "hello",
2848 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
2849 { id: "contextWindow", value: "1m" },
2850 ]),
2951 modelSelection: createModelSelection(
2952 ProviderInstanceId.make("claudeAgent"),
2953 "claude-opus-4-6",
2954 [{ id: "contextWindow", value: "1m" }],
2955 ),
28512956 attachments: [],
28522957 });
28532958 yield* adapter.sendTurn({
28542959 threadId: session.threadId,
28552960 input: "hello again",
28562961 modelSelection: {
2857 provider: "claudeAgent",
2962 instanceId: ProviderInstanceId.make("claudeAgent"),
28582963 model: "claude-opus-4-6",
28592964 },
28602965 attachments: [],
......@@ -2874,7 +2979,7 @@ describe("ClaudeAdapterLive", () => {
28742979
28752980 const session = yield* adapter.startSession({
28762981 threadId: THREAD_ID,
2877 provider: "claudeAgent",
2982 provider: ProviderDriverKind.make("claudeAgent"),
28782983 runtimeMode: "full-access",
28792984 });
28802985 yield* adapter.sendTurn({
......@@ -2904,7 +3009,7 @@ describe("ClaudeAdapterLive", () => {
29043009
29053010 const session = yield* adapter.startSession({
29063011 threadId: THREAD_ID,
2907 provider: "claudeAgent",
3012 provider: ProviderDriverKind.make("claudeAgent"),
29083013 runtimeMode,
29093014 });
29103015
......@@ -2956,7 +3061,7 @@ describe("ClaudeAdapterLive", () => {
29563061
29573062 const session = yield* adapter.startSession({
29583063 threadId: THREAD_ID,
2959 provider: "claudeAgent",
3064 provider: ProviderDriverKind.make("claudeAgent"),
29603065 runtimeMode: "full-access",
29613066 });
29623067 yield* adapter.sendTurn({
......@@ -2979,7 +3084,7 @@ describe("ClaudeAdapterLive", () => {
29793084
29803085 const session = yield* adapter.startSession({
29813086 threadId: THREAD_ID,
2982 provider: "claudeAgent",
3087 provider: ProviderDriverKind.make("claudeAgent"),
29833088 runtimeMode: "full-access",
29843089 });
29853090
......@@ -3045,7 +3150,7 @@ describe("ClaudeAdapterLive", () => {
30453150
30463151 const session = yield* adapter.startSession({
30473152 threadId: THREAD_ID,
3048 provider: "claudeAgent",
3153 provider: ProviderDriverKind.make("claudeAgent"),
30493154 runtimeMode: "full-access",
30503155 });
30513156
......@@ -3117,7 +3222,7 @@ describe("ClaudeAdapterLive", () => {
31173222 // Start session in approval-required mode so canUseTool fires.
31183223 const session = yield* adapter.startSession({
31193224 threadId: THREAD_ID,
3120 provider: "claudeAgent",
3225 provider: ProviderDriverKind.make("claudeAgent"),
31213226 runtimeMode: "approval-required",
31223227 });
31233228
......@@ -3240,7 +3345,7 @@ describe("ClaudeAdapterLive", () => {
32403345 // AskUserQuestion should still go through the user-input flow.
32413346 const session = yield* adapter.startSession({
32423347 threadId: THREAD_ID,
3243 provider: "claudeAgent",
3348 provider: ProviderDriverKind.make("claudeAgent"),
32443349 runtimeMode: "full-access",
32453350 });
32463351
......@@ -3306,7 +3411,7 @@ describe("ClaudeAdapterLive", () => {
33063411
33073412 const session = yield* adapter.startSession({
33083413 threadId: THREAD_ID,
3309 provider: "claudeAgent",
3414 provider: ProviderDriverKind.make("claudeAgent"),
33103415 runtimeMode: "approval-required",
33113416 });
33123417
......@@ -3393,7 +3498,7 @@ describe("ClaudeAdapterLive", () => {
33933498
33943499 const session = yield* adapter.startSession({
33953500 threadId: THREAD_ID,
3396 provider: "claudeAgent",
3501 provider: ProviderDriverKind.make("claudeAgent"),
33973502 runtimeMode: "full-access",
33983503 });
33993504 const turn = yield* adapter.sendTurn({
apps/server/src/provider/Layers/ClaudeAdapter.ts+31-32
......@@ -24,8 +24,11 @@ import {
2424 ApprovalRequestId,
2525 type CanonicalItemType,
2626 type CanonicalRequestType,
27 type ClaudeSettings,
2728 EventId,
2829 type ProviderApprovalDecision,
30 ProviderDriverKind,
31 ProviderInstanceId,
2932 ProviderItemId,
3033 type ProviderRuntimeEvent,
3134 type ProviderRuntimeTurnStatus,
......@@ -56,7 +59,7 @@ import {
5659 Exit,
5760 FileSystem,
5861 Fiber,
59 Layer,
62 Path,
6063 Queue,
6164 Random,
6265 Ref,
......@@ -65,7 +68,7 @@ import {
6568
6669import { resolveAttachmentPath } from "../../attachmentStore.ts";
6770import { ServerConfig } from "../../config.ts";
68import { ServerSettingsService } from "../../serverSettings.ts";
71import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
6972import {
7073 getClaudeModelCapabilities,
7174 normalizeClaudeCliEffort,
......@@ -80,10 +83,10 @@ import {
8083 ProviderAdapterValidationError,
8184 type ProviderAdapterError,
8285} from "../Errors.ts";
83import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
86import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
8487import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
8588
86const PROVIDER = "claudeAgent" as const;
89const PROVIDER = ProviderDriverKind.make("claudeAgent");
8790type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
8891type ClaudeToolResultStreamKind = Extract<
8992 RuntimeContentStreamKind,
......@@ -182,6 +185,8 @@ interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
182185}
183186
184187export interface ClaudeAdapterLiveOptions {
188 readonly instanceId?: ProviderInstanceId;
189 readonly environment?: NodeJS.ProcessEnv;
185190 readonly createQuery?: (input: {
186191 readonly prompt: AsyncIterable<SDKUserMessage>;
187192 readonly options: ClaudeQueryOptions;
......@@ -565,13 +570,16 @@ const CLAUDE_SETTING_SOURCES = [
565570 "local",
566571] as const satisfies ReadonlyArray<SettingSource>;
567572
568function buildPromptText(input: ProviderSendTurnInput): string {
573function buildPromptText(
574 input: ProviderSendTurnInput,
575 boundInstanceId: ProviderInstanceId,
576): string {
569577 const rawEffort =
570 input.modelSelection?.provider === "claudeAgent"
578 input.modelSelection?.instanceId === boundInstanceId
571579 ? getModelSelectionStringOptionValue(input.modelSelection, "effort")
572580 : null;
573581 const claudeModel =
574 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
582 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined;
575583 const caps = getClaudeModelCapabilities(claudeModel);
576584
577585 const promptEffort = resolvePromptInjectedEffort(caps, rawEffort);
......@@ -611,9 +619,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
611619 dependencies: {
612620 readonly fileSystem: FileSystem.FileSystem;
613621 readonly attachmentsDir: string;
622 readonly boundInstanceId: ProviderInstanceId;
614623 },
615624) {
616 const text = buildPromptText(input);
625 const text = buildPromptText(input, dependencies.boundInstanceId);
617626 const sdkContent: Array<Record<string, unknown>> = [];
618627
619628 if (text.length > 0) {
......@@ -963,11 +972,17 @@ function sdkNativeItemId(message: SDKMessage): string | undefined {
963972 return undefined;
964973}
965974
966const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
975export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
976 claudeSettings: ClaudeSettings,
967977 options?: ClaudeAdapterLiveOptions,
968978) {
979 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent");
969980 const fileSystem = yield* FileSystem.FileSystem;
981 const path = yield* Path.Path;
970982 const serverConfig = yield* ServerConfig;
983 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe(
984 Effect.provideService(Path.Path, path),
985 );
971986 const nativeEventLogger =
972987 options?.nativeEventLogger ??
973988 (options?.nativeEventLogPath !== undefined
......@@ -989,7 +1004,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
9891004
9901005 const sessions = new Map<ThreadId, ClaudeSessionContext>();
9911006 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
992 const serverSettingsService = yield* ServerSettingsService;
9931007
9941008 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
9951009 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id));
......@@ -2820,22 +2834,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28202834 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
28212835 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 );
28352837 const claudeBinaryPath = claudeSettings.binaryPath;
28362838 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
28372839 const modelSelection =
2838 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2840 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
28392841 const caps = getClaudeModelCapabilities(modelSelection?.model);
28402842 const descriptors = getProviderOptionDescriptors({ caps });
28412843 const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
......@@ -2863,7 +2865,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28632865 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
28642866 ...(fastMode ? { fastMode: true } : {}),
28652867 };
2866
28672868 const queryOptions: ClaudeQueryOptions = {
28682869 ...(input.cwd ? { cwd: input.cwd } : {}),
28692870 ...(apiModelId ? { model: apiModelId } : {}),
......@@ -2885,7 +2886,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28852886 ...(newSessionId ? { sessionId: newSessionId } : {}),
28862887 includePartialMessages: true,
28872888 canUseTool,
2888 env: process.env,
2889 env: claudeEnvironment,
28892890 ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
28902891 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
28912892 };
......@@ -2933,6 +2934,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29332934 const session: ProviderSession = {
29342935 threadId,
29352936 provider: PROVIDER,
2937 providerInstanceId: boundInstanceId,
29362938 status: "ready",
29372939 runtimeMode: input.runtimeMode,
29382940 ...(input.cwd ? { cwd: input.cwd } : {}),
......@@ -3044,7 +3046,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
30443046 const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
30453047 const context = yield* requireSession(input.threadId);
30463048 const modelSelection =
3047 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
3049 input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId
3050 ? input.modelSelection
3051 : undefined;
30483052
30493053 if (context.turnState) {
30503054 // Auto-close a stale synthetic turn (from background agent responses
......@@ -3118,6 +3122,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
31183122 const message = yield* buildUserMessageEffect(input, {
31193123 fileSystem,
31203124 attachmentsDir: serverConfig.attachmentsDir,
3125 boundInstanceId,
31213126 });
31223127
31233128 yield* Queue.offer(context.promptQueue, {
......@@ -3255,9 +3260,3 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
32553260 },
32563261 } satisfies ClaudeAdapterShape;
32573262});
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 @@
1import type {
2 ClaudeSettings,
3 ClaudeModelSelection,
4 ModelCapabilities,
5 ServerProvider,
6 ServerProviderModel,
7 ServerProviderAuth,
8 ServerProviderSlashCommand,
9 ServerProviderState,
1import {
2 type ClaudeSettings,
3 type ModelCapabilities,
4 type ModelSelection,
5 ProviderDriverKind,
6 type ServerProviderModel,
7 type ServerProviderSlashCommand,
108} from "@t3tools/contracts";
11import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
9import { Effect, Option, Path, Result } from "effect";
1210import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
1411import {
1512 createModelCapabilities,
1613 getModelSelectionStringOptionValue,
......@@ -27,27 +24,22 @@ import {
2724 buildBooleanOptionDescriptor,
2825 buildSelectOptionDescriptor,
2926 buildServerProvider,
30 AUTH_PROBE_TIMEOUT_MS,
3127 DEFAULT_TIMEOUT_MS,
3228 detailFromResult,
33 extractAuthBoolean,
3429 isCommandMissingCause,
3530 parseGenericCliVersion,
3631 providerModelsFromSettings,
3732 spawnAndCollect,
38 type CommandResult,
33 type ServerProviderDraft,
3934} from "../providerSnapshot.ts";
4035import { compareCliVersions } from "../cliVersion.ts";
41import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
42import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
43import { ServerSettingsService } from "../../serverSettings.ts";
44import { ServerSettingsError } from "@t3tools/contracts";
36import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
4537
4638const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
4739 optionDescriptors: [],
4840});
4941
50const PROVIDER = "claudeAgent" as const;
42const PROVIDER = ProviderDriverKind.make("claudeAgent");
5143const CLAUDE_PRESENTATION = {
5244 displayName: "Claude",
5345 showInteractionModeToggle: true,
......@@ -242,7 +234,7 @@ export function normalizeClaudeCliEffort(effort: string | null | undefined): str
242234 return effort;
243235}
244236
245export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string {
237export function resolveClaudeApiModelId(modelSelection: ModelSelection): string {
246238 switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) {
247239 case "1m":
248240 return `${modelSelection.model}[1m]`;
......@@ -250,181 +242,6 @@ export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): s
250242 return modelSelection.model;
251243 }
252244}
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
429246function toTitleCaseWords(value: string): string {
430247 return value
......@@ -439,11 +256,27 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string |
439256 if (!normalized) return undefined;
440257
441258 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";
442273 case "max":
443274 case "maxplan":
275 return "Max";
444276 case "max5":
277 return "Max 5x";
445278 case "max20":
446 return "Max";
279 return "Max 20x";
447280 case "enterprise":
448281 return "Enterprise";
449282 case "team":
......@@ -460,10 +293,33 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string |
460293function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined {
461294 const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, "");
462295 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 }
464303 return undefined;
465304}
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
467323function claudeAuthMetadata(input: {
468324 readonly subscriptionType: string | undefined;
469325 readonly authMethod: string | undefined;
......@@ -476,10 +332,9 @@ function claudeAuthMetadata(input: {
476332 }
477333
478334 if (input.subscriptionType) {
479 const subscriptionLabel = claudeSubscriptionLabel(input.subscriptionType);
480335 return {
481336 type: input.subscriptionType,
482 label: `Claude ${subscriptionLabel ?? toTitleCaseWords(input.subscriptionType)} Subscription`,
337 label: formatClaudeSubscriptionAuthLabel(input.subscriptionType),
483338 };
484339 }
485340
......@@ -495,6 +350,13 @@ function nonEmptyProbeString(value: string): string | undefined {
495350 return candidate ? candidate : undefined;
496351}
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
498360function parseClaudeInitializationCommands(
499361 commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
500362): ReadonlyArray<ServerProviderSlashCommand> {
......@@ -580,30 +442,46 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
580442 * This is used as a fallback when `claude auth status` does not include
581443 * subscription type information.
582444 */
583const probeClaudeCapabilities = (binaryPath: string) => {
445const probeClaudeCapabilities = (
446 claudeSettings: ClaudeSettings,
447 environment: NodeJS.ProcessEnv = process.env,
448) => {
584449 const abort = new AbortController();
585 return Effect.tryPromise(async () => {
586 const q = claudeQuery({
587 // Never yield — we only need initialization data, not a conversation.
588 // This prevents any prompt from reaching the Anthropic API.
589 // oxlint-disable-next-line require-yield
590 prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
591 await waitForAbortSignal(abort.signal);
592 })(),
593 options: {
594 persistSession: false,
595 pathToClaudeCodeExecutable: binaryPath,
596 abortController: abort,
597 settingSources: ["user", "project", "local"],
598 allowedTools: [],
599 stderr: () => {},
600 },
450 return Effect.gen(function* () {
451 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
452 return yield* Effect.tryPromise(async () => {
453 const q = claudeQuery({
454 // Never yield — we only need initialization data, not a conversation.
455 // This prevents any prompt from reaching the Anthropic API.
456 // oxlint-disable-next-line require-yield
457 prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
458 await waitForAbortSignal(abort.signal);
459 })(),
460 options: {
461 persistSession: false,
462 pathToClaudeCodeExecutable: claudeSettings.binaryPath,
463 abortController: abort,
464 settingSources: ["user", "project", "local"],
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;
601484 });
602 const init = await q.initializationResult();
603 return {
604 subscriptionType: init.account?.subscriptionType,
605 slashCommands: parseClaudeInitializationCommands(init.commands),
606 };
607485 }).pipe(
608486 Effect.ensuring(
609487 Effect.sync(() => {
......@@ -619,31 +497,30 @@ const probeClaudeCapabilities = (binaryPath: string) => {
619497 );
620498};
621499
622const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: ReadonlyArray<string>) {
623 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
624 Effect.flatMap((service) => service.getSettings),
625 Effect.map((settings) => settings.providers.claudeAgent),
626 );
500const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
501 claudeSettings: ClaudeSettings,
502 args: ReadonlyArray<string>,
503 environment: NodeJS.ProcessEnv = process.env,
504) {
505 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
627506 const command = ChildProcess.make(claudeSettings.binaryPath, [...args], {
507 env: claudeEnvironment,
628508 shell: process.platform === "win32",
629509 });
630510 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
631511});
632512
633513export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
634 resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>,
635 resolveSlashCommands?: (
636 binaryPath: string,
637 ) => Effect.Effect<ReadonlyArray<ServerProviderSlashCommand> | undefined>,
514 claudeSettings: ClaudeSettings,
515 resolveCapabilities?: (
516 claudeSettings: ClaudeSettings,
517 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
518 environment: NodeJS.ProcessEnv = process.env,
638519): Effect.fn.Return<
639 ServerProvider,
640 ServerSettingsError,
641 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
520 ServerProviderDraft,
521 never,
522 ChildProcessSpawner.ChildProcessSpawner | Path.Path
642523> {
643 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
644 Effect.flatMap((service) => service.getSettings),
645 Effect.map((settings) => settings.providers.claudeAgent),
646 );
647524 const checkedAt = new Date().toISOString();
648525 const allModels = providerModelsFromSettings(
649526 BUILT_IN_MODELS,
......@@ -654,7 +531,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
654531
655532 if (!claudeSettings.enabled) {
656533 return buildServerProvider({
657 provider: PROVIDER,
658534 presentation: CLAUDE_PRESENTATION,
659535 enabled: false,
660536 checkedAt,
......@@ -669,7 +545,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
669545 });
670546 }
671547
672 const versionProbe = yield* runClaudeCommand(["--version"]).pipe(
548 const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe(
673549 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
674550 Effect.result,
675551 );
......@@ -677,7 +553,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
677553 if (Result.isFailure(versionProbe)) {
678554 const error = versionProbe.failure;
679555 return buildServerProvider({
680 provider: PROVIDER,
681556 presentation: CLAUDE_PRESENTATION,
682557 enabled: claudeSettings.enabled,
683558 checkedAt,
......@@ -696,7 +571,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
696571
697572 if (Option.isNone(versionProbe.success)) {
698573 return buildServerProvider({
699 provider: PROVIDER,
700574 presentation: CLAUDE_PRESENTATION,
701575 enabled: claudeSettings.enabled,
702576 checkedAt,
......@@ -717,7 +591,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
717591 if (version.code !== 0) {
718592 const detail = detailFromResult(version);
719593 return buildServerProvider({
720 provider: PROVIDER,
721594 presentation: CLAUDE_PRESENTATION,
722595 enabled: claudeSettings.enabled,
723596 checkedAt,
......@@ -744,66 +617,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
744617 ? undefined
745618 : formatClaudeOpus47UpgradeMessage(parsedVersion);
746619
747 const slashCommands =
748 (resolveSlashCommands
749 ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe(
750 Effect.orElseSucceed(() => undefined),
751 )
752 : undefined) ?? [];
620 const capabilities = resolveCapabilities
621 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
622 : undefined;
623 const slashCommands = capabilities?.slashCommands ?? [];
753624 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
754625
755 // ── Auth check + subscription detection ────────────────────────────
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)) {
626 if (!capabilities) {
805627 return buildServerProvider({
806 provider: PROVIDER,
807628 presentation: CLAUDE_PRESENTATION,
808629 enabled: claudeSettings.enabled,
809630 checkedAt,
......@@ -814,15 +635,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
814635 version: parsedVersion,
815636 status: "warning",
816637 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.",
818639 },
819640 });
820641 }
821642
822 const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value);
823 const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod });
643 const authMetadata = claudeAuthMetadata({
644 subscriptionType: capabilities.subscriptionType,
645 authMethod: capabilities.tokenSource,
646 });
824647 return buildServerProvider({
825 provider: PROVIDER,
826648 presentation: CLAUDE_PRESENTATION,
827649 enabled: claudeSettings.enabled,
828650 checkedAt,
......@@ -831,21 +653,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
831653 probe: {
832654 installed: true,
833655 version: parsedVersion,
834 status: parsed.status,
656 status: "ready",
835657 auth: {
836 ...parsed.auth,
658 status: "authenticated",
659 ...(capabilities.email ? { email: capabilities.email } : {}),
837660 ...(authMetadata ? authMetadata : {}),
838661 },
839 ...(parsed.message
840 ? { message: parsed.message }
841 : opus47UpgradeMessage
842 ? { message: opus47UpgradeMessage }
843 : {}),
662 ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}),
844663 },
845664 });
846665});
847666
848const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => {
667export const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProviderDraft => {
849668 const checkedAt = new Date().toISOString();
850669 const models = providerModelsFromSettings(
851670 BUILT_IN_MODELS,
......@@ -856,7 +675,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid
856675
857676 if (!claudeSettings.enabled) {
858677 return buildServerProvider({
859 provider: PROVIDER,
860678 presentation: CLAUDE_PRESENTATION,
861679 enabled: false,
862680 checkedAt,
......@@ -872,7 +690,6 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid
872690 }
873691
874692 return buildServerProvider({
875 provider: PROVIDER,
876693 presentation: CLAUDE_PRESENTATION,
877694 enabled: true,
878695 checkedAt,
......@@ -887,43 +704,4 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid
887704 });
888705};
889706
890export const ClaudeProviderLive = Layer.effect(
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);
707export { probeClaudeCapabilities };
apps/server/src/provider/Layers/CodexAdapter.test.ts+148-38
......@@ -4,7 +4,10 @@ import os from "node:os";
44import path from "node:path";
55import {
66 ApprovalRequestId,
7 CodexSettings,
78 EventId,
9 ProviderDriverKind,
10 ProviderInstanceId,
811 ProviderItemId,
912 type ProviderApprovalDecision,
1013 type ProviderEvent,
......@@ -18,13 +21,13 @@ import { createModelSelection } from "@t3tools/shared/model";
1821import * as NodeServices from "@effect/platform-node/NodeServices";
1922import { 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";
2225import * as CodexErrors from "effect-codex-app-server/errors";
2326
2427import { ServerConfig } from "../../config.ts";
2528import { ServerSettingsService } from "../../serverSettings.ts";
2629import { ProviderAdapterValidationError } from "../Errors.ts";
27import { CodexAdapter } from "../Services/CodexAdapter.ts";
30import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
2831import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2932import {
3033 type CodexSessionRuntimeOptions,
......@@ -32,7 +35,12 @@ import {
3235 type CodexSessionRuntimeShape,
3336 type CodexThreadSnapshot,
3437} 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
3745const asThreadId = (value: string): ThreadId => ThreadId.make(value);
3846const asTurnId = (value: string): TurnId => TurnId.make(value);
......@@ -45,7 +53,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
4553
4654 public readonly startImpl = vi.fn(() =>
4755 Promise.resolve({
48 provider: "codex" as const,
56 provider: ProviderDriverKind.make("codex"),
4957 status: "ready" as const,
5058 runtimeMode: this.options.runtimeMode,
5159 threadId: this.options.threadId,
......@@ -203,7 +211,15 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory
203211
204212const validationRuntimeFactory = makeRuntimeFactory();
205213const 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(
207223 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
208224 Layer.provideMerge(ServerSettingsService.layerTest()),
209225 Layer.provideMerge(providerSessionDirectoryTestLayer),
......@@ -217,7 +233,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
217233 const adapter = yield* CodexAdapter;
218234 const result = yield* adapter
219235 .startSession({
220 provider: "claudeAgent",
236 provider: ProviderDriverKind.make("claudeAgent"),
221237 threadId: asThreadId("thread-1"),
222238 runtimeMode: "full-access",
223239 })
......@@ -227,7 +243,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
227243 assert.deepStrictEqual(
228244 result.failure,
229245 new ProviderAdapterValidationError({
230 provider: "codex",
246 provider: ProviderDriverKind.make("codex"),
231247 operation: "startSession",
232248 issue: "Expected provider 'codex' but received 'claudeAgent'.",
233249 }),
......@@ -241,9 +257,9 @@ validationLayer("CodexAdapterLive validation", (it) => {
241257 const adapter = yield* CodexAdapter;
242258
243259 yield* adapter.startSession({
244 provider: "codex",
260 provider: ProviderDriverKind.make("codex"),
245261 threadId: asThreadId("thread-1"),
246 modelSelection: createModelSelection("codex", "gpt-5.3-codex", [
262 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
247263 { id: "fastMode", value: true },
248264 ]),
249265 runtimeMode: "full-access",
......@@ -253,6 +269,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
253269 binaryPath: "codex",
254270 cwd: process.cwd(),
255271 model: "gpt-5.3-codex",
272 providerInstanceId: ProviderInstanceId.make("codex"),
256273 serviceTier: "fast",
257274 threadId: asThreadId("thread-1"),
258275 runtimeMode: "full-access",
......@@ -263,7 +280,15 @@ validationLayer("CodexAdapterLive validation", (it) => {
263280
264281const sessionRuntimeFactory = makeRuntimeFactory();
265282const 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(
267292 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
268293 Layer.provideMerge(ServerSettingsService.layerTest()),
269294 Layer.provideMerge(providerSessionDirectoryTestLayer),
......@@ -294,7 +319,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
294319 Effect.gen(function* () {
295320 const adapter = yield* CodexAdapter;
296321 yield* adapter.startSession({
297 provider: "codex",
322 provider: ProviderDriverKind.make("codex"),
298323 threadId: asThreadId("sess-missing"),
299324 runtimeMode: "full-access",
300325 });
......@@ -306,7 +331,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
306331 adapter.sendTurn({
307332 threadId: asThreadId("sess-missing"),
308333 input: "hello",
309 modelSelection: createModelSelection("codex", "gpt-5.3-codex", [
334 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
310335 { id: "reasoningEffort", value: "high" },
311336 { id: "fastMode", value: true },
312337 ]),
......@@ -322,11 +347,74 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
322347 });
323348 }),
324349 );
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 });
325405});
326406
327407const lifecycleRuntimeFactory = makeRuntimeFactory();
328408const 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(
330418 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
331419 Layer.provideMerge(ServerSettingsService.layerTest()),
332420 Layer.provideMerge(providerSessionDirectoryTestLayer),
......@@ -338,7 +426,7 @@ function startLifecycleRuntime() {
338426 return Effect.gen(function* () {
339427 const adapter = yield* CodexAdapter;
340428 yield* adapter.startSession({
341 provider: "codex",
429 provider: ProviderDriverKind.make("codex"),
342430 threadId: asThreadId("thread-1"),
343431 runtimeMode: "full-access",
344432 });
......@@ -357,7 +445,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
357445 const event: ProviderEvent = {
358446 id: asEventId("evt-msg-complete"),
359447 kind: "notification",
360 provider: "codex",
448 provider: ProviderDriverKind.make("codex"),
361449 createdAt: new Date().toISOString(),
362450 method: "item/completed",
363451 threadId: asThreadId("thread-1"),
......@@ -399,7 +487,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
399487 const event: ProviderEvent = {
400488 id: asEventId("evt-plan-complete"),
401489 kind: "notification",
402 provider: "codex",
490 provider: ProviderDriverKind.make("codex"),
403491 createdAt: new Date().toISOString(),
404492 method: "item/completed",
405493 threadId: asThreadId("thread-1"),
......@@ -440,7 +528,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
440528 yield* runtime.emit({
441529 id: asEventId("evt-plan-delta"),
442530 kind: "notification",
443 provider: "codex",
531 provider: ProviderDriverKind.make("codex"),
444532 createdAt: new Date().toISOString(),
445533 method: "item/plan/delta",
446534 threadId: asThreadId("thread-1"),
......@@ -477,7 +565,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
477565 const event: ProviderEvent = {
478566 id: asEventId("evt-session-closed"),
479567 kind: "session",
480 provider: "codex",
568 provider: ProviderDriverKind.make("codex"),
481569 threadId: asThreadId("thread-1"),
482570 createdAt: new Date().toISOString(),
483571 method: "session/closed",
......@@ -508,7 +596,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
508596 yield* runtime.emit({
509597 id: asEventId("evt-retryable-error"),
510598 kind: "notification",
511 provider: "codex",
599 provider: ProviderDriverKind.make("codex"),
512600 threadId: asThreadId("thread-1"),
513601 createdAt: new Date().toISOString(),
514602 method: "error",
......@@ -546,7 +634,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
546634 yield* runtime.emit({
547635 id: asEventId("evt-process-stderr"),
548636 kind: "notification",
549 provider: "codex",
637 provider: ProviderDriverKind.make("codex"),
550638 threadId: asThreadId("thread-1"),
551639 createdAt: new Date().toISOString(),
552640 method: "process/stderr",
......@@ -580,7 +668,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
580668 yield* runtime.emit({
581669 id: asEventId("evt-process-stderr-websocket"),
582670 kind: "notification",
583 provider: "codex",
671 provider: ProviderDriverKind.make("codex"),
584672 threadId: asThreadId("thread-1"),
585673 createdAt: new Date().toISOString(),
586674 method: "process/stderr",
......@@ -616,7 +704,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
616704 const event: ProviderEvent = {
617705 id: asEventId("evt-request-resolved"),
618706 kind: "notification",
619 provider: "codex",
707 provider: ProviderDriverKind.make("codex"),
620708 threadId: asThreadId("thread-1"),
621709 createdAt: new Date().toISOString(),
622710 method: "serverRequest/resolved",
......@@ -651,7 +739,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
651739 const event: ProviderEvent = {
652740 id: asEventId("evt-file-read-request-resolved"),
653741 kind: "notification",
654 provider: "codex",
742 provider: ProviderDriverKind.make("codex"),
655743 threadId: asThreadId("thread-1"),
656744 createdAt: new Date().toISOString(),
657745 method: "serverRequest/resolved",
......@@ -686,7 +774,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
686774 const event: ProviderEvent = {
687775 id: asEventId("evt-user-input-empty"),
688776 kind: "notification",
689 provider: "codex",
777 provider: ProviderDriverKind.make("codex"),
690778 threadId: asThreadId("thread-1"),
691779 createdAt: new Date().toISOString(),
692780 method: "item/tool/requestUserInput/answered",
......@@ -726,7 +814,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
726814 const event: ProviderEvent = {
727815 id: asEventId("evt-windows-sandbox-failed"),
728816 kind: "notification",
729 provider: "codex",
817 provider: ProviderDriverKind.make("codex"),
730818 threadId: asThreadId("thread-1"),
731819 createdAt: new Date().toISOString(),
732820 method: "windowsSandbox/setupCompleted",
......@@ -771,7 +859,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
771859 yield* runtime.emit({
772860 id: asEventId("evt-user-input-requested"),
773861 kind: "request",
774 provider: "codex",
862 provider: ProviderDriverKind.make("codex"),
775863 threadId: asThreadId("thread-1"),
776864 createdAt: new Date().toISOString(),
777865 method: "item/tool/requestUserInput",
......@@ -798,7 +886,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
798886 yield* runtime.emit({
799887 id: asEventId("evt-user-input-resolved"),
800888 kind: "notification",
801 provider: "codex",
889 provider: ProviderDriverKind.make("codex"),
802890 threadId: asThreadId("thread-1"),
803891 createdAt: new Date().toISOString(),
804892 method: "item/tool/requestUserInput/answered",
......@@ -838,7 +926,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
838926 yield* runtime.emit({
839927 id: asEventId("evt-codex-thread-token-usage-updated"),
840928 kind: "notification",
841 provider: "codex",
929 provider: ProviderDriverKind.make("codex"),
842930 threadId: asThreadId("thread-1"),
843931 turnId: asTurnId("turn-1"),
844932 createdAt: new Date().toISOString(),
......@@ -897,7 +985,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
897985
898986const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory();
899987const 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(
901997 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
902998 Layer.provideMerge(ServerSettingsService.layerTest()),
903999 Layer.provideMerge(providerSessionDirectoryTestLayer),
......@@ -912,7 +1008,7 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => {
9121008 const adapter = yield* CodexAdapter;
9131009
9141010 yield* adapter.startSession({
915 provider: "codex",
1011 provider: ProviderDriverKind.make("codex"),
9161012 threadId: asThreadId("thread-stop"),
9171013 runtimeMode: "full-access",
9181014 });
......@@ -933,7 +1029,15 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => {
9331029
9341030const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true });
9351031const 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(
9371041 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
9381042 Layer.provideMerge(ServerSettingsService.layerTest()),
9391043 Layer.provideMerge(providerSessionDirectoryTestLayer),
......@@ -949,7 +1053,7 @@ scopedFailureLayer("CodexAdapterLive scoped startup failure", (it) => {
9491053
9501054 const result = yield* adapter
9511055 .startSession({
952 provider: "codex",
1056 provider: ProviderDriverKind.make("codex"),
9531057 threadId: asThreadId("thread-fail"),
9541058 runtimeMode: "full-access",
9551059 })
......@@ -974,10 +1078,16 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
9741078 let scopeClosed = false;
9751079
9761080 try {
977 const layer = makeCodexAdapterLive({
978 makeRuntime: runtimeFactory.factory,
979 nativeEventLogPath: basePath,
980 }).pipe(
1081 const layer = Layer.effect(
1082 CodexAdapter,
1083 Effect.gen(function* () {
1084 const codexConfig = Schema.decodeSync(CodexSettings)({});
1085 return yield* makeCodexAdapter(codexConfig, {
1086 makeRuntime: runtimeFactory.factory,
1087 nativeEventLogPath: basePath,
1088 });
1089 }),
1090 ).pipe(
9811091 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
9821092 Layer.provideMerge(ServerSettingsService.layerTest()),
9831093 Layer.provideMerge(providerSessionDirectoryTestLayer),
......@@ -987,7 +1097,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
9871097 const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context));
9881098
9891099 yield* adapter.startSession({
990 provider: "codex",
1100 provider: ProviderDriverKind.make("codex"),
9911101 threadId: asThreadId("thread-logger"),
9921102 runtimeMode: "full-access",
9931103 });
......@@ -999,7 +1109,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
9991109 yield* runtime.emit({
10001110 id: asEventId("evt-native-log"),
10011111 kind: "notification",
1002 provider: "codex",
1112 provider: ProviderDriverKind.make("codex"),
10031113 threadId: asThreadId("thread-logger"),
10041114 createdAt: new Date().toISOString(),
10051115 method: "process/stderr",
apps/server/src/provider/Layers/CodexAdapter.ts+35-30
......@@ -10,7 +10,10 @@
1010import {
1111 type CanonicalItemType,
1212 type CanonicalRequestType,
13 type CodexSettings,
14 ProviderDriverKind,
1315 type ProviderEvent,
16 ProviderInstanceId,
1417 type ProviderRuntimeEvent,
1518 type ProviderRequestKind,
1619 type ThreadTokenUsageSnapshot,
......@@ -21,7 +24,7 @@ import {
2124 ThreadId,
2225 ProviderSendTurnInput,
2326} 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";
2528import { ChildProcessSpawner } from "effect/unstable/process";
2629import * as CodexErrors from "effect-codex-app-server/errors";
2730import * as EffectCodexSchema from "effect-codex-app-server/schema";
......@@ -39,10 +42,9 @@ import {
3942 ProviderAdapterValidationError,
4043 type ProviderAdapterError,
4144} from "../Errors.ts";
42import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
45import { type CodexAdapterShape } from "../Services/CodexAdapter.ts";
4346import { resolveAttachmentPath } from "../../attachmentStore.ts";
4447import { ServerConfig } from "../../config.ts";
45import { ServerSettingsService } from "../../serverSettings.ts";
4648import {
4749 CodexResumeCursorSchema,
4850 CodexSessionRuntimeThreadIdMissingError,
......@@ -53,9 +55,11 @@ import {
5355} from "./CodexSessionRuntime.ts";
5456import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
5557
56const PROVIDER = "codex" as const;
58const PROVIDER = ProviderDriverKind.make("codex");
5759
5860export interface CodexAdapterLiveOptions {
61 readonly instanceId?: ProviderInstanceId;
62 readonly environment?: NodeJS.ProcessEnv;
5963 readonly makeRuntime?: (
6064 options: CodexSessionRuntimeOptions,
6165 ) => Effect.Effect<
......@@ -1318,9 +1322,20 @@ function mapToRuntimeEvents(
13181322 return [];
13191323}
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,
13221336 options?: CodexAdapterLiveOptions,
13231337) {
1338 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex");
13241339 const fileSystem = yield* FileSystem.FileSystem;
13251340 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
13261341 const serverConfig = yield* Effect.service(ServerConfig);
......@@ -1333,7 +1348,6 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
13331348 : undefined);
13341349 const managedNativeEventLogger =
13351350 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
1336 const serverSettingsService = yield* ServerSettingsService;
13371351 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
13381352 const sessions = new Map<ThreadId, CodexAdapterSessionContext>();
13391353
......@@ -1353,31 +1367,21 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
13531367 yield* Effect.suspend(() => stopSessionInternal(existing));
13541368 }
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 );
13681370 const runtimeInput: CodexSessionRuntimeOptions = {
13691371 threadId: input.threadId,
1372 providerInstanceId: boundInstanceId,
13701373 cwd: input.cwd ?? process.cwd(),
1371 binaryPath: codexSettings.binaryPath,
1372 ...(codexSettings.homePath ? { homePath: codexSettings.homePath } : {}),
1374 binaryPath: codexConfig.binaryPath,
1375 ...(options?.environment ? { environment: options.environment } : {}),
1376 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
13731377 ...(Schema.is(CodexResumeCursorSchema)(input.resumeCursor)
13741378 ? { resumeCursor: input.resumeCursor }
13751379 : {}),
13761380 runtimeMode: input.runtimeMode,
1377 ...(input.modelSelection?.provider === "codex"
1381 ...(input.modelSelection?.instanceId === boundInstanceId
13781382 ? { model: input.modelSelection.model }
13791383 : {}),
1380 ...(input.modelSelection?.provider === "codex" &&
1384 ...(input.modelSelection?.instanceId === boundInstanceId &&
13811385 getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true
13821386 ? { serviceTier: "fast" }
13831387 : {}),
......@@ -1492,17 +1496,17 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
14921496
14931497 const session = yield* requireSession(input.threadId);
14941498 const reasoningEffort =
1495 input.modelSelection?.provider === "codex"
1499 input.modelSelection?.instanceId === boundInstanceId
14961500 ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort")
14971501 : undefined;
14981502 const fastMode =
1499 input.modelSelection?.provider === "codex"
1503 input.modelSelection?.instanceId === boundInstanceId
15001504 ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode")
15011505 : undefined;
15021506 return yield* session.runtime
15031507 .sendTurn({
15041508 ...(input.input !== undefined ? { input: input.input } : {}),
1505 ...(input.modelSelection?.provider === "codex"
1509 ...(input.modelSelection?.instanceId === boundInstanceId
15061510 ? { model: input.modelSelection.model }
15071511 : {}),
15081512 ...(reasoningEffort
......@@ -1676,8 +1680,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
16761680 } satisfies CodexAdapterShape;
16771681});
16781682
1679export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter());
1680
1681export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) {
1682 return Layer.effect(CodexAdapter, makeCodexAdapter(options));
1683}
1683// NOTE: the old `CodexAdapterLive` / `makeCodexAdapterLive` singleton Layer
1684// exports have been removed as part of the per-instance-driver refactor.
1685// `makeCodexAdapter(codexConfig, options?)` is now invoked directly by
1686// `CodexDriver.create()` for each configured instance; downstream consumers
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 @@
1import {
2 DateTime,
3 Duration,
4 Effect,
5 Equal,
6 Layer,
7 Option,
8 Result,
9 Schema,
10 Stream,
11 Types,
12} from "effect";
1import { DateTime, Duration, Effect, Layer, Option, Result, Schema, Types } from "effect";
132import { ChildProcessSpawner } from "effect/unstable/process";
143import * as CodexClient from "effect-codex-app-server/client";
154import * as CodexSchema from "effect-codex-app-server/schema";
......@@ -27,14 +16,11 @@ import { ServerSettingsError } from "@t3tools/contracts";
2716
2817import { createModelCapabilities } from "@t3tools/shared/model";
2918
30import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
31import { buildServerProvider } from "../providerSnapshot.ts";
32import { CodexProvider } from "../Services/CodexProvider.ts";
19import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";
3320import { expandHomePath } from "../../pathExpansion.ts";
34import { ServerSettingsService } from "../../serverSettings.ts";
21import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
3522import packageJson from "../../../package.json" with { type: "json" };
3623
37const PROVIDER = "codex" as const;
3824const PROVIDER_PROBE_TIMEOUT_MS = 8_000;
3925const CODEX_PRESENTATION = {
4026 displayName: "Codex",
......@@ -90,6 +76,11 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
9076 }
9177}
9278
79function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) {
80 if (!account || account.type !== "chatgpt") return undefined;
81 return account.email;
82}
83
9384function mapCodexModelCapabilities(
9485 model: CodexSchema.V2ModelListResponse__Model,
9586): ModelCapabilities {
......@@ -245,18 +236,33 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
245236 };
246237}
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.
248245const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
249246 readonly binaryPath: string;
250247 readonly homePath?: string;
251248 readonly cwd: string;
252249 readonly customModels?: ReadonlyArray<string>;
250 readonly environment?: NodeJS.ProcessEnv;
253251}) {
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;
254257 const clientContext = yield* Layer.build(
255258 CodexClient.layerCommand({
256259 command: input.binaryPath,
257260 args: ["app-server"],
258261 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 },
260266 }),
261267 );
262268 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
......@@ -305,7 +311,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
305311 models: appendCustomCodexModels(models, input.customModels ?? []),
306312 skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
307313 } satisfies CodexAppServerProviderSnapshot;
308}, Effect.scoped);
314}, scopedSafeTeardown("codex-probe"));
309315
310316const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
311317 codexSettings.customModels
......@@ -318,13 +324,12 @@ const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvi
318324 capabilities: null,
319325 }));
320326
321const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => {
327const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProviderDraft => {
322328 const checkedAt = new Date().toISOString();
323329 const models = emptyCodexModelsFromSettings(codexSettings);
324330
325331 if (!codexSettings.enabled) {
326332 return buildServerProvider({
327 provider: PROVIDER,
328333 presentation: CODEX_PRESENTATION,
329334 enabled: false,
330335 checkedAt,
......@@ -341,7 +346,6 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider
341346 }
342347
343348 return buildServerProvider({
344 provider: PROVIDER,
345349 presentation: CODEX_PRESENTATION,
346350 enabled: true,
347351 checkedAt,
......@@ -363,10 +367,12 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]):
363367 readonly message?: string;
364368} {
365369 const authLabel = codexAccountAuthLabel(account.account);
370 const authEmail = codexAccountEmail(account.account);
366371 const auth = {
367372 status: account.account ? ("authenticated" as const) : ("unknown" as const),
368373 ...(account.account?.type ? { type: account.account?.type } : {}),
369374 ...(authLabel ? { label: authLabel } : {}),
375 ...(authEmail ? { email: authEmail } : {}),
370376 } satisfies ServerProvider["auth"];
371377
372378 if (account.account) {
......@@ -385,31 +391,29 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]):
385391}
386392
387393export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
394 codexSettings: CodexSettings,
388395 probe: (input: {
389396 readonly binaryPath: string;
390397 readonly homePath?: string;
391398 readonly cwd: string;
392399 readonly customModels: ReadonlyArray<string>;
400 readonly environment?: NodeJS.ProcessEnv;
393401 }) => Effect.Effect<
394402 CodexAppServerProviderSnapshot,
395403 CodexErrors.CodexAppServerError,
396404 ChildProcessSpawner.ChildProcessSpawner
397405 > = probeCodexAppServerProvider,
406 environment: NodeJS.ProcessEnv = process.env,
398407): Effect.fn.Return<
399 ServerProvider,
408 ServerProviderDraft,
400409 ServerSettingsError,
401 ServerSettingsService | ChildProcessSpawner.ChildProcessSpawner
410 ChildProcessSpawner.ChildProcessSpawner
402411> {
403 const codexSettings = yield* Effect.service(ServerSettingsService).pipe(
404 Effect.flatMap((service) => service.getSettings),
405 Effect.map((settings) => settings.providers.codex),
406 );
407412 const checkedAt = DateTime.formatIso(yield* DateTime.now);
408413 const emptyModels = emptyCodexModelsFromSettings(codexSettings);
409414
410415 if (!codexSettings.enabled) {
411416 return buildServerProvider({
412 provider: PROVIDER,
413417 presentation: CODEX_PRESENTATION,
414418 enabled: false,
415419 checkedAt,
......@@ -430,13 +434,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
430434 homePath: codexSettings.homePath,
431435 cwd: process.cwd(),
432436 customModels: codexSettings.customModels,
437 environment,
433438 }).pipe(Effect.timeoutOption(Duration.millis(PROVIDER_PROBE_TIMEOUT_MS)), Effect.result);
434439
435440 if (Result.isFailure(probeResult)) {
436441 const error = probeResult.failure;
437442 const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error);
438443 return buildServerProvider({
439 provider: PROVIDER,
440444 presentation: CODEX_PRESENTATION,
441445 enabled: codexSettings.enabled,
442446 checkedAt,
......@@ -456,7 +460,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
456460
457461 if (Option.isNone(probeResult.success)) {
458462 return buildServerProvider({
459 provider: PROVIDER,
460463 presentation: CODEX_PRESENTATION,
461464 enabled: codexSettings.enabled,
462465 checkedAt,
......@@ -476,7 +479,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
476479 const accountStatus = accountProbeStatus(snapshot.account);
477480
478481 return buildServerProvider({
479 provider: PROVIDER,
480482 presentation: CODEX_PRESENTATION,
481483 enabled: codexSettings.enabled,
482484 checkedAt,
......@@ -492,28 +494,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
492494 });
493495});
494496
495export const CodexProviderLive = Layer.effect(
496 CodexProvider,
497 Effect.gen(function* () {
498 const serverSettings = yield* ServerSettingsService;
499 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
500 const checkProvider = checkCodexProviderStatus().pipe(
501 Effect.provideService(ServerSettingsService, serverSettings),
502 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
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);
497// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of
498// the per-instance-driver refactor. `CodexDriver.create()` builds a managed
499// snapshot per instance (each with its own `CodexSettings`) and hands the
500// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`.
501//
502// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are
503// re-exported for use by `CodexDriver`.
504export { makePendingCodexProvider };
apps/server/src/provider/Layers/CodexSessionRuntime.ts+39-22
......@@ -1,10 +1,10 @@
1import { randomUUID } from "node:crypto";
2
31import {
42 ApprovalRequestId,
5 DEFAULT_MODEL_BY_PROVIDER,
3 DEFAULT_MODEL,
64 EventId,
5 ProviderDriverKind,
76 ProviderItemId,
7 type ProviderInstanceId,
88 type ProviderApprovalDecision,
99 type ProviderEvent,
1010 type ProviderInteractionMode,
......@@ -17,7 +17,7 @@ import {
1717 TurnId,
1818} from "@t3tools/contracts";
1919import { 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";
2121import * as SchemaIssue from "effect/SchemaIssue";
2222import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2323import * as CodexClient from "effect-codex-app-server/client";
......@@ -26,13 +26,13 @@ import * as CodexRpc from "effect-codex-app-server/rpc";
2626import * as EffectCodexSchema from "effect-codex-app-server/schema";
2727
2828import { buildCodexInitializeParams } from "./CodexProvider.ts";
29import { expandHomePath } from "../../pathExpansion.ts";
2930import {
3031 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
3132 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
3233} from "../CodexDeveloperInstructions.ts";
33import { expandHomePath } from "../../pathExpansion.ts";
3434
35const PROVIDER = "codex" as const;
35const PROVIDER = ProviderDriverKind.make("codex");
3636
3737const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
3838const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
......@@ -76,8 +76,10 @@ type CodexThreadItem =
7676
7777export interface CodexSessionRuntimeOptions {
7878 readonly threadId: ThreadId;
79 readonly providerInstanceId?: ProviderInstanceId;
7980 readonly binaryPath: string;
8081 readonly homePath?: string;
82 readonly environment?: NodeJS.ProcessEnv;
8183 readonly cwd: string;
8284 readonly runtimeMode: RuntimeMode;
8385 readonly model?: string;
......@@ -87,7 +89,10 @@ export interface CodexSessionRuntimeOptions {
8789
8890export interface CodexSessionRuntimeSendTurnInput {
8991 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 }>;
9196 readonly model?: string;
9297 readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier | undefined;
9398 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined;
......@@ -303,7 +308,7 @@ function buildCodexCollaborationMode(input: {
303308 if (input.interactionMode === undefined) {
304309 return undefined;
305310 }
306 const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL_BY_PROVIDER.codex;
311 const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL;
307312 return {
308313 mode: input.interactionMode,
309314 settings: {
......@@ -321,7 +326,10 @@ export function buildTurnStartParams(input: {
321326 readonly threadId: string;
322327 readonly runtimeMode: RuntimeMode;
323328 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 }>;
325333 readonly model?: string;
326334 readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier;
327335 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
......@@ -680,13 +688,19 @@ export const makeCodexSessionRuntime = (
680688 const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>());
681689 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 };
683699 const child = yield* spawner
684700 .spawn(
685701 ChildProcess.make(options.binaryPath, ["app-server"], {
686702 cwd: options.cwd,
687 ...(options.homePath
688 ? { env: { ...process.env, CODEX_HOME: expandHomePath(options.homePath) } }
689 : {}),
703 env,
690704 shell: process.platform === "win32",
691705 }),
692706 )
......@@ -712,6 +726,7 @@ export const makeCodexSessionRuntime = (
712726
713727 const initialSession = {
714728 provider: PROVIDER,
729 ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}),
715730 status: "connecting",
716731 runtimeMode: options.runtimeMode,
717732 cwd: options.cwd,
......@@ -725,13 +740,15 @@ export const makeCodexSessionRuntime = (
725740 const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid);
726741
727742 const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) =>
728 offerEvent({
729 id: EventId.make(randomUUID()),
730 provider: PROVIDER,
731 createdAt: new Date().toISOString(),
732 ...event,
733 });
734
743 Effect.flatMap(Random.nextUUIDv4, (id) =>
744 offerEvent({
745 id: EventId.make(id),
746 provider: PROVIDER,
747 ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}),
748 createdAt: new Date().toISOString(),
749 ...event,
750 }),
751 );
735752 const emitSessionEvent = (method: string, message: string) =>
736753 emitEvent({
737754 kind: "session",
......@@ -891,7 +908,7 @@ export const makeCodexSessionRuntime = (
891908
892909 yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) =>
893910 Effect.gen(function* () {
894 const requestId = ApprovalRequestId.make(randomUUID());
911 const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4);
895912 const turnId = TurnId.make(payload.turnId);
896913 const itemId = ProviderItemId.make(payload.itemId);
897914 const decision = yield* Deferred.make<ProviderApprovalDecision>();
......@@ -947,7 +964,7 @@ export const makeCodexSessionRuntime = (
947964
948965 yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) =>
949966 Effect.gen(function* () {
950 const requestId = ApprovalRequestId.make(randomUUID());
967 const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4);
951968 const turnId = TurnId.make(payload.turnId);
952969 const itemId = ProviderItemId.make(payload.itemId);
953970 const decision = yield* Deferred.make<ProviderApprovalDecision>();
......@@ -1003,7 +1020,7 @@ export const makeCodexSessionRuntime = (
10031020
10041021 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
10051022 Effect.gen(function* () {
1006 const requestId = ApprovalRequestId.make(randomUUID());
1023 const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4);
10071024 const turnId = TurnId.make(payload.turnId);
10081025 const itemId = ProviderItemId.make(payload.itemId);
10091026 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";
55
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { assert, it } from "@effect/vitest";
8import { Deferred, Effect, Fiber, Layer, Stream } from "effect";
8import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect";
99import { 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
1320import { ServerConfig } from "../../config.ts";
1421import { ServerSettingsService } from "../../serverSettings.ts";
15import { CursorAdapter } from "../Services/CursorAdapter.ts";
16import { makeCursorAdapterLive } from "./CursorAdapter.ts";
22import type { CursorAdapterShape } from "../Services/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
1830const __dirname = path.dirname(fileURLToPath(import.meta.url));
1931const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
......@@ -91,8 +103,31 @@ async function waitForFileContent(filePath: string, attempts = 40) {
91103 throw new Error(`Timed out waiting for file content at ${filePath}`);
92104}
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
94122const 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(
96131 Layer.provideMerge(ServerSettingsService.layerTest()),
97132 Layer.provideMerge(
98133 ServerConfig.layerTest(process.cwd(), {
......@@ -120,10 +155,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
120155
121156 const session = yield* adapter.startSession({
122157 threadId,
123 provider: "cursor",
158 provider: ProviderDriverKind.make("cursor"),
124159 cwd: process.cwd(),
125160 runtimeMode: "full-access",
126 modelSelection: { provider: "cursor", model: "default" },
161 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
127162 });
128163
129164 assert.equal(session.provider, "cursor");
......@@ -205,10 +240,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
205240
206241 yield* adapter.startSession({
207242 threadId,
208 provider: "cursor",
243 provider: ProviderDriverKind.make("cursor"),
209244 cwd: process.cwd(),
210245 runtimeMode: "full-access",
211 modelSelection: { provider: "cursor", model: "default" },
246 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
212247 });
213248
214249 yield* adapter.stopSession(threadId);
......@@ -244,17 +279,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
244279 [
245280 adapter.startSession({
246281 threadId,
247 provider: "cursor",
282 provider: ProviderDriverKind.make("cursor"),
248283 cwd: process.cwd(),
249284 runtimeMode: "full-access",
250 modelSelection: { provider: "cursor", model: "default" },
285 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
251286 }),
252287 adapter.startSession({
253288 threadId,
254 provider: "cursor",
289 provider: ProviderDriverKind.make("cursor"),
255290 cwd: process.cwd(),
256291 runtimeMode: "full-access",
257 modelSelection: { provider: "cursor", model: "default" },
292 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
258293 }),
259294 ],
260295 { concurrency: "unbounded" },
......@@ -276,7 +311,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
276311 const result = yield* adapter
277312 .startSession({
278313 threadId: ThreadId.make("bad-provider"),
279 provider: "codex",
314 provider: ProviderDriverKind.make("codex"),
280315 cwd: process.cwd(),
281316 runtimeMode: "full-access",
282317 })
......@@ -302,10 +337,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
302337
303338 yield* adapter.startSession({
304339 threadId,
305 provider: "cursor",
340 provider: ProviderDriverKind.make("cursor"),
306341 cwd: process.cwd(),
307342 runtimeMode: "full-access",
308 modelSelection: { provider: "cursor", model: "composer-2" },
343 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
309344 });
310345
311346 yield* adapter.sendTurn({
......@@ -358,7 +393,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
358393 providers: { cursor: { binaryPath: wrapperPath } },
359394 });
360395
361 const modelSelection = createModelSelection("cursor", "gpt-5.4", [
396 const modelSelection = createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [
362397 { id: "reasoning", value: "xhigh" },
363398 { id: "contextWindow", value: "1m" },
364399 { id: "fastMode", value: true },
......@@ -366,7 +401,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
366401
367402 yield* adapter.startSession({
368403 threadId,
369 provider: "cursor",
404 provider: ProviderDriverKind.make("cursor"),
370405 cwd: process.cwd(),
371406 runtimeMode: "full-access",
372407 modelSelection,
......@@ -460,10 +495,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
460495 const program = Effect.gen(function* () {
461496 yield* adapter.startSession({
462497 threadId,
463 provider: "cursor",
498 provider: ProviderDriverKind.make("cursor"),
464499 cwd: process.cwd(),
465500 runtimeMode: "approval-required",
466 modelSelection: { provider: "cursor", model: "default" },
501 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
467502 });
468503
469504 const turn = yield* adapter.sendTurn({
......@@ -560,7 +595,14 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
560595 );
561596 }).pipe(
562597 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(
564606 Layer.provideMerge(ServerSettingsService.layerTest()),
565607 Layer.provideMerge(
566608 ServerConfig.layerTest(process.cwd(), {
......@@ -615,10 +657,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
615657
616658 yield* adapter.startSession({
617659 threadId,
618 provider: "cursor",
660 provider: ProviderDriverKind.make("cursor"),
619661 cwd: process.cwd(),
620662 runtimeMode: "full-access",
621 modelSelection: { provider: "cursor", model: "default" },
663 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
622664 });
623665
624666 const turn = yield* adapter.sendTurn({
......@@ -714,10 +756,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
714756
715757 yield* adapter.startSession({
716758 threadId,
717 provider: "cursor",
759 provider: ProviderDriverKind.make("cursor"),
718760 cwd: process.cwd(),
719761 runtimeMode: "full-access",
720 modelSelection: { provider: "cursor", model: "default" },
762 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
721763 });
722764
723765 const turn = yield* adapter.sendTurn({
......@@ -836,10 +878,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
836878
837879 yield* adapter.startSession({
838880 threadId,
839 provider: "cursor",
881 provider: ProviderDriverKind.make("cursor"),
840882 cwd: process.cwd(),
841883 runtimeMode: "approval-required",
842 modelSelection: { provider: "cursor", model: "default" },
884 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
843885 });
844886
845887 const sendTurnFiber = yield* adapter
......@@ -906,10 +948,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
906948
907949 yield* adapter.startSession({
908950 threadId,
909 provider: "cursor",
951 provider: ProviderDriverKind.make("cursor"),
910952 cwd: process.cwd(),
911953 runtimeMode: "approval-required",
912 modelSelection: { provider: "cursor", model: "default" },
954 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
913955 });
914956
915957 const sendTurnFiber = yield* adapter
......@@ -949,10 +991,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
949991
950992 yield* adapter.startSession({
951993 threadId,
952 provider: "cursor",
994 provider: ProviderDriverKind.make("cursor"),
953995 cwd: process.cwd(),
954996 runtimeMode: "full-access",
955 modelSelection: { provider: "cursor", model: "default" },
997 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
956998 });
957999
9581000 const sendTurnFiber = yield* adapter
......@@ -992,10 +1034,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
9921034
9931035 yield* adapter.startSession({
9941036 threadId,
995 provider: "cursor",
1037 provider: ProviderDriverKind.make("cursor"),
9961038 cwd: process.cwd(),
9971039 runtimeMode: "full-access",
998 modelSelection: { provider: "cursor", model: "default" },
1040 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
9991041 });
10001042
10011043 const sendTurnFiber = yield* adapter
......@@ -1035,10 +1077,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10351077
10361078 yield* adapter.startSession({
10371079 threadId,
1038 provider: "cursor",
1080 provider: ProviderDriverKind.make("cursor"),
10391081 cwd: process.cwd(),
10401082 runtimeMode: "full-access",
1041 modelSelection: { provider: "cursor", model: "default" },
1083 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
10421084 });
10431085
10441086 const firstEvents = Array.from(yield* Fiber.join(firstConsumer));
......@@ -1073,10 +1115,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10731115
10741116 yield* adapter.startSession({
10751117 threadId,
1076 provider: "cursor",
1118 provider: ProviderDriverKind.make("cursor"),
10771119 cwd: process.cwd(),
10781120 runtimeMode: "full-access",
1079 modelSelection: { provider: "cursor", model: "composer-2" },
1121 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
10801122 });
10811123
10821124 yield* adapter.sendTurn({
......@@ -1089,7 +1131,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10891131 threadId,
10901132 input: "second turn after switching model",
10911133 attachments: [],
1092 modelSelection: createModelSelection("cursor", "composer-2", [
1134 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [
10931135 { id: "fastMode", value: true },
10941136 ]),
10951137 });
......@@ -1136,17 +1178,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
11361178
11371179 yield* adapter.startSession({
11381180 threadId,
1139 provider: "cursor",
1181 provider: ProviderDriverKind.make("cursor"),
11401182 cwd: process.cwd(),
11411183 runtimeMode: "full-access",
1142 modelSelection: { provider: "cursor", model: "composer-2" },
1184 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
11431185 });
11441186
11451187 yield* adapter.sendTurn({
11461188 threadId,
11471189 input: "first turn with fast mode",
11481190 attachments: [],
1149 modelSelection: createModelSelection("cursor", "composer-2", [
1191 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [
11501192 { id: "fastMode", value: true },
11511193 ]),
11521194 });
......@@ -1155,7 +1197,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
11551197 threadId,
11561198 input: "second turn without fast mode",
11571199 attachments: [],
1158 modelSelection: createModelSelection("cursor", "composer-2", [
1200 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [
11591201 { id: "fastMode", value: false },
11601202 ]),
11611203 });
......@@ -1174,4 +1216,91 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
11741216 yield* adapter.stopSession(threadId);
11751217 }),
11761218 );
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 );
11771306});
apps/server/src/provider/Layers/CursorAdapter.ts+45-28
......@@ -7,6 +7,7 @@ import * as nodePath from "node:path";
77
88import {
99 ApprovalRequestId,
10 type CursorSettings,
1011 type ProviderOptionSelection,
1112 EventId,
1213 type ProviderApprovalDecision,
......@@ -14,6 +15,8 @@ import {
1415 type ProviderRuntimeEvent,
1516 type ProviderSession,
1617 type ProviderUserInputAnswers,
18 ProviderDriverKind,
19 ProviderInstanceId,
1720 RuntimeRequestId,
1821 type RuntimeMode,
1922 type ThreadId,
......@@ -26,7 +29,6 @@ import {
2629 Exit,
2730 Fiber,
2831 FileSystem,
29 Layer,
3032 Option,
3133 PubSub,
3234 Random,
......@@ -40,7 +42,6 @@ import type * as EffectAcpSchema from "effect-acp/schema";
4042
4143import { resolveAttachmentPath } from "../../attachmentStore.ts";
4244import { ServerConfig } from "../../config.ts";
43import { ServerSettingsService } from "../../serverSettings.ts";
4445import {
4546 ProviderAdapterProcessError,
4647 ProviderAdapterRequestError,
......@@ -72,19 +73,37 @@ import {
7273 extractPlanMarkdown,
7374 extractTodosAsPlan,
7475} from "../acp/CursorAcpExtension.ts";
75import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts";
76import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";
7677import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
7778import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
7879
79const PROVIDER = "cursor" as const;
80const PROVIDER = ProviderDriverKind.make("cursor");
8081const CURSOR_RESUME_VERSION = 1 as const;
8182const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
8283const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
8384const ACP_APPROVAL_MODE_ALIASES = ["ask"];
8485
8586export interface CursorAdapterLiveOptions {
87 readonly environment?: NodeJS.ProcessEnv;
8688 readonly nativeEventLogPath?: string;
8789 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>;
88107}
89108
90109interface PendingApproval {
......@@ -280,12 +299,15 @@ function selectAutoApprovedPermissionOption(
280299 return undefined;
281300}
282301
283function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
302export function makeCursorAdapter(
303 cursorSettings: CursorSettings,
304 options?: CursorAdapterLiveOptions,
305) {
284306 return Effect.gen(function* () {
307 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor");
285308 const fileSystem = yield* FileSystem.FileSystem;
286309 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
287310 const serverConfig = yield* Effect.service(ServerConfig);
288 const serverSettingsService = yield* ServerSettingsService;
289311 const nativeEventLogger =
290312 options?.nativeEventLogger ??
291313 (options?.nativeEventLogPath !== undefined
......@@ -440,25 +462,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
440462
441463 const cwd = nodePath.resolve(input.cwd.trim());
442464 const cursorModelSelection =
443 input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined;
465 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
444466 const existing = sessions.get(input.threadId);
445467 if (existing && !existing.stopped) {
446468 yield* stopSessionInternal(existing);
447469 }
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
462471 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
463472 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
464473 const sessionScope = yield* Scope.make("sequential");
......@@ -475,8 +484,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
475484 threadId: input.threadId,
476485 });
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
478499 const acp = yield* makeCursorAcpRuntime({
479 cursorSettings,
500 cursorSettings: effectiveCursorSettings,
501 ...(options?.environment ? { environment: options.environment } : {}),
480502 childProcessSpawner,
481503 cwd,
482504 ...(resumeSessionId ? { resumeSessionId } : {}),
......@@ -666,6 +688,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
666688 const now = yield* nowIso;
667689 const session: ProviderSession = {
668690 provider: PROVIDER,
691 providerInstanceId: boundInstanceId,
669692 status: "ready",
670693 runtimeMode: input.runtimeMode,
671694 cwd,
......@@ -815,7 +838,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
815838 const ctx = yield* requireSession(input.threadId);
816839 const turnId = TurnId.make(crypto.randomUUID());
817840 const turnModelSelection =
818 input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined;
841 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
819842 const model = turnModelSelection?.model ?? ctx.session.model;
820843 const resolvedModel = resolveCursorAcpBaseModelId(model);
821844 yield* applyRequestedSessionConfiguration({
......@@ -1049,9 +1072,3 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
10491072 } satisfies CursorAdapterShape;
10501073 });
10511074}
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 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
1import * as NodeOS from "node:os";
52
63import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect } from "effect";
4import { Effect, FileSystem, Path } from "effect";
85import { describe, expect, it } from "vitest";
96import type * as EffectAcpSchema from "effect-acp/schema";
107import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";
......@@ -14,6 +11,7 @@ import {
1411 buildCursorProviderSnapshot,
1512 buildCursorCapabilitiesFromConfigOptions,
1613 buildCursorDiscoveredModelsFromConfigOptions,
14 checkCursorProviderStatus,
1715 discoverCursorModelCapabilitiesViaAcp,
1816 discoverCursorModelsViaAcp,
1917 getCursorFallbackModels,
......@@ -25,8 +23,14 @@ import {
2523 resolveCursorAcpConfigUpdates,
2624} from "./CursorProvider.ts";
2725
28const __dirname = path.dirname(fileURLToPath(import.meta.url));
29const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
26const runNode = <A, E>(
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
3135function selectDescriptor(
3236 id: string,
......@@ -53,8 +57,16 @@ function booleanDescriptor(id: string, label: string, currentValue?: boolean) {
5357 };
5458}
5559
56async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {
57 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-"));
60const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* (
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 });
5870 const wrapperPath = path.join(dir, "fake-agent.sh");
5971 const envExports = Object.entries(extraEnv ?? {})
6072 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
......@@ -63,23 +75,80 @@ async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {
6375${envExports}
6476exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"
6577`;
66 await writeFile(wrapperPath, script, "utf8");
67 await chmod(wrapperPath, 0o755);
78 yield* fileSystem.writeFileString(wrapperPath, script);
79 yield* fileSystem.chmod(wrapperPath, 0o755);
6880 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;
72110 for (let attempt = 0; attempt < attempts; attempt += 1) {
73 try {
74 const content = await readFile(filePath, "utf8");
111 const content = yield* fileSystem
112 .readFileString(filePath)
113 .pipe(Effect.catch(() => Effect.void));
114 if (content !== undefined) {
75115 if (content.trim().length > 0) {
76116 return content;
77117 }
78 } catch {}
79 await new Promise((resolve) => setTimeout(resolve, 50));
118 }
119 yield* Effect.sleep("50 millis");
80120 }
81 throw new Error(`Timed out waiting for file content at ${filePath}`);
82}
121 return yield* Effect.fail(new Error(`Timed out waiting for file content at ${filePath}`));
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
84153const parameterizedGpt54ConfigOptions = [
85154 {
......@@ -428,9 +497,38 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => {
428497 });
429498});
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
431529describe("discoverCursorModelsViaAcp", () => {
432530 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
435533 const models = await Effect.runPromise(
436534 discoverCursorModelsViaAcp({
......@@ -450,11 +548,9 @@ describe("discoverCursorModelsViaAcp", () => {
450548 });
451549
452550 it("closes the ACP probe runtime after discovery completes", async () => {
453 const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-"));
454 const exitLogPath = path.join(tempDir, "exit.log");
455 const wrapperPath = await makeMockAgentWrapper({
456 T3_ACP_EXIT_LOG_PATH: exitLogPath,
457 });
551 const { exitLogPath, wrapperPath } = await runNode(
552 makeExitLogFixture("cursor-provider-exit-log-"),
553 );
458554
459555 await Effect.runPromise(
460556 discoverCursorModelsViaAcp({
......@@ -465,18 +561,16 @@ describe("discoverCursorModelsViaAcp", () => {
465561 }).pipe(Effect.provide(NodeServices.layer)),
466562 );
467563
468 const exitLog = await waitForFileContent(exitLogPath);
564 const exitLog = await runNode(waitForFileContent(exitLogPath));
469565 expect(exitLog).toContain("SIGTERM");
470566 });
471567});
472568
473569describe("discoverCursorModelCapabilitiesViaAcp", () => {
474570 it("closes all ACP probe runtimes after capability enrichment completes", async () => {
475 const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-"));
476 const exitLogPath = path.join(tempDir, "exit.log");
477 const wrapperPath = await makeMockAgentWrapper({
478 T3_ACP_EXIT_LOG_PATH: exitLogPath,
479 });
571 const { exitLogPath, wrapperPath } = await runNode(
572 makeExitLogFixture("cursor-capabilities-exit-log-"),
573 );
480574 const existingModels: ReadonlyArray<ServerProviderModel> = [
481575 { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities },
482576 { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities },
......@@ -508,7 +602,7 @@ describe("discoverCursorModelCapabilitiesViaAcp", () => {
508602 "claude-opus-4-6",
509603 ]);
510604
511 const exitLog = await waitForFileContent(exitLogPath);
605 const exitLog = await runNode(waitForFileContent(exitLogPath));
512606 expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4);
513607 });
514608});
......@@ -530,6 +624,7 @@ describe("parseCursorAboutOutput", () => {
530624 status: "ready",
531625 auth: {
532626 status: "authenticated",
627 email: "jmarminge@gmail.com",
533628 type: "Team",
534629 label: "Cursor Team Subscription",
535630 },
apps/server/src/provider/Layers/CursorProvider.ts+352-313
......@@ -1,6 +1,4 @@
1import * as nodeFs from "node:fs";
21import * as nodeOs from "node:os";
3import * as nodePath from "node:path";
42
53import type {
64 CursorSettings,
......@@ -10,10 +8,10 @@ import type {
108 ServerProviderAuth,
119 ServerProviderModel,
1210 ServerProviderState,
13 ServerSettingsError,
1411} from "@t3tools/contracts";
12import { ProviderDriverKind } from "@t3tools/contracts";
1513import 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";
1715import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1816import {
1917 createModelCapabilities,
......@@ -29,13 +27,11 @@ import {
2927 isCommandMissingCause,
3028 providerModelsFromSettings,
3129 type CommandResult,
30 type ServerProviderDraft,
3231} from "../providerSnapshot.ts";
33import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
34import { CursorProvider } from "../Services/CursorProvider.ts";
3532import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";
3733
38const PROVIDER = "cursor" as const;
34const PROVIDER = ProviderDriverKind.make("cursor");
3935const CURSOR_PRESENTATION = {
4036 displayName: "Cursor",
4137 badgeLabel: "Early Access",
......@@ -48,7 +44,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
4844const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
4945const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
5046const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;
51const CURSOR_REFRESH_INTERVAL = "1 hour";
5247const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
5348export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
5449 _meta: {
......@@ -56,13 +51,14 @@ export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
5651 },
5752} satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
5853
59function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider {
54export function buildInitialCursorProviderSnapshot(
55 cursorSettings: CursorSettings,
56): ServerProviderDraft {
6057 const checkedAt = new Date().toISOString();
6158 const models = getCursorFallbackModels(cursorSettings);
6259
6360 if (!cursorSettings.enabled) {
6461 return buildServerProvider({
65 provider: PROVIDER,
6662 presentation: CURSOR_PRESENTATION,
6763 enabled: false,
6864 checkedAt,
......@@ -78,7 +74,6 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser
7874 }
7975
8076 return buildServerProvider({
81 provider: PROVIDER,
8277 presentation: CURSOR_PRESENTATION,
8378 enabled: true,
8479 checkedAt,
......@@ -112,7 +107,12 @@ function flattenSessionConfigSelectOptions(
112107 }
113108 return configOption.options.flatMap((entry) =>
114109 "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 ]
116116 : entry.options.map(
117117 (option) =>
118118 ({
......@@ -392,7 +392,10 @@ export function buildCursorDiscoveredModelsFromConfigOptions(
392392 );
393393}
394394
395const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) =>
395const makeCursorAcpProbeRuntime = (
396 cursorSettings: CursorSettings,
397 environment: NodeJS.ProcessEnv = process.env,
398) =>
396399 Effect.gen(function* () {
397400 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
398401 const acpContext = yield* Layer.build(
......@@ -404,6 +407,7 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) =>
404407 "acp",
405408 ],
406409 cwd: process.cwd(),
410 env: environment,
407411 },
408412 cwd: process.cwd(),
409413 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
......@@ -417,7 +421,12 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) =>
417421const withCursorAcpProbeRuntime = <A, E, R>(
418422 cursorSettings: CursorSettings,
419423 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
422431function normalizeCursorConfigOptionToken(value: string | null | undefined): string {
423432 return (
......@@ -460,12 +469,18 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s
460469export function resolveCursorAcpConfigUpdates(
461470 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
462471 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}> {
464476 if (!configOptions || configOptions.length === 0) {
465477 return [];
466478 }
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
470485 const reasoningOption = findCursorEffortConfigOption(configOptions);
471486 const requestedReasoning = normalizeCursorReasoningValue(
......@@ -525,43 +540,129 @@ export function resolveCursorAcpConfigUpdates(
525540 return updates;
526541}
527542
528export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) =>
529 withCursorAcpProbeRuntime(cursorSettings, (acp) =>
530 Effect.map(acp.start(), (started) =>
531 buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []),
532 ),
543export const discoverCursorModelsViaAcp = (
544 cursorSettings: CursorSettings,
545 environment: NodeJS.ProcessEnv = process.env,
546) =>
547 withCursorAcpProbeRuntime(
548 cursorSettings,
549 (acp) =>
550 Effect.map(acp.start(), (started) =>
551 buildCursorDiscoveredModelsFromConfigOptions(
552 started.sessionSetupResult.configOptions ?? [],
553 ),
554 ),
555 environment,
533556 );
534557
535558export const discoverCursorModelCapabilitiesViaAcp = (
536559 cursorSettings: CursorSettings,
537560 existingModels: ReadonlyArray<ServerProviderModel>,
561 environment: NodeJS.ProcessEnv = process.env,
538562) =>
539 withCursorAcpProbeRuntime(cursorSettings, (acp) =>
540 Effect.gen(function* () {
541 const started = yield* acp.start();
542 const initialConfigOptions = started.sessionSetupResult.configOptions ?? [];
543 const modelOption = findCursorModelConfigOption(initialConfigOptions);
544 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
545 if (!modelOption || modelChoices.length === 0) {
546 return [];
547 }
563 withCursorAcpProbeRuntime(
564 cursorSettings,
565 (acp) =>
566 Effect.gen(function* () {
567 const started = yield* acp.start();
568 const initialConfigOptions = started.sessionSetupResult.configOptions ?? [];
569 const modelOption = findCursorModelConfigOption(initialConfigOptions);
570 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
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 =
550 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
551 const capabilitiesBySlug = new Map<string, ModelCapabilities>();
552 if (currentModelValue) {
553 capabilitiesBySlug.set(
554 currentModelValue,
555 buildCursorCapabilitiesFromConfigOptions(initialConfigOptions),
585 const targetModelSlugs = new Set(
586 existingModels
587 .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model))
588 .map((model) => model.slug),
556589 );
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) {
565666 return buildCursorDiscoveredModels(
566667 modelChoices.map((modelChoice) => ({
567668 slug: modelChoice.value.trim(),
......@@ -569,73 +670,8 @@ export const discoverCursorModelCapabilitiesViaAcp = (
569670 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
570671 })),
571672 );
572 }
573
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", {})),
673 }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})),
674 environment,
639675 );
640676
641677export function getCursorFallbackModels(
......@@ -683,10 +719,9 @@ export function buildCursorProviderSnapshot(input: {
683719 readonly parsed: CursorAboutResult;
684720 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
685721 readonly discoveryWarning?: string;
686}): ServerProvider {
722}): ServerProviderDraft {
687723 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
688724 return buildServerProvider({
689 provider: PROVIDER,
690725 presentation: CURSOR_PRESENTATION,
691726 enabled: input.cursorSettings.enabled,
692727 checkedAt: input.checkedAt,
......@@ -811,14 +846,13 @@ function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean {
811846 );
812847}
813848
814function readCursorCliConfigChannel(): string | undefined {
815 try {
816 const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json");
817 return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8"));
818 } catch {
819 return undefined;
820 }
821}
849const readCursorCliConfigChannel = Effect.fn("readCursorCliConfigChannel")(function* () {
850 const fileSystem = yield* FileSystem.FileSystem;
851 const path = yield* Path.Path;
852 const configPath = path.join(nodeOs.homedir(), ".cursor", "cli-config.json");
853 const raw = yield* fileSystem.readFileString(configPath).pipe(Effect.orElseSucceed(() => ""));
854 return parseCursorCliConfigChannel(raw);
855});
822856
823857export function getCursorParameterizedModelPickerUnsupportedMessage(input: {
824858 readonly version: string | null | undefined;
......@@ -934,6 +968,7 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult
934968 status: "ready",
935969 auth: {
936970 status: "authenticated",
971 email: userEmail,
937972 ...authMetadata,
938973 },
939974 };
......@@ -989,17 +1024,22 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult
9891024 }
9901025
9911026 // 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 };
9931032}
9941033
995const runCursorCommand = (args: ReadonlyArray<string>) =>
1034const runCursorCommand = (
1035 cursorSettings: CursorSettings,
1036 args: ReadonlyArray<string>,
1037 environment: NodeJS.ProcessEnv = process.env,
1038) =>
9961039 Effect.gen(function* () {
9971040 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 );
10021041 const command = ChildProcess.make(cursorSettings.binaryPath, [...args], {
1042 env: environment,
10031043 shell: process.platform === "win32",
10041044 });
10051045
......@@ -1016,203 +1056,202 @@ const runCursorCommand = (args: ReadonlyArray<string>) =>
10161056 return { stdout, stderr, code: exitCode } satisfies CommandResult;
10171057 }).pipe(Effect.scoped);
10181058
1019const runCursorAboutCommand = Effect.gen(function* () {
1020 const jsonResult = yield* runCursorCommand(["about", "--format", "json"]);
1021 if (!isCursorAboutJsonFormatUnsupported(jsonResult)) {
1022 return jsonResult;
1023 }
1024 return yield* runCursorCommand(["about"]);
1025});
1026
1027export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
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),
1059const runCursorAboutCommand = (
1060 cursorSettings: CursorSettings,
1061 environment: NodeJS.ProcessEnv = process.env,
1062) =>
1063 Effect.gen(function* () {
1064 const jsonResult = yield* runCursorCommand(
1065 cursorSettings,
1066 ["about", "--format", "json"],
1067 environment,
10361068 );
1037 const checkedAt = new Date().toISOString();
1038 const fallbackModels = getCursorFallbackModels(cursorSettings);
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 });
1069 if (!isCursorAboutJsonFormatUnsupported(jsonResult)) {
1070 return jsonResult;
10551071 }
1072 return yield* runCursorCommand(cursorSettings, ["about"], environment);
1073 });
10561074
1057 // Single `agent about` probe: returns version + auth status in one call.
1058 const aboutProbe = yield* runCursorAboutCommand.pipe(
1059 Effect.timeoutOption(ABOUT_TIMEOUT_MS),
1060 Effect.result,
1061 );
1075export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
1076 cursorSettings: CursorSettings,
1077 environment: NodeJS.ProcessEnv = process.env,
1078): Effect.fn.Return<
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)) {
1064 const error = aboutProbe.failure;
1065 return buildServerProvider({
1066 provider: PROVIDER,
1067 presentation: CURSOR_PRESENTATION,
1068 enabled: cursorSettings.enabled,
1069 checkedAt,
1070 models: fallbackModels,
1071 probe: {
1072 installed: !isCommandMissingCause(error),
1073 version: null,
1074 status: "error",
1075 auth: { status: "unknown" },
1076 message: isCommandMissingCause(error)
1077 ? "Cursor Agent CLI (`agent`) is not installed or not on PATH."
1078 : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
1079 },
1080 });
1081 }
1086 if (!cursorSettings.enabled) {
1087 return buildServerProvider({
1088 presentation: CURSOR_PRESENTATION,
1089 enabled: false,
1090 checkedAt,
1091 models: fallbackModels,
1092 probe: {
1093 installed: false,
1094 version: null,
1095 status: "warning",
1096 auth: { status: "unknown" },
1097 message: "Cursor is disabled in T3 Code settings.",
1098 },
1099 });
1100 }
10821101
1083 if (Option.isNone(aboutProbe.success)) {
1084 return buildServerProvider({
1085 provider: PROVIDER,
1086 presentation: CURSOR_PRESENTATION,
1087 enabled: cursorSettings.enabled,
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 }
1102 // Single `agent about` probe: returns version + auth status in one call.
1103 const aboutProbe = yield* runCursorAboutCommand(cursorSettings, environment).pipe(
1104 Effect.timeoutOption(ABOUT_TIMEOUT_MS),
1105 Effect.result,
1106 );
10991107
1100 const parsed = parseCursorAboutOutput(aboutProbe.success.value);
1101 const parameterizedModelPickerUnsupportedMessage =
1102 getCursorParameterizedModelPickerUnsupportedMessage({
1103 version: parsed.version,
1104 channel: readCursorCliConfigChannel(),
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({
1108 if (Result.isFailure(aboutProbe)) {
1109 const error = aboutProbe.failure;
1110 return buildServerProvider({
1111 presentation: CURSOR_PRESENTATION,
1112 enabled: cursorSettings.enabled,
11471113 checkedAt,
1148 cursorSettings,
1149 parsed,
1150 discoveredModels: Option.getOrElse(
1151 Option.filter(discoveredModels, (models) => models.length > 0),
1152 () => [] as const,
1153 ),
1154 ...(discoveryWarning ? { discoveryWarning } : {}),
1114 models: fallbackModels,
1115 probe: {
1116 installed: !isCommandMissingCause(error),
1117 version: null,
1118 status: "error",
1119 auth: { status: "unknown" },
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 },
11551124 });
1156 },
1157);
1125 }
11581126
1159export const CursorProviderLive = Layer.effect(
1160 CursorProvider,
1161 Effect.gen(function* () {
1162 const serverSettings = yield* ServerSettingsService;
1163 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1127 if (Option.isNone(aboutProbe.success)) {
1128 return buildServerProvider({
1129 presentation: CURSOR_PRESENTATION,
1130 enabled: cursorSettings.enabled,
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(
1166 Effect.provideService(ServerSettingsService, serverSettings),
1167 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1143 const parsed = parseCursorAboutOutput(aboutProbe.success.value);
1144 const cursorCliConfigChannel = yield* readCursorCliConfigChannel();
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 ),
11681175 );
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>({
1171 getSettings: serverSettings.getSettings.pipe(
1172 Effect.map((settings) => settings.providers.cursor),
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 }
1201export function hasUncapturedCursorModels(snapshot: Pick<ServerProvider, "models">): boolean {
1202 return snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model));
1203}
11891204
1190 return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe(
1191 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1192 Effect.flatMap((discoveredModels) => {
1193 if (discoveredModels.length === 0) {
1194 return Effect.void;
1195 }
1205/**
1206 * Background capability enrichment for a Cursor snapshot.
1207 *
1208 * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot`
1209 * hook: runs the slow ACP per-model capability probe, and republishes the
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({
1198 ...snapshot,
1199 models: providerModelsFromSettings(
1200 discoveredModels,
1201 PROVIDER,
1202 settings.customModels,
1203 EMPTY_CAPABILITIES,
1204 ),
1205 });
1206 }),
1207 Effect.catchCause((cause) =>
1208 Effect.logWarning("Cursor ACP background capability enrichment failed", {
1209 models: snapshot.models.map((model) => model.slug),
1210 cause: Cause.pretty(cause),
1211 }).pipe(Effect.asVoid),
1225 if (
1226 !settings.enabled ||
1227 snapshot.auth.status === "unauthenticated" ||
1228 !hasUncapturedCursorModels(snapshot)
1229 ) {
1230 return Effect.void;
1231 }
1232
1233 return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe(
1234 Effect.flatMap((discoveredModels) => {
1235 if (discoveredModels.length === 0) {
1236 return Effect.void;
1237 }
1238 return publishSnapshot(
1239 stampIdentity({
1240 ...snapshot,
1241 models: providerModelsFromSettings(
1242 discoveredModels,
1243 PROVIDER,
1244 settings.customModels,
1245 EMPTY_CAPABILITIES,
12121246 ),
1213 );
1214 },
1215 refreshInterval: CURSOR_REFRESH_INTERVAL,
1216 });
1217 }),
1218);
1247 }),
1248 );
1249 }),
1250 Effect.catchCause((cause) =>
1251 Effect.logWarning("Cursor ACP background capability enrichment failed", {
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";
22
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { it } from "@effect/vitest";
5import { Effect, Layer, Option } from "effect";
5import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect";
66import { 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";
915import { ServerConfig } from "../../config.ts";
1016import { ServerSettingsService } from "../../serverSettings.ts";
1117import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
12import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
18import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1319import {
1420 OpenCodeRuntime,
1521 OpenCodeRuntimeError,
......@@ -17,10 +23,15 @@ import {
1723} from "../opencodeRuntime.ts";
1824import {
1925 appendOpenCodeAssistantTextDelta,
20 makeOpenCodeAdapterLive,
26 makeOpenCodeAdapter,
2127 mergeOpenCodeAssistantText,
2228} 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
2435const asThreadId = (value: string): ThreadId => ThreadId.make(value);
2536
2637type MessageEntry = {
......@@ -39,6 +50,7 @@ const runtimeMock = {
3950 abortCalls: [] as string[],
4051 closeCalls: [] as string[],
4152 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
53 promptCalls: [] as Array<unknown>,
4254 promptAsyncError: null as Error | null,
4355 closeError: null as Error | null,
4456 messages: [] as MessageEntry[],
......@@ -51,6 +63,7 @@ const runtimeMock = {
5163 this.state.abortCalls.length = 0;
5264 this.state.closeCalls.length = 0;
5365 this.state.revertCalls.length = 0;
66 this.state.promptCalls.length = 0;
5467 this.state.promptAsyncError = null;
5568 this.state.closeError = null;
5669 this.state.messages = [];
......@@ -111,7 +124,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
111124 abort: async ({ sessionID }: { sessionID: string }) => {
112125 runtimeMock.state.abortCalls.push(sessionID);
113126 },
114 promptAsync: async () => {
127 promptAsync: async (input: unknown) => {
128 runtimeMock.state.promptCalls.push(input);
115129 if (runtimeMock.state.promptAsyncError) {
116130 throw runtimeMock.state.promptAsyncError;
117131 }
......@@ -165,7 +179,24 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory
165179 listBindings: () => Effect.succeed([]),
166180});
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(
169200 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
170201 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
171202 Layer.provideMerge(
......@@ -196,7 +227,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
196227 const adapter = yield* OpenCodeAdapter;
197228
198229 const session = yield* adapter.startSession({
199 provider: "opencode",
230 provider: ProviderDriverKind.make("opencode"),
200231 threadId: asThreadId("thread-opencode"),
201232 runtimeMode: "full-access",
202233 });
......@@ -215,7 +246,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
215246 Effect.gen(function* () {
216247 const adapter = yield* OpenCodeAdapter;
217248 yield* adapter.startSession({
218 provider: "opencode",
249 provider: ProviderDriverKind.make("opencode"),
219250 threadId: asThreadId("thread-opencode"),
220251 runtimeMode: "full-access",
221252 });
......@@ -230,16 +261,42 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
230261 }),
231262 );
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
233290 it.effect("clears session state even when cleanup finalizers throw", () =>
234291 Effect.gen(function* () {
235292 const adapter = yield* OpenCodeAdapter;
236293 yield* adapter.startSession({
237 provider: "opencode",
294 provider: ProviderDriverKind.make("opencode"),
238295 threadId: asThreadId("thread-stop-all-a"),
239296 runtimeMode: "full-access",
240297 });
241298 yield* adapter.startSession({
242 provider: "opencode",
299 provider: ProviderDriverKind.make("opencode"),
243300 threadId: asThreadId("thread-stop-all-b"),
244301 runtimeMode: "full-access",
245302 });
......@@ -261,11 +318,44 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
261318 }),
262319 );
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
264354 it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () =>
265355 Effect.gen(function* () {
266356 const adapter = yield* OpenCodeAdapter;
267357 yield* adapter.startSession({
268 provider: "opencode",
358 provider: ProviderDriverKind.make("opencode"),
269359 threadId: asThreadId("thread-send-turn-failure"),
270360 runtimeMode: "full-access",
271361 });
......@@ -276,7 +366,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
276366 threadId: asThreadId("thread-send-turn-failure"),
277367 input: "Fix it",
278368 modelSelection: {
279 provider: "opencode",
369 instanceId: ProviderInstanceId.make("opencode"),
280370 model: "openai/gpt-5",
281371 },
282372 })
......@@ -299,12 +389,158 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
299389 }),
300390 );
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
302538 it.effect("reverts the full thread when rollback removes every assistant turn", () =>
303539 Effect.gen(function* () {
304540 const adapter = yield* OpenCodeAdapter;
305541 const threadId = asThreadId("thread-rollback-all");
306542 yield* adapter.startSession({
307 provider: "opencode",
543 provider: ProviderDriverKind.make("opencode"),
308544 threadId,
309545 runtimeMode: "full-access",
310546 });
......@@ -396,9 +632,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
396632 close: () => Effect.void,
397633 };
398634
399 const adapterLayer = makeOpenCodeAdapterLive({
400 nativeEventLogger,
401 }).pipe(
635 const adapterLayer = Layer.effect(
636 OpenCodeAdapter,
637 Effect.gen(function* () {
638 return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
639 nativeEventLogger,
640 });
641 }),
642 ).pipe(
402643 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
403644 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
404645 Layer.provideMerge(
......@@ -419,7 +660,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
419660 const session = yield* Effect.gen(function* () {
420661 const adapter = yield* OpenCodeAdapter;
421662 const started = yield* adapter.startSession({
422 provider: "opencode",
663 provider: ProviderDriverKind.make("opencode"),
423664 threadId: asThreadId("thread-native-log"),
424665 runtimeMode: "full-access",
425666 });
......@@ -475,9 +716,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
475716 close: () => Effect.void,
476717 };
477718
478 const adapterLayer = makeOpenCodeAdapterLive({
479 nativeEventLogger,
480 }).pipe(
719 const adapterLayer = Layer.effect(
720 OpenCodeAdapter,
721 makeOpenCodeAdapter(openCodeAdapterTestSettings, {
722 nativeEventLogger,
723 }),
724 ).pipe(
481725 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
482726 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
483727 Layer.provideMerge(
......@@ -504,7 +748,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
504748 const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () {
505749 const adapter = yield* OpenCodeAdapter;
506750 yield* adapter.startSession({
507 provider: "opencode",
751 provider: ProviderDriverKind.make("opencode"),
508752 threadId: asThreadId("thread-native-log-failure"),
509753 runtimeMode: "full-access",
510754 });
apps/server/src/provider/Layers/OpenCodeAdapter.ts+907-863
......@@ -1,7 +1,8 @@
1import { randomUUID } from "node:crypto";
2
31import {
42 EventId,
3 type OpenCodeSettings,
4 ProviderDriverKind,
5 ProviderInstanceId,
56 type ProviderRuntimeEvent,
67 type ProviderSession,
78 RuntimeItemId,
......@@ -11,13 +12,12 @@ import {
1112 TurnId,
1213 type UserInputQuestion,
1314} 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";
1516import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
1617import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1718
1819import { resolveAttachmentPath } from "../../attachmentStore.ts";
1920import { ServerConfig } from "../../config.ts";
20import { ServerSettingsService } from "../../serverSettings.ts";
2121import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
2222import {
2323 ProviderAdapterProcessError,
......@@ -26,7 +26,7 @@ import {
2626 ProviderAdapterSessionNotFoundError,
2727 ProviderAdapterValidationError,
2828} from "../Errors.ts";
29import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
29import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
3030import {
3131 buildOpenCodePermissionRules,
3232 OpenCodeRuntime,
......@@ -41,7 +41,7 @@ import {
4141 type OpenCodeServerConnection,
4242} from "../opencodeRuntime.ts";
4343
44const PROVIDER = "opencode" as const;
44const PROVIDER = ProviderDriverKind.make("opencode");
4545
4646interface OpenCodeTurnSnapshot {
4747 readonly id: TurnId;
......@@ -89,6 +89,8 @@ interface OpenCodeSessionContext {
8989}
9090
9191export interface OpenCodeAdapterLiveOptions {
92 readonly instanceId?: ProviderInstanceId;
93 readonly environment?: NodeJS.ProcessEnv;
9294 readonly nativeEventLogPath?: string;
9395 readonly nativeEventLogger?: EventNdjsonLogger;
9496}
......@@ -125,35 +127,38 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc
125127 cause,
126128 });
127129
128function buildEventBase(input: {
130const buildEventBase = (input: {
129131 readonly threadId: ThreadId;
130132 readonly turnId?: TurnId | undefined;
131133 readonly itemId?: string | undefined;
132134 readonly requestId?: string | undefined;
133135 readonly createdAt?: string | undefined;
134136 readonly raw?: unknown;
135}): Pick<
136 ProviderRuntimeEvent,
137 "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw"
138> {
139 return {
140 eventId: EventId.make(randomUUID()),
141 provider: PROVIDER,
142 threadId: input.threadId,
143 createdAt: input.createdAt ?? nowIso(),
144 ...(input.turnId ? { turnId: input.turnId } : {}),
145 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
146 ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}),
147 ...(input.raw !== undefined
148 ? {
149 raw: {
150 source: "opencode.sdk.event",
151 payload: input.raw,
152 },
153 }
154 : {}),
155 };
156}
137}): Effect.Effect<
138 Pick<
139 ProviderRuntimeEvent,
140 "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw"
141 >
142> =>
143 Random.nextUUIDv4.pipe(
144 Effect.map((uuid) => ({
145 eventId: EventId.make(uuid),
146 provider: PROVIDER,
147 threadId: input.threadId,
148 createdAt: input.createdAt ?? nowIso(),
149 ...(input.turnId ? { turnId: input.turnId } : {}),
150 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
151 ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}),
152 ...(input.raw !== undefined
153 ? {
154 raw: {
155 source: "opencode.sdk.event",
156 payload: input.raw,
157 },
158 }
159 : {}),
160 })),
161 );
157162
158163function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
159164 const normalized = toolName.toLowerCase();
......@@ -245,13 +250,19 @@ function ensureSessionContext(
245250): OpenCodeSessionContext {
246251 const session = sessions.get(threadId);
247252 if (!session) {
248 throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId });
253 throw new ProviderAdapterSessionNotFoundError({
254 provider: PROVIDER,
255 threadId,
256 });
249257 }
250258 // `ensureSessionContext` is a sync gate used from both sync helpers and
251259 // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell —
252260 // no fiber suspension required, which keeps this callable everywhere.
253261 if (Ref.getUnsafe(session.stopped)) {
254 throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId });
262 throw new ProviderAdapterSessionClosedError({
263 provider: PROVIDER,
264 threadId,
265 });
255266 }
256267 return session;
257268}
......@@ -419,7 +430,7 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
419430) {
420431 // Race-safe one-shot: first caller flips the flag, everyone else no-ops.
421432 if (yield* Ref.getAndSet(context.stopped, true)) {
422 return;
433 return false;
423434 }
424435
425436 // Best-effort remote abort. The scope close below tears down the local
......@@ -433,957 +444,990 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
433444 // runs each finalizer we registered — the `AbortController.abort()` call,
434445 // the child-process termination, etc.
435446 yield* Scope.close(context.sessionScope, Exit.void);
447 return true;
436448});
437449
438export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
439 return Layer.effect(
440 OpenCodeAdapter,
441 Effect.gen(function* () {
442 const serverConfig = yield* ServerConfig;
443 const serverSettings = yield* ServerSettingsService;
444 const openCodeRuntime = yield* OpenCodeRuntime;
445 const nativeEventLogger =
446 options?.nativeEventLogger ??
447 (options?.nativeEventLogPath !== undefined
448 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
449 stream: "native",
450 })
451 : undefined);
452 // Only close loggers we created. If the caller passed one in via
453 // `options.nativeEventLogger`, they own its lifecycle.
454 const managedNativeEventLogger =
455 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
456 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
457 const sessions = new Map<ThreadId, OpenCodeSessionContext>();
458
459 // Layer-level finalizer: when the adapter layer shuts down, stop every
460 // session. Each session's `Scope.close` tears down its spawned OpenCode
461 // server (via the `ChildProcessSpawner` finalizer installed in
462 // `startOpenCodeServerProcess`) and interrupts the forked event/exit
463 // fibers. Consumers that can't reason about Effect scopes therefore
464 // cannot leak OpenCode child processes by forgetting to call `stopAll`.
465 yield* Effect.addFinalizer(() =>
466 Effect.gen(function* () {
467 const contexts = [...sessions.values()];
468 sessions.clear();
469 // `ignoreCause` swallows both typed failures (none here) and defects
470 // from throwing scope finalizers so a sibling's death can't interrupt
471 // the remaining cleanups.
472 yield* Effect.forEach(
473 contexts,
474 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
475 { concurrency: "unbounded", discard: true },
476 );
477 // Close the logger AFTER session teardown so any final lifecycle
478 // events emitted during shutdown still get written. `close` flushes
479 // the `Logger.batched` window and closes each per-thread
480 // `RotatingFileSink` handle owned by the logger's internal scope.
481 if (managedNativeEventLogger !== undefined) {
482 yield* managedNativeEventLogger.close();
483 }
484 }),
485 );
486
487 const emit = (event: ProviderRuntimeEvent) =>
488 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
489 const writeNativeEvent = (
490 threadId: ThreadId,
491 event: {
492 readonly observedAt: string;
493 readonly event: Record<string, unknown>;
450export function makeOpenCodeAdapter(
451 openCodeSettings: OpenCodeSettings,
452 options?: OpenCodeAdapterLiveOptions,
453) {
454 return Effect.gen(function* () {
455 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode");
456 const serverConfig = yield* ServerConfig;
457 const openCodeRuntime = yield* OpenCodeRuntime;
458 const nativeEventLogger =
459 options?.nativeEventLogger ??
460 (options?.nativeEventLogPath !== undefined
461 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
462 stream: "native",
463 })
464 : undefined);
465 // Only close loggers we created. If the caller passed one in via
466 // `options.nativeEventLogger`, they own its lifecycle.
467 const managedNativeEventLogger =
468 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
469 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
470 const sessions = new Map<ThreadId, OpenCodeSessionContext>();
471
472 // Layer-level finalizer: when the adapter layer shuts down, stop every
473 // session. Each session's `Scope.close` tears down its spawned OpenCode
474 // server (via the `ChildProcessSpawner` finalizer installed in
475 // `startOpenCodeServerProcess`) and interrupts the forked event/exit
476 // fibers. Consumers that can't reason about Effect scopes therefore
477 // cannot leak OpenCode child processes by forgetting to call `stopAll`.
478 yield* Effect.addFinalizer(() =>
479 Effect.gen(function* () {
480 const contexts = [...sessions.values()];
481 sessions.clear();
482 // `ignoreCause` swallows both typed failures (none here) and defects
483 // from throwing scope finalizers so a sibling's death can't interrupt
484 // the remaining cleanups.
485 yield* Effect.forEach(
486 contexts,
487 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
488 { concurrency: "unbounded", discard: true },
489 );
490 // Close the logger AFTER session teardown so any final lifecycle
491 // events emitted during shutdown still get written. `close` flushes
492 // the `Logger.batched` window and closes each per-thread
493 // `RotatingFileSink` handle owned by the logger's internal scope.
494 if (managedNativeEventLogger !== undefined) {
495 yield* managedNativeEventLogger.close();
496 }
497 }).pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))),
498 );
499
500 const emit = (event: ProviderRuntimeEvent) =>
501 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
502 const writeNativeEvent = (
503 threadId: ThreadId,
504 event: {
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",
494543 },
495 ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void);
496 const writeNativeEventBestEffort = (
497 threadId: ThreadId,
498 event: {
499 readonly observedAt: string;
500 readonly event: Record<string, unknown>;
544 }).pipe(Effect.ignore);
545 yield* emit({
546 ...(yield* buildEventBase({
547 threadId: context.session.threadId,
548 turnId,
549 })),
550 type: "session.exited",
551 payload: {
552 reason: message,
553 recoverable: false,
554 exitKind: "error",
501555 },
502 ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void));
503
504 const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* (
505 context: OpenCodeSessionContext,
506 message: string,
507 ) {
508 // Atomic one-shot: two fibers can race here (the event-pump on stream
509 // failure and the server-exit watcher). `getAndSet` flips the flag in
510 // a single step so the loser observes `true` and returns; a plain
511 // `Ref.get` would let both racers slip past and emit duplicates.
512 if (yield* Ref.getAndSet(context.stopped, true)) {
513 return;
514 }
515 const turnId = context.activeTurnId;
516 sessions.delete(context.session.threadId);
517 // Emit lifecycle events BEFORE tearing down the scope. Both call sites
518 // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`;
519 // closing that scope triggers the fiber-interrupt finalizer, so any
520 // subsequent yield point would unwind and silently drop these emits.
556 }).pipe(Effect.ignore);
557 // Inline the teardown that `stopOpenCodeContext` would do; we can't
558 // delegate to it because our `getAndSet` above already flipped the
559 // one-shot guard, so the call would no-op.
560 yield* runOpenCodeSdk("session.abort", () =>
561 context.client.session.abort({ sessionID: context.openCodeSessionId }),
562 ).pipe(Effect.ignore({ log: true }));
563 yield* Scope.close(context.sessionScope, Exit.void);
564 });
565
566 /** Emit content.delta and item.completed events for an assistant text part. */
567 const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* (
568 context: OpenCodeSessionContext,
569 part: Part,
570 turnId: TurnId | undefined,
571 raw: unknown,
572 ) {
573 const text = textFromPart(part);
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) {
521589 yield* emit({
522 ...buildEventBase({ threadId: context.session.threadId, turnId }),
523 type: "runtime.error",
590 ...(yield* buildEventBase({
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",
524601 payload: {
525 message,
526 class: "transport_error",
602 streamKind: resolveTextStreamKind(part),
603 delta: deltaToEmit,
527604 },
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);
529614 yield* emit({
530 ...buildEventBase({ threadId: context.session.threadId, turnId }),
531 type: "session.exited",
615 ...(yield* buildEventBase({
616 threadId: context.session.threadId,
617 turnId,
618 itemId: part.id,
619 createdAt: isoFromEpochMs(part.time.end),
620 raw,
621 })),
622 type: "item.completed",
532623 payload: {
533 reason: message,
534 recoverable: false,
535 exitKind: "error",
624 itemType: "assistant_message",
625 status: "completed",
626 title: "Assistant message",
627 ...(latestText.length > 0 ? { detail: latestText } : {}),
536628 },
537 }).pipe(Effect.ignore);
538 // Inline the teardown that `stopOpenCodeContext` would do; we can't
539 // delegate to it because our `getAndSet` above already flipped the
540 // one-shot guard, so the call would no-op.
541 yield* runOpenCodeSdk("session.abort", () =>
542 context.client.session.abort({ sessionID: context.openCodeSessionId }),
543 ).pipe(Effect.ignore({ log: true }));
544 yield* Scope.close(context.sessionScope, Exit.void);
629 });
630 }
631 });
632
633 const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* (
634 context: OpenCodeSessionContext,
635 event: OpenCodeSubscribedEvent,
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 },
545654 });
546655
547 /** Emit content.delta and item.completed events for an assistant text part. */
548 const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* (
549 context: OpenCodeSessionContext,
550 part: Part,
551 turnId: TurnId | undefined,
552 raw: unknown,
553 ) {
554 const text = textFromPart(part);
555 if (text === undefined) {
556 return;
656 switch (event.type) {
657 case "message.updated": {
658 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
659 if (event.properties.info.role === "assistant") {
660 for (const part of context.partById.values()) {
661 if (part.messageID !== event.properties.info.id) {
662 continue;
663 }
664 yield* emitAssistantTextDelta(context, part, turnId, event);
665 }
666 }
667 break;
557668 }
558 const previousText = context.emittedTextByPartId.get(part.id);
559 const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text);
560 context.emittedTextByPartId.set(part.id, latestText);
561 if (latestText !== text) {
562 context.partById.set(
563 part.id,
564 (part.type === "text" || part.type === "reasoning"
565 ? { ...part, text: latestText }
566 : part) satisfies Part,
567 );
669
670 case "message.removed": {
671 context.messageRoleById.delete(event.properties.messageID);
672 break;
568673 }
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 }
570704 yield* emit({
571 ...buildEventBase({
705 ...(yield* buildEventBase({
572706 threadId: context.session.threadId,
573707 turnId,
574 itemId: part.id,
575 createdAt:
576 part.type === "text" || part.type === "reasoning"
577 ? isoFromEpochMs(part.time?.start)
578 : undefined,
579 raw,
580 }),
708 itemId: event.properties.partID,
709 raw: event,
710 })),
581711 type: "content.delta",
582712 payload: {
583 streamKind: resolveTextStreamKind(part),
713 streamKind,
584714 delta: deltaToEmit,
585715 },
586716 });
717 break;
587718 }
588719
589 if (
590 part.type === "text" &&
591 part.time?.end !== undefined &&
592 !context.completedAssistantPartIds.has(part.id)
593 ) {
594 context.completedAssistantPartIds.add(part.id);
720 case "message.part.updated": {
721 const part = event.properties.part;
722 context.partById.set(part.id, part);
723 const messageRole = messageRoleForPart(context, part);
724
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);
595772 yield* emit({
596 ...buildEventBase({
773 ...(yield* buildEventBase({
597774 threadId: context.session.threadId,
598775 turnId,
599 itemId: part.id,
600 createdAt: isoFromEpochMs(part.time.end),
601 raw,
602 }),
603 type: "item.completed",
776 requestId: event.properties.id,
777 raw: event,
778 })),
779 type: "request.opened",
604780 payload: {
605 itemType: "assistant_message",
606 status: "completed",
607 title: "Assistant message",
608 ...(latestText.length > 0 ? { detail: latestText } : {}),
781 requestType: mapPermissionToRequestType(event.properties.permission),
782 detail:
783 event.properties.patterns.length > 0
784 ? event.properties.patterns.join("\n")
785 : event.properties.permission,
786 args: event.properties.metadata,
609787 },
610788 });
789 break;
611790 }
612 });
613791
614 const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* (
615 context: OpenCodeSessionContext,
616 event: OpenCodeSubscribedEvent,
617 ) {
618 const payloadSessionId =
619 "properties" in event
620 ? (event.properties as { sessionID?: unknown }).sessionID
621 : undefined;
622 if (payloadSessionId !== context.openCodeSessionId) {
623 return;
792 case "permission.replied": {
793 context.pendingPermissions.delete(event.properties.requestID);
794 yield* emit({
795 ...(yield* buildEventBase({
796 threadId: context.session.threadId,
797 turnId,
798 requestId: event.properties.requestID,
799 raw: event,
800 })),
801 type: "request.resolved",
802 payload: {
803 requestType: "unknown",
804 decision: mapPermissionDecision(event.properties.reply),
805 },
806 });
807 break;
624808 }
625809
626 const turnId = context.activeTurnId;
627 yield* writeNativeEventBestEffort(context.session.threadId, {
628 observedAt: nowIso(),
629 event: {
630 provider: PROVIDER,
631 threadId: context.session.threadId,
632 providerThreadId: context.openCodeSessionId,
633 type: event.type,
634 ...(turnId ? { turnId } : {}),
635 payload: event,
636 },
637 });
810 case "question.asked": {
811 context.pendingQuestions.set(event.properties.id, event.properties);
812 yield* emit({
813 ...(yield* buildEventBase({
814 threadId: context.session.threadId,
815 turnId,
816 requestId: event.properties.id,
817 raw: event,
818 })),
819 type: "user-input.requested",
820 payload: {
821 questions: normalizeQuestionRequest(event.properties),
822 },
823 });
824 break;
825 }
638826
639 switch (event.type) {
640 case "message.updated": {
641 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
642 if (event.properties.info.role === "assistant") {
643 for (const part of context.partById.values()) {
644 if (part.messageID !== event.properties.info.id) {
645 continue;
646 }
647 yield* emitAssistantTextDelta(context, part, turnId, event);
648 }
649 }
650 break;
651 }
827 case "question.replied": {
828 const request = context.pendingQuestions.get(event.properties.requestID);
829 context.pendingQuestions.delete(event.properties.requestID);
830 const answers = Object.fromEntries(
831 (request?.questions ?? []).map((question, index) => [
832 openCodeQuestionId(index, question),
833 event.properties.answers[index]?.join(", ") ?? "",
834 ]),
835 );
836 yield* emit({
837 ...(yield* buildEventBase({
838 threadId: context.session.threadId,
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": {
654 context.messageRoleById.delete(event.properties.messageID);
655 break;
656 }
849 case "question.rejected": {
850 context.pendingQuestions.delete(event.properties.requestID);
851 yield* emit({
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": {
659 const existingPart = context.partById.get(event.properties.partID);
660 if (!existingPart) {
661 break;
662 }
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 },
864 case "session.status": {
865 if (event.properties.status.type === "busy") {
866 updateProviderSession(context, {
867 status: "running",
868 activeTurnId: turnId,
699869 });
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;
751870 }
752871
753 case "permission.asked": {
754 context.pendingPermissions.set(event.properties.id, event.properties);
872 if (event.properties.status.type === "retry") {
755873 yield* emit({
756 ...buildEventBase({
874 ...(yield* buildEventBase({
757875 threadId: context.session.threadId,
758876 turnId,
759 requestId: event.properties.id,
760877 raw: event,
761 }),
762 type: "request.opened",
878 })),
879 type: "runtime.warning",
763880 payload: {
764 requestType: mapPermissionToRequestType(event.properties.permission),
765 detail:
766 event.properties.patterns.length > 0
767 ? event.properties.patterns.join("\n")
768 : event.properties.permission,
769 args: event.properties.metadata,
881 message: event.properties.status.message,
882 detail: event.properties.status,
770883 },
771884 });
772885 break;
773886 }
774887
775 case "permission.replied": {
776 context.pendingPermissions.delete(event.properties.requestID);
888 if (event.properties.status.type === "idle" && turnId) {
889 context.activeTurnId = undefined;
890 updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true });
777891 yield* emit({
778 ...buildEventBase({
892 ...(yield* buildEventBase({
779893 threadId: context.session.threadId,
780894 turnId,
781 requestId: event.properties.requestID,
782895 raw: event,
783 }),
784 type: "request.resolved",
896 })),
897 type: "turn.completed",
785898 payload: {
786 requestType: "unknown",
787 decision: mapPermissionDecision(event.properties.reply),
899 state: "completed",
788900 },
789901 });
790 break;
791902 }
903 break;
904 }
792905
793 case "question.asked": {
794 context.pendingQuestions.set(event.properties.id, event.properties);
906 case "session.error": {
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) {
795919 yield* emit({
796 ...buildEventBase({
920 ...(yield* buildEventBase({
797921 threadId: context.session.threadId,
798 turnId,
799 requestId: event.properties.id,
922 turnId: activeTurnId,
800923 raw: event,
801 }),
802 type: "user-input.requested",
924 })),
925 type: "turn.completed",
803926 payload: {
804 questions: normalizeQuestionRequest(event.properties),
927 state: "failed",
928 errorMessage: message,
805929 },
806930 });
807 break;
808931 }
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": {
811 const request = context.pendingQuestions.get(event.properties.requestID);
812 context.pendingQuestions.delete(event.properties.requestID);
813 const answers = Object.fromEntries(
814 (request?.questions ?? []).map((question, index) => [
815 openCodeQuestionId(index, question),
816 event.properties.answers[index]?.join(", ") ?? "",
817 ]),
818 );
819 yield* emit({
820 ...buildEventBase({
821 threadId: context.session.threadId,
822 turnId,
823 requestId: event.properties.requestID,
824 raw: event,
825 }),
826 type: "user-input.resolved",
827 payload: { answers },
828 });
829 break;
830 }
947 default:
948 break;
949 }
950 });
951
952 const startEventPump = Effect.fn("startEventPump")(function* (context: OpenCodeSessionContext) {
953 // One AbortController per session scope. The finalizer fires when
954 // the scope closes (explicit stop, unexpected exit, or layer
955 // shutdown) and cancels the in-flight `event.subscribe` fetch so
956 // the async iterable unwinds cleanly.
957 const eventsAbortController = new AbortController();
958 yield* Scope.addFinalizer(
959 context.sessionScope,
960 Effect.sync(() => eventsAbortController.abort()),
961 );
831962
832 case "question.rejected": {
833 context.pendingQuestions.delete(event.properties.requestID);
834 yield* emit({
835 ...buildEventBase({
836 threadId: context.session.threadId,
837 turnId,
838 requestId: event.properties.requestID,
839 raw: event,
963 // Fibers forked into `context.sessionScope` are interrupted
964 // automatically when the scope closes — no bookkeeping required.
965 yield* Effect.flatMap(
966 runOpenCodeSdk("event.subscribe", () =>
967 context.client.event.subscribe(undefined, {
968 signal: eventsAbortController.signal,
969 }),
970 ),
971 (subscription) =>
972 Stream.fromAsyncIterable(
973 subscription.stream,
974 (cause) =>
975 new OpenCodeRuntimeError({
976 operation: "event.subscribe",
977 detail: openCodeRuntimeErrorDetail(cause),
978 cause,
840979 }),
841 type: "user-input.resolved",
842 payload: { answers: {} },
843 });
844 break;
845 }
846
847 case "session.status": {
848 if (event.properties.status.type === "busy") {
849 updateProviderSession(context, { status: "running", activeTurnId: turnId });
980 ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))),
981 ).pipe(
982 Effect.exit,
983 Effect.flatMap((exit) =>
984 Effect.gen(function* () {
985 // Expected paths: caller aborted the fetch or the session
986 // has already been marked stopped. Treat as a clean exit.
987 if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) {
988 return;
850989 }
851
852 if (event.properties.status.type === "retry") {
853 yield* emit({
854 ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }),
855 type: "runtime.warning",
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 });
990 if (Exit.isFailure(exit)) {
991 yield* emitUnexpectedExit(
992 context,
993 openCodeRuntimeErrorDetail(Cause.squash(exit.cause)),
994 );
903995 }
904 yield* emit({
905 ...buildEventBase({ threadId: context.session.threadId, raw: event }),
906 type: "runtime.error",
907 payload: {
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 );
996 }),
997 ),
998 Effect.forkIn(context.sessionScope),
999 );
9331000
934 // Fibers forked into `context.sessionScope` are interrupted
935 // automatically when the scope closes — no bookkeeping required.
936 yield* Effect.flatMap(
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) =>
1001 if (!context.server.external && context.server.exitCode !== null) {
1002 yield* context.server.exitCode.pipe(
1003 Effect.flatMap((code) =>
9551004 Effect.gen(function* () {
956 // Expected paths: caller aborted the fetch or the session
957 // has already been marked stopped. Treat as a clean exit.
958 if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) {
1005 if (yield* Ref.get(context.stopped)) {
9591006 return;
9601007 }
961 if (Exit.isFailure(exit)) {
962 yield* emitUnexpectedExit(
963 context,
964 openCodeRuntimeErrorDetail(Cause.squash(exit.cause)),
965 );
966 }
1008 yield* emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`);
9671009 }),
9681010 ),
9691011 Effect.forkIn(context.sessionScope),
9701012 );
971
972 if (!context.server.external && context.server.exitCode !== null) {
973 yield* context.server.exitCode.pipe(
974 Effect.flatMap((code) =>
975 Effect.gen(function* () {
976 if (yield* Ref.get(context.stopped)) {
977 return;
978 }
979 yield* emitUnexpectedExit(
980 context,
981 `OpenCode server exited unexpectedly (${code}).`,
982 );
983 }),
984 ),
985 Effect.forkIn(context.sessionScope),
986 );
1013 }
1014 });
1015
1016 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
1017 function* (input) {
1018 const binaryPath = openCodeSettings.binaryPath;
1019 const serverUrl = openCodeSettings.serverUrl;
1020 const serverPassword = openCodeSettings.serverPassword;
1021 const directory = input.cwd ?? serverConfig.cwd;
1022 const existing = sessions.get(input.threadId);
1023 if (existing) {
1024 yield* stopOpenCodeContext(existing);
1025 sessions.delete(input.threadId);
9871026 }
988 });
9891027
990 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
991 function* (input) {
992 const settings = yield* serverSettings.getSettings.pipe(
993 Effect.mapError(
994 (cause) =>
995 new ProviderAdapterProcessError({
996 provider: PROVIDER,
997 threadId: input.threadId,
998 detail: "Failed to read OpenCode settings.",
999 cause,
1028 const started = yield* Effect.gen(function* () {
1029 const sessionScope = yield* Scope.make();
1030 const startedExit = yield* Effect.exit(
1031 Effect.gen(function* () {
1032 // The runtime binds the server's lifetime to the Scope.Scope
1033 // we provide below — closing `sessionScope` kills the child
1034 // process automatically. No manual `server.close()` needed.
1035 const server = yield* openCodeRuntime.connectToOpenCodeServer({
1036 binaryPath,
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),
10001049 }),
1001 ),
1002 );
1003 const binaryPath = settings.providers.opencode.binaryPath;
1004 const serverUrl = settings.providers.opencode.serverUrl;
1005 const serverPassword = settings.providers.opencode.serverPassword;
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 } : {}),
1050 );
1051 if (!openCodeSession.data) {
1052 return yield* new OpenCodeRuntimeError({
1053 operation: "session.create",
1054 detail: "OpenCode session.create returned no session payload.",
10281055 });
1029 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
1030 client.session.create({
1031 title: `T3 Code ${input.threadId}`,
1032 permission: buildOpenCodePermissionRules(input.runtimeMode),
1033 }),
1034 );
1035 if (!openCodeSession.data) {
1036 return yield* new OpenCodeRuntimeError({
1037 operation: "session.create",
1038 detail: "OpenCode session.create returned no session payload.",
1039 });
1040 }
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;
1056 }
1057 return {
1058 sessionScope,
1059 server,
1060 client,
1061 openCodeSession: openCodeSession.data,
1062 };
1063 }).pipe(Effect.provideService(Scope.Scope, sessionScope)),
1064 );
1065 if (Exit.isFailure(startedExit)) {
1066 yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore);
1067 return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause));
10621068 }
1069 return startedExit.value;
1070 });
10631071
1064 const createdAt = nowIso();
1065 const session: ProviderSession = {
1066 provider: PROVIDER,
1067 status: "ready",
1068 runtimeMode: input.runtimeMode,
1069 cwd: directory,
1070 ...(input.modelSelection ? { model: input.modelSelection.model } : {}),
1071 threadId: input.threadId,
1072 createdAt,
1073 updatedAt: createdAt,
1074 };
1075
1076 const context: OpenCodeSessionContext = {
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 });
1072 // Guard against a concurrent startSession call that may have raced
1073 // and already inserted a session while we were awaiting async work.
1074 const raceWinner = sessions.get(input.threadId);
1075 if (raceWinner) {
1076 // Another call won the race – clean up the session we just created
1077 // (including the remote SDK session) and return the existing one.
1078 yield* runOpenCodeSdk("session.abort", () =>
1079 started.client.session.abort({
1080 sessionID: started.openCodeSession.id,
1081 }),
1082 ).pipe(Effect.ignore);
1083 yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore);
1084 return raceWinner.session;
11321085 }
11331086
1134 const text = input.input?.trim();
1135 const fileParts = toOpenCodeFileParts({
1136 attachments: input.attachments,
1137 resolveAttachmentPath: (attachment) =>
1138 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
1139 });
1140 if ((!text || text.length === 0) && fileParts.length === 0) {
1141 return yield* new ProviderAdapterValidationError({
1142 provider: PROVIDER,
1143 operation: "sendTurn",
1144 issue: "OpenCode turns require text input or at least one attachment.",
1145 });
1146 }
1087 const createdAt = nowIso();
1088 const session: ProviderSession = {
1089 provider: PROVIDER,
1090 providerInstanceId: boundInstanceId,
1091 status: "ready",
1092 runtimeMode: input.runtimeMode,
1093 cwd: directory,
1094 ...(input.modelSelection ? { model: input.modelSelection.model } : {}),
1095 threadId: input.threadId,
1096 createdAt,
1097 updatedAt: createdAt,
1098 };
11471099
1148 const agent =
1149 input.modelSelection?.provider === PROVIDER
1150 ? getModelSelectionStringOptionValue(input.modelSelection, "agent")
1151 : undefined;
1152 const variant =
1153 input.modelSelection?.provider === PROVIDER
1154 ? getModelSelectionStringOptionValue(input.modelSelection, "variant")
1155 : undefined;
1156
1157 context.activeTurnId = turnId;
1158 context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
1159 context.activeVariant = variant;
1160 updateProviderSession(
1161 context,
1162 {
1163 status: "running",
1164 activeTurnId: turnId,
1165 model: modelSelection?.model ?? context.session.model,
1166 },
1167 { clearLastError: true },
1168 );
1100 const context: OpenCodeSessionContext = {
1101 session,
1102 client: started.client,
1103 server: started.server,
1104 directory,
1105 openCodeSessionId: started.openCodeSession.id,
1106 pendingPermissions: new Map(),
1107 pendingQuestions: new Map(),
1108 partById: new Map(),
1109 emittedTextByPartId: new Map(),
1110 messageRoleById: new Map(),
1111 completedAssistantPartIds: new Set(),
1112 turns: [],
1113 activeTurnId: undefined,
1114 activeAgent: undefined,
1115 activeVariant: undefined,
1116 stopped: yield* Ref.make(false),
1117 sessionScope: started.sessionScope,
1118 };
1119 sessions.set(input.threadId, context);
1120 yield* startEventPump(context);
11691121
11701122 yield* emit({
1171 ...buildEventBase({ threadId: input.threadId, turnId }),
1172 type: "turn.started",
1123 ...(yield* buildEventBase({ threadId: input.threadId })),
1124 type: "session.started",
11731125 payload: {
1174 model: modelSelection?.model ?? context.session.model,
1175 ...(variant ? { effort: variant } : {}),
1126 message: "OpenCode session started",
1127 },
1128 });
1129 yield* emit({
1130 ...(yield* buildEventBase({ threadId: input.threadId })),
1131 type: "thread.started",
1132 payload: {
1133 providerThreadId: started.openCodeSession.id,
11761134 },
11771135 });
11781136
1179 yield* runOpenCodeSdk("session.promptAsync", () =>
1180 context.client.session.promptAsync({
1181 sessionID: context.openCodeSessionId,
1182 model: parsedModel,
1183 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
1184 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
1185 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
1137 return session;
1138 },
1139 );
1140
1141 const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
1142 const context = ensureSessionContext(sessions, input.threadId);
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,
11861172 }),
1187 ).pipe(
1188 Effect.mapError(toRequestError),
1189 // On failure: clear active-turn state, flip the session back to ready
1190 // with lastError set, emit turn.aborted, then let the typed error
1191 // propagate. We don't need to rebuild the error here — `toRequestError`
1192 // already produced the right shape.
1193 Effect.tapError((requestError) =>
1194 Effect.gen(function* () {
1195 context.activeTurnId = undefined;
1196 context.activeAgent = undefined;
1197 context.activeVariant = undefined;
1198 updateProviderSession(
1199 context,
1200 {
1201 status: "ready",
1202 model: modelSelection?.model ?? context.session.model,
1203 lastError: requestError.detail,
1204 },
1205 { clearActiveTurnId: true },
1206 );
1207 yield* emit({
1208 ...buildEventBase({ threadId: input.threadId, turnId }),
1209 type: "turn.aborted",
1210 payload: {
1211 reason: requestError.detail,
1212 },
1213 });
1214 }),
1215 ),
1216 );
1173 });
1174 if ((!text || text.length === 0) && fileParts.length === 0) {
1175 return yield* new ProviderAdapterValidationError({
1176 provider: PROVIDER,
1177 operation: "sendTurn",
1178 issue: "OpenCode turns require text input or at least one attachment.",
1179 });
1180 }
1181
1182 const agent = getModelSelectionStringOptionValue(modelSelection, "agent");
1183 const variant = getModelSelectionStringOptionValue(modelSelection, "variant");
1184
1185 context.activeTurnId = turnId;
1186 context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
1187 context.activeVariant = variant;
1188 updateProviderSession(
1189 context,
1190 {
1191 status: "running",
1192 activeTurnId: turnId,
1193 model: modelSelection?.model ?? context.session.model,
1194 },
1195 { clearLastError: true },
1196 );
12171197
1218 return {
1219 threadId: input.threadId,
1220 turnId,
1221 };
1198 yield* emit({
1199 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
1200 type: "turn.started",
1201 payload: {
1202 model: modelSelection?.model ?? context.session.model,
1203 ...(variant ? { effort: variant } : {}),
1204 },
12221205 });
12231206
1224 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
1225 function* (threadId, turnId) {
1226 const context = ensureSessionContext(sessions, threadId);
1227 yield* runOpenCodeSdk("session.abort", () =>
1228 context.client.session.abort({ sessionID: context.openCodeSessionId }),
1229 ).pipe(Effect.mapError(toRequestError));
1230 if (turnId ?? context.activeTurnId) {
1207 yield* runOpenCodeSdk("session.promptAsync", () =>
1208 context.client.session.promptAsync({
1209 sessionID: context.openCodeSessionId,
1210 model: parsedModel,
1211 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
1212 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
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 );
12311235 yield* emit({
1232 ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }),
1236 ...(yield* buildEventBase({
1237 threadId: input.threadId,
1238 turnId,
1239 })),
12331240 type: "turn.aborted",
12341241 payload: {
1235 reason: "Interrupted by user.",
1242 reason: requestError.detail,
12361243 },
12371244 });
1238 }
1239 },
1245 }),
1246 ),
12401247 );
12411248
1242 const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn(
1243 "respondToRequest",
1244 )(function* (threadId, requestId, decision) {
1245 const context = ensureSessionContext(sessions, threadId);
1246 if (!context.pendingPermissions.has(requestId)) {
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 });
1249 return {
1250 threadId: input.threadId,
1251 turnId,
1252 };
1253 });
12611254
1262 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(
1263 "respondToUserInput",
1264 )(function* (threadId, requestId, answers) {
1255 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
1256 function* (threadId, turnId) {
12651257 const context = ensureSessionContext(sessions, threadId);
1266 const request = context.pendingQuestions.get(requestId);
1267 if (!request) {
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 }),
1258 yield* runOpenCodeSdk("session.abort", () =>
1259 context.client.session.abort({ sessionID: context.openCodeSessionId }),
12801260 ).pipe(Effect.mapError(toRequestError));
1281 });
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);
1261 if (turnId ?? context.activeTurnId) {
12881262 yield* emit({
1289 ...buildEventBase({ threadId }),
1290 type: "session.exited",
1263 ...(yield* buildEventBase({
1264 threadId,
1265 turnId: turnId ?? context.activeTurnId,
1266 })),
1267 type: "turn.aborted",
12911268 payload: {
1292 reason: "Session stopped.",
1293 recoverable: false,
1294 exitKind: "graceful",
1269 reason: "Interrupted by user.",
12951270 },
12961271 });
1297 },
1298 );
1272 }
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"] = () =>
1301 Effect.sync(() => [...sessions.values()].map((context) => context.session));
1309 yield* runOpenCodeSdk("question.reply", () =>
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) =>
1304 Effect.sync(() => sessions.has(threadId));
1343 const listSessions: OpenCodeAdapterShape["listSessions"] = () =>
1344 Effect.sync(() => [...sessions.values()].map((context) => context.session));
13051345
1306 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
1307 function* (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));
1346 const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) =>
1347 Effect.sync(() => sessions.has(threadId));
13121348
1313 const turns = (messages.data ?? [])
1314 .filter((entry) => entry.info.role === "assistant")
1315 .map((entry) => ({
1316 id: TurnId.make(entry.info.id),
1317 items: [entry.info, ...entry.parts],
1318 }));
1349 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
1350 function* (threadId) {
1351 const context = ensureSessionContext(sessions, threadId);
1352 const messages = yield* runOpenCodeSdk("session.messages", () =>
1353 context.client.session.messages({
1354 sessionID: context.openCodeSessionId,
1355 }),
1356 ).pipe(Effect.mapError(toRequestError));
13191357
1320 return {
1321 threadId,
1322 turns,
1323 };
1324 },
1325 );
1358 const turns = (messages.data ?? [])
1359 .filter((entry) => entry.info.role === "assistant")
1360 .map((entry) => ({
1361 id: TurnId.make(entry.info.id),
1362 items: [entry.info, ...entry.parts],
1363 }));
13261364
1327 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
1328 function* (threadId, numTurns) {
1329 const context = ensureSessionContext(sessions, threadId);
1330 const messages = yield* runOpenCodeSdk("session.messages", () =>
1331 context.client.session.messages({ sessionID: context.openCodeSessionId }),
1332 ).pipe(Effect.mapError(toRequestError));
1365 return {
1366 threadId,
1367 turns,
1368 };
1369 },
1370 );
13331371
1334 const assistantMessages = (messages.data ?? []).filter(
1335 (entry) => entry.info.role === "assistant",
1336 );
1337 const targetIndex = assistantMessages.length - numTurns - 1;
1338 const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null;
1339 yield* runOpenCodeSdk("session.revert", () =>
1340 context.client.session.revert({
1341 sessionID: context.openCodeSessionId,
1342 ...(target ? { messageID: target.info.id } : {}),
1343 }),
1344 ).pipe(Effect.mapError(toRequestError));
1372 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
1373 function* (threadId, numTurns) {
1374 const context = ensureSessionContext(sessions, threadId);
1375 const messages = yield* runOpenCodeSdk("session.messages", () =>
1376 context.client.session.messages({
1377 sessionID: context.openCodeSessionId,
1378 }),
1379 ).pipe(Effect.mapError(toRequestError));
13451380
1346 return yield* readThread(threadId);
1347 },
1348 );
1381 const assistantMessages = (messages.data ?? []).filter(
1382 (entry) => entry.info.role === "assistant",
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"] = () =>
1351 Effect.gen(function* () {
1352 const contexts = [...sessions.values()];
1353 sessions.clear();
1354 // `stopOpenCodeContext` is typed as never-failing — SDK aborts are
1355 // already `Effect.ignore`'d inside it. `ignoreCause` here also
1356 // swallows defects from throwing finalizers so one bad close can't
1357 // interrupt the sibling fibers. Same pattern as the layer finalizer.
1358 yield* Effect.forEach(
1359 contexts,
1360 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
1361 { concurrency: "unbounded", discard: true },
1362 );
1363 });
1393 return yield* readThread(threadId);
1394 },
1395 );
1396
1397 const stopAll: OpenCodeAdapterShape["stopAll"] = () =>
1398 Effect.gen(function* () {
1399 const contexts = [...sessions.values()];
1400 sessions.clear();
1401 // `stopOpenCodeContext` is typed as never-failing — SDK aborts are
1402 // already `Effect.ignore`'d inside it. `ignoreCause` here also
1403 // swallows defects from throwing finalizers so one bad close can't
1404 // interrupt the sibling fibers. Same pattern as the layer finalizer.
1405 yield* Effect.forEach(
1406 contexts,
1407 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
1408 { concurrency: "unbounded", discard: true },
1409 );
1410 });
13641411
1365 return {
1366 provider: PROVIDER,
1367 capabilities: {
1368 sessionModelSwitch: "in-session",
1369 },
1370 startSession,
1371 sendTurn,
1372 interruptTurn,
1373 respondToRequest,
1374 respondToUserInput,
1375 stopSession,
1376 listSessions,
1377 hasSession,
1378 readThread,
1379 rollbackThread,
1380 stopAll,
1381 get streamEvents() {
1382 return Stream.fromQueue(runtimeEvents);
1383 },
1384 } satisfies OpenCodeAdapterShape;
1385 }),
1386 );
1412 return {
1413 provider: PROVIDER,
1414 capabilities: {
1415 sessionModelSwitch: "in-session",
1416 },
1417 startSession,
1418 sendTurn,
1419 interruptTurn,
1420 respondToRequest,
1421 respondToUserInput,
1422 stopSession,
1423 listSessions,
1424 hasSession,
1425 readThread,
1426 rollbackThread,
1427 stopAll,
1428 get streamEvents() {
1429 return Stream.fromQueue(runtimeEvents);
1430 },
1431 } satisfies OpenCodeAdapterShape;
1432 });
13871433}
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";
22
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { it } from "@effect/vitest";
5import { Effect, Layer } from "effect";
5import { Effect, Layer, Schema } from "effect";
66import { beforeEach } from "vitest";
77
8import { OpenCodeSettings } from "@t3tools/contracts";
89import { ServerConfig } from "../../config.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
10import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1110import {
1211 OpenCodeRuntime,
1312 OpenCodeRuntimeError,
1413 type OpenCodeRuntimeShape,
1514} from "../opencodeRuntime.ts";
16import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
15import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts";
1716import type { OpenCodeInventory } from "../opencodeRuntime.ts";
1817
1918const 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
2129const runtimeMock = {
2230 state: {
2331 runVersionError: null as Error | null,
......@@ -90,20 +98,26 @@ beforeEach(() => {
9098 runtimeMock.reset();
9199});
92100
93const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) =>
94 OpenCodeProviderLive.pipe(
95 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
96 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
97 Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)),
98 Layer.provideMerge(NodeServices.layer),
99 );
100
101it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
101const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
102 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
103 Layer.provideMerge(NodeServices.layer),
104);
105
106const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
107 Schema.decodeSync(OpenCodeSettings)({
108 enabled: true,
109 binaryPath: "opencode",
110 serverUrl: "",
111 serverPassword: "",
112 customModels: [],
113 ...overrides,
114 });
115
116it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
102117 it.effect("shows a codex-style missing binary message", () =>
103118 Effect.gen(function* () {
104119 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
105 const provider = yield* OpenCodeProvider;
106 const snapshot = yield* provider.refresh;
120 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
107121
108122 assert.equal(snapshot.status, "error");
109123 assert.equal(snapshot.installed, false);
......@@ -114,8 +128,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
114128 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
115129 Effect.gen(function* () {
116130 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
117 const provider = yield* OpenCodeProvider;
118 const snapshot = yield* provider.refresh;
131 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
119132
120133 assert.equal(snapshot.status, "error");
121134 assert.equal(snapshot.installed, true);
......@@ -123,33 +136,6 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
123136 }),
124137 );
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
153139 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
154140 Effect.gen(function* () {
155141 runtimeMock.state.inventory = {
......@@ -182,8 +168,7 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
182168 ],
183169 };
184170
185 const provider = yield* OpenCodeProvider;
186 const snapshot = yield* provider.refresh;
171 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
187172 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
188173
189174 assert.ok(model);
......@@ -191,40 +176,41 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
191176 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
192177 );
193178 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 );
195183 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
196184 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
197185 );
198186 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 );
200191 }),
201192 );
202193
203194 it.effect("closes the local OpenCode server scope after provider refresh", () =>
204195 Effect.gen(function* () {
205 const provider = yield* OpenCodeProvider;
206 yield* provider.refresh;
196 yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
207197
208198 assert.equal(runtimeMock.state.closeCalls, 1);
209199 }),
210200 );
211201});
212202
213it.layer(
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) => {
203it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => {
223204 it.effect("surfaces a friendly auth error for configured servers", () =>
224205 Effect.gen(function* () {
225206 runtimeMock.state.inventoryError = new Error("401 Unauthorized");
226 const provider = yield* OpenCodeProvider;
227 const snapshot = yield* provider.refresh;
207 const snapshot = yield* checkOpenCodeProviderStatus(
208 makeOpenCodeSettings({
209 serverUrl: "http://127.0.0.1:9999",
210 serverPassword: "secret-password",
211 }),
212 process.cwd(),
213 );
228214
229215 assert.equal(snapshot.status, "error");
230216 assert.equal(snapshot.installed, true);
......@@ -240,8 +226,13 @@ it.layer(
240226 runtimeMock.state.inventoryError = new Error(
241227 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
242228 );
243 const provider = yield* OpenCodeProvider;
244 const snapshot = yield* provider.refresh;
229 const snapshot = yield* checkOpenCodeProviderStatus(
230 makeOpenCodeSettings({
231 serverUrl: "http://127.0.0.1:9999",
232 serverPassword: "secret-password",
233 }),
234 process.cwd(),
235 );
245236
246237 assert.equal(snapshot.status, "error");
247238 assert.equal(snapshot.installed, true);
apps/server/src/provider/Layers/OpenCodeProvider.ts+172-206
......@@ -1,24 +1,21 @@
1import type {
2 ModelCapabilities,
3 OpenCodeSettings,
4 ServerProvider,
5 ServerProviderModel,
1import {
2 ProviderDriverKind,
3 type ModelCapabilities,
4 type OpenCodeSettings,
5 type ServerProviderModel,
66} from "@t3tools/contracts";
7import { Cause, Data, Effect, Equal, Layer, Stream } from "effect";
7import { Cause, Data, Effect } from "effect";
88
99import { createModelCapabilities } from "@t3tools/shared/model";
1010
11import { ServerConfig } from "../../config.ts";
12import { ServerSettingsService } from "../../serverSettings.ts";
13import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
1411import {
1512 buildServerProvider,
1613 nonEmptyTrimmed,
1714 parseGenericCliVersion,
1815 providerModelsFromSettings,
16 type ServerProviderDraft,
1917} from "../providerSnapshot.ts";
2018import { compareCliVersions } from "../cliVersion.ts";
21import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
2219import {
2320 OpenCodeRuntime,
2421 openCodeRuntimeErrorDetail,
......@@ -26,7 +23,7 @@ import {
2623} from "../opencodeRuntime.ts";
2724import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2825
29const PROVIDER = "opencode" as const;
26const PROVIDER = ProviderDriverKind.make("opencode");
3027const OPENCODE_PRESENTATION = {
3128 displayName: "OpenCode",
3229 showInteractionModeToggle: false,
......@@ -251,7 +248,9 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
251248 return models.toSorted((left, right) => left.name.localeCompare(right.name));
252249}
253250
254const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => {
251export const makePendingOpenCodeProvider = (
252 openCodeSettings: OpenCodeSettings,
253): ServerProviderDraft => {
255254 const checkedAt = new Date().toISOString();
256255 const models = providerModelsFromSettings(
257256 [],
......@@ -262,7 +261,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
262261
263262 if (!openCodeSettings.enabled) {
264263 return buildServerProvider({
265 provider: PROVIDER,
266264 presentation: OPENCODE_PRESENTATION,
267265 enabled: false,
268266 checkedAt,
......@@ -281,7 +279,6 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
281279 }
282280
283281 return buildServerProvider({
284 provider: PROVIDER,
285282 presentation: OPENCODE_PRESENTATION,
286283 enabled: true,
287284 checkedAt,
......@@ -296,210 +293,179 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
296293 });
297294};
298295
299export const OpenCodeProviderLive = Layer.effect(
300 OpenCodeProvider,
301 Effect.gen(function* () {
302 const serverSettings = yield* ServerSettingsService;
303 const serverConfig = yield* ServerConfig;
304 const openCodeRuntime = yield* OpenCodeRuntime;
305
306 const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (input: {
307 readonly settings: OpenCodeSettings;
308 readonly cwd: string;
309 }): Effect.fn.Return<ServerProvider, never> {
310 const checkedAt = new Date().toISOString();
311 const customModels = input.settings.customModels;
312 const isExternalServer = input.settings.serverUrl.trim().length > 0;
313
314 const fallback = (cause: unknown, version: string | null = null) => {
315 const failure = formatOpenCodeProbeError({
316 cause,
317 isExternalServer,
318 serverUrl: input.settings.serverUrl,
319 });
320 return buildServerProvider({
321 provider: PROVIDER,
322 presentation: OPENCODE_PRESENTATION,
323 enabled: input.settings.enabled,
324 checkedAt,
325 models: providerModelsFromSettings(
326 [],
327 PROVIDER,
328 customModels,
329 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
330 ),
331 probe: {
332 installed: failure.installed,
333 version,
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 }
296export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
297 openCodeSettings: OpenCodeSettings,
298 cwd: string,
299 environment: NodeJS.ProcessEnv = process.env,
300): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> {
301 const openCodeRuntime = yield* OpenCodeRuntime;
302 const checkedAt = new Date().toISOString();
303 const customModels = openCodeSettings.customModels;
304 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
305
306 const fallback = (cause: unknown, version: string | null = null) => {
307 const failure = formatOpenCodeProbeError({
308 cause,
309 isExternalServer,
310 serverUrl: openCodeSettings.serverUrl,
311 });
312 return buildServerProvider({
313 presentation: OPENCODE_PRESENTATION,
314 enabled: openCodeSettings.enabled,
315 checkedAt,
316 models: providerModelsFromSettings(
317 [],
318 PROVIDER,
319 customModels,
320 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
321 ),
322 probe: {
323 installed: failure.installed,
324 version,
325 status: "error",
326 auth: { status: "unknown" },
327 message: failure.message,
328 },
329 });
330 };
452331
453 const models = providerModelsFromSettings(
454 flattenOpenCodeModels(inventoryExit.value),
332 if (!openCodeSettings.enabled) {
333 return buildServerProvider({
334 presentation: OPENCODE_PRESENTATION,
335 enabled: false,
336 checkedAt,
337 models: providerModelsFromSettings(
338 [],
455339 PROVIDER,
456340 customModels,
457341 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,
458381 );
459 const connectedCount = inventoryExit.value.providerList.connected.length;
382 }
383 if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
460384 return buildServerProvider({
461 provider: PROVIDER,
462385 presentation: OPENCODE_PRESENTATION,
463 enabled: true,
386 enabled: openCodeSettings.enabled,
464387 checkedAt,
465 models,
388 models: providerModelsFromSettings(
389 [],
390 PROVIDER,
391 customModels,
392 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
393 ),
466394 probe: {
467395 installed: true,
468396 version,
469 status: connectedCount > 0 ? "ready" : "warning",
470 auth: {
471 status: connectedCount > 0 ? "authenticated" : "unknown",
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.",
397 status: "error",
398 auth: { status: "unknown" },
399 message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,
480400 },
481401 });
482 });
402 }
403 }
483404
484 const getProviderSettings = serverSettings.getSettings.pipe(
485 Effect.map((settings) => settings.providers.opencode),
486 );
405 const inventoryExit = yield* Effect.exit(
406 Effect.scoped(
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>({
489 getSettings: getProviderSettings.pipe(Effect.orDie),
490 streamSettings: serverSettings.streamChanges.pipe(
491 Stream.map((settings) => settings.providers.opencode),
492 ),
493 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
494 initialSnapshot: makePendingOpenCodeProvider,
495 checkProvider: getProviderSettings.pipe(
496 Effect.flatMap((settings) =>
497 checkOpenCodeProviderStatus({
498 settings,
499 cwd: serverConfig.cwd,
500 }),
501 ),
502 ),
503 });
504 }),
505);
443 const models = providerModelsFromSettings(
444 flattenOpenCodeModels(inventoryExit.value),
445 PROVIDER,
446 customModels,
447 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
448 );
449 const connectedCount = inventoryExit.value.providerList.connected.length;
450 return buildServerProvider({
451 presentation: OPENCODE_PRESENTATION,
452 enabled: true,
453 checkedAt,
454 models,
455 probe: {
456 installed: true,
457 version,
458 status: connectedCount > 0 ? "ready" : "warning",
459 auth: {
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 @@
1import type { ProviderKind } from "@t3tools/contracts";
1import {
2 defaultInstanceIdForDriver,
3 ProviderDriverKind,
4 type ServerProvider,
5} from "@t3tools/contracts";
26import { 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";
810import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
9import { CodexAdapter } from "../Services/CodexAdapter.ts";
1011import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11import { CursorAdapter } from "../Services/CursorAdapter.ts";
1212import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
13import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
1413import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1514import { 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";
1618import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
17import { ProviderUnsupportedError } from "../Errors.ts";
1819import * 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
2026const fakeCodexAdapter: CodexAdapterShape = {
21 provider: "codex",
27 provider: CODEX_DRIVER,
2228 capabilities: { sessionModelSwitch: "in-session" },
2329 startSession: vi.fn(),
2430 sendTurn: vi.fn(),
......@@ -35,7 +41,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
3541};
3642
3743const fakeClaudeAdapter: ClaudeAdapterShape = {
38 provider: "claudeAgent",
44 provider: CLAUDE_AGENT_DRIVER,
3945 capabilities: { sessionModelSwitch: "in-session" },
4046 startSession: vi.fn(),
4147 sendTurn: vi.fn(),
......@@ -52,7 +58,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {
5258};
5359
5460const fakeOpenCodeAdapter: OpenCodeAdapterShape = {
55 provider: "opencode",
61 provider: OPENCODE_DRIVER,
5662 capabilities: { sessionModelSwitch: "in-session" },
5763 startSession: vi.fn(),
5864 sendTurn: vi.fn(),
......@@ -69,7 +75,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = {
6975};
7076
7177const fakeCursorAdapter: CursorAdapterShape = {
72 provider: "cursor",
78 provider: CURSOR_DRIVER,
7379 capabilities: { sessionModelSwitch: "in-session" },
7480 startSession: vi.fn(),
7581 sendTurn: vi.fn(),
......@@ -85,44 +91,94 @@ const fakeCursorAdapter: CursorAdapterShape = {
8591 streamEvents: Stream.empty,
8692};
8793
88const layer = it.layer(
89 Layer.mergeAll(
90 Layer.provide(
91 ProviderAdapterRegistryLive,
92 Layer.mergeAll(
93 Layer.succeed(CodexAdapter, fakeCodexAdapter),
94 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
95 Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter),
96 Layer.succeed(CursorAdapter, fakeCursorAdapter),
97 ),
98 ),
99 NodeServices.layer,
100 ),
94// ProviderAdapterRegistryLive is now a facade over ProviderInstanceRegistry —
95// it walks `listInstances` once at boot and surfaces the default-instance
96// adapter keyed by its driver kind. To test the facade we supply four fake
97// instances whose `instanceId === defaultInstanceIdForDriver(driverKind)` so
98// they pass the default-instance filter.
99const makeFakeInstance = (
100 driverKindString: "codex" | "claudeAgent" | "cursor" | "opencode",
101 adapter: ProviderInstance["adapter"],
102): ProviderInstance => {
103 const driverKind = ProviderDriverKind.make(driverKindString);
104 return {
105 instanceId: defaultInstanceIdForDriver(driverKind),
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,
101144);
102145
103layer("ProviderAdapterRegistryLive", (it) => {
104 it.effect("resolves a registered provider adapter", () =>
146it.layer(layer)("ProviderAdapterRegistryLive", (it) => {
147 it("resolves adapters and routing metadata from provider instances", () =>
105148 Effect.gen(function* () {
106149 const registry = yield* ProviderAdapterRegistry;
107 const codex = yield* registry.getByProvider("codex");
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);
150 const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER);
115151
116 const providers = yield* registry.listProviders();
117 assert.deepEqual(providers, ["codex", "claudeAgent", "opencode", "cursor"]);
118 }),
119 );
152 const adapter = yield* registry.getByInstance(claudeInstanceId);
153 assert.strictEqual(adapter, fakeClaudeAdapter);
120154
121 it.effect("fails with ProviderUnsupportedError for unknown providers", () =>
122 Effect.gen(function* () {
123 const registry = yield* ProviderAdapterRegistry;
124 const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result);
125 assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" }));
126 }),
127 );
155 const info = yield* registry.getInstanceInfo(claudeInstanceId);
156 assert.deepStrictEqual(info, {
157 instanceId: claudeInstanceId,
158 driverKind: CLAUDE_AGENT_DRIVER,
159 displayName: undefined,
160 accentColor: undefined,
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 }));
128184});
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+93-40
......@@ -1,59 +1,101 @@
11/**
2 * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer.
2 * ProviderAdapterRegistryLive — facade over `ProviderInstanceRegistry`.
33 *
4 * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services.
5 * This layer only performs adapter lookup; it does not route session-scoped
6 * calls or own provider lifecycle workflows.
4 * `ProviderAdapterRegistry` historically mapped one `ProviderDriverKind` to one
5 * adapter via the four `<X>AdapterLive` singleton Layers. The per-instance
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.
715 *
816 * @module ProviderAdapterRegistryLive
917 */
18import {
19 defaultInstanceIdForDriver,
20 ProviderInstanceId,
21 type ProviderDriverKind,
22} from "@t3tools/contracts";
1023import { Effect, Layer } from "effect";
1124
12import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";
13import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
25import { ProviderUnsupportedError } from "../Errors.ts";
26import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
1427import {
1528 ProviderAdapterRegistry,
1629 type ProviderAdapterRegistryShape,
1730} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
19import { CodexAdapter } from "../Services/CodexAdapter.ts";
20import { CursorAdapter } from "../Services/CursorAdapter.ts";
21import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
22import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts";
23
24export interface ProviderAdapterRegistryLiveOptions {
25 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
26}
27
28const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* (
29 options?: ProviderAdapterRegistryLiveOptions,
30) {
31 const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter);
32 const adapters =
33 options?.adapters !== undefined
34 ? options.adapters
35 : createBuiltInAdapterList({
36 codex: yield* CodexAdapter,
37 claudeAgent: yield* ClaudeAdapter,
38 opencode: yield* OpenCodeAdapter,
39 ...(cursorAdapterOption._tag === "Some" ? { cursor: cursorAdapterOption.value } : {}),
40 });
41 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
42
43 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
44 const adapter = byProvider.get(provider);
45 if (!adapter) {
46 return Effect.fail(new ProviderUnsupportedError({ provider }));
47 }
48 return Effect.succeed(adapter);
49 };
31
32const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () {
33 const registry = yield* ProviderInstanceRegistry;
34
35 const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) =>
36 registry.getInstance(instanceId).pipe(
37 Effect.flatMap((instance) =>
38 instance === undefined
39 ? Effect.fail(
40 new ProviderUnsupportedError({
41 provider: instanceId,
42 }),
43 )
44 : Effect.succeed(instance.adapter),
45 ),
46 );
47
48 const getInstanceInfo: ProviderAdapterRegistryShape["getInstanceInfo"] = (instanceId) =>
49 registry.getInstance(instanceId).pipe(
50 Effect.flatMap((instance) =>
51 instance === undefined
52 ? Effect.fail(
53 new ProviderUnsupportedError({
54 provider: instanceId,
55 }),
56 )
57 : Effect.succeed({
58 instanceId: instance.instanceId,
59 driverKind: instance.driverKind,
60 displayName: instance.displayName,
61 accentColor: instance.accentColor,
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
5173 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
5490 return {
55 getByProvider,
91 getByInstance,
92 getInstanceInfo,
93 listInstances,
5694 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,
5799 } satisfies ProviderAdapterRegistryShape;
58100});
59101
......@@ -61,3 +103,14 @@ export const ProviderAdapterRegistryLive = Layer.effect(
61103 ProviderAdapterRegistry,
62104 makeProviderAdapterRegistry(),
63105);
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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";
2import { describe, it, assert, live } from "@effect/vitest";
33import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect";
44import * as CodexErrors from "effect-codex-app-server/errors";
55import {
6 ClaudeSettings,
7 CodexSettings,
68 DEFAULT_SERVER_SETTINGS,
9 ProviderDriverKind,
10 ProviderInstanceId,
711 ServerSettings,
812 type ServerProvider,
13 type ServerProviderSlashCommand,
914 type ServerSettings as ContractServerSettings,
1015} from "@t3tools/contracts";
1116import * as PlatformError from "effect/PlatformError";
......@@ -14,7 +19,10 @@ import { deepMerge } from "@t3tools/shared/Struct";
1419import { createModelCapabilities } from "@t3tools/shared/model";
1520
1621import { 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";
1826import {
1927 haveProvidersChanged,
2028 mergeProviderSnapshot,
......@@ -22,8 +30,16 @@ import {
2230} from "./ProviderRegistry.ts";
2331import { ServerConfig } from "../../config.ts";
2432import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
33import type { ProviderInstance } from "../ProviderDriver.ts";
34import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
2535import { 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
2743process.env.T3CODE_CURSOR_ENABLED = "1";
2844
2945// ── Test helpers ────────────────────────────────────────────────────
......@@ -54,6 +70,27 @@ function booleanDescriptor(id: string, label: string) {
5470 };
5571}
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
5794function mockHandle(result: { stdout: string; stderr: string; code: number }) {
5895 return ChildProcessSpawner.makeHandle({
5996 pid: ChildProcessSpawner.ProcessId(1),
......@@ -71,7 +108,11 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) {
71108}
72109
73110function 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 },
75116) {
76117 return Layer.succeed(
77118 ChildProcessSpawner.ChildProcessSpawner,
......@@ -82,6 +123,33 @@ function mockSpawnerLayer(
82123 );
83124}
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
85153function mockCommandSpawnerLayer(
86154 handler: (
87155 command: string,
......@@ -91,7 +159,10 @@ function mockCommandSpawnerLayer(
91159 return Layer.succeed(
92160 ChildProcessSpawner.ChildProcessSpawner,
93161 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 };
95166 return Effect.succeed(mockHandle(handler(cmd.command, cmd.args)));
96167 }),
97168 );
......@@ -181,7 +252,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
181252 describe("checkCodexProviderStatus", () => {
182253 it.effect("uses the app-server account and model list for provider status", () =>
183254 Effect.gen(function* () {
184 const status = yield* checkCodexProviderStatus(() =>
255 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
185256 Effect.succeed(
186257 makeCodexProbeSnapshot({
187258 skills: [
......@@ -196,14 +267,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
196267 }),
197268 ),
198269 );
199
200 assert.strictEqual(status.provider, "codex");
201270 assert.strictEqual(status.status, "ready");
202271 assert.strictEqual(status.installed, true);
203272 assert.strictEqual(status.version, "1.0.0");
204273 assert.strictEqual(status.auth.status, "authenticated");
205274 assert.strictEqual(status.auth.type, "chatgpt");
206275 assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription");
276 assert.strictEqual(status.auth.email, "test@example.com");
207277 assert.deepStrictEqual(status.models, [
208278 {
209279 slug: "gpt-live-codex",
......@@ -226,7 +296,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
226296
227297 it.effect("returns unauthenticated when app-server requires OpenAI auth", () =>
228298 Effect.gen(function* () {
229 const status = yield* checkCodexProviderStatus(() =>
299 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
230300 Effect.succeed(
231301 makeCodexProbeSnapshot({
232302 account: {
......@@ -250,7 +320,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
250320 "returns ready with unknown auth when app-server does not require OpenAI auth",
251321 () =>
252322 Effect.gen(function* () {
253 const status = yield* checkCodexProviderStatus(() =>
323 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
254324 Effect.succeed(
255325 makeCodexProbeSnapshot({
256326 account: {
......@@ -268,7 +338,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
268338
269339 it.effect("returns an api key label for codex api key auth", () =>
270340 Effect.gen(function* () {
271 const status = yield* checkCodexProviderStatus(() =>
341 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
272342 Effect.succeed(
273343 makeCodexProbeSnapshot({
274344 account: {
......@@ -288,7 +358,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
288358
289359 it.effect("returns unavailable when codex is missing", () =>
290360 Effect.gen(function* () {
291 const status = yield* checkCodexProviderStatus(() =>
361 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
292362 Effect.fail(
293363 new CodexErrors.CodexAppServerSpawnError({
294364 command: "codex app-server",
......@@ -296,7 +366,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
296366 }),
297367 ),
298368 );
299 assert.strictEqual(status.provider, "codex");
300369 assert.strictEqual(status.status, "error");
301370 assert.strictEqual(status.installed, false);
302371 assert.strictEqual(status.auth.status, "unknown");
......@@ -312,7 +381,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
312381 it("treats equal provider snapshots as unchanged", () => {
313382 const providers = [
314383 {
315 provider: "codex",
384 instanceId: ProviderInstanceId.make("codex"),
385 driver: ProviderDriverKind.make("codex"),
316386 status: "ready",
317387 enabled: true,
318388 installed: true,
......@@ -324,7 +394,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
324394 skills: [],
325395 },
326396 {
327 provider: "claudeAgent",
397 instanceId: ProviderInstanceId.make("claudeAgent"),
398 driver: ProviderDriverKind.make("claudeAgent"),
328399 status: "warning",
329400 enabled: true,
330401 installed: true,
......@@ -342,7 +413,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
342413
343414 it("preserves previously discovered provider models when a refresh returns none", () => {
344415 const previousProvider = {
345 provider: "cursor",
416 instanceId: ProviderInstanceId.make("cursor"),
417 driver: ProviderDriverKind.make("cursor"),
346418 status: "ready",
347419 enabled: true,
348420 installed: true,
......@@ -381,7 +453,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
381453
382454 it("fills missing capabilities from the previous provider snapshot", () => {
383455 const previousProvider = {
384 provider: "cursor",
456 instanceId: ProviderInstanceId.make("cursor"),
457 driver: ProviderDriverKind.make("cursor"),
385458 status: "ready",
386459 enabled: true,
387460 installed: true,
......@@ -427,41 +500,345 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
427500 ]);
428501 });
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", () =>
431703 Effect.gen(function* () {
432 let spawnCount = 0;
704 const missingBinary = `t3code_codex_missing_${process.pid}_${Date.now()}`;
433705 const serverSettings = yield* makeMutableServerSettingsService(
434706 Schema.decodeSync(ServerSettings)(
435707 deepMerge(DEFAULT_SERVER_SETTINGS, {
436708 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.
437714 codex: { enabled: false },
715 claudeAgent: { enabled: false },
438716 cursor: { enabled: false },
717 opencode: { enabled: false },
439718 },
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"],
440738 }),
441739 ),
442740 );
443741 const scope = yield* Scope.make();
444742 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
445743 const providerRegistryLayer = ProviderRegistryLive.pipe(
744 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
446745 Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)),
447746 Layer.provideMerge(
448747 ServerConfig.layerTest(process.cwd(), {
449748 prefix: "t3-provider-registry-",
450749 }),
451750 ),
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)),
452830 Layer.provideMerge(
453 mockCommandSpawnerLayer((command, args) => {
454 spawnCount += 1;
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}`);
831 ServerConfig.layerTest(process.cwd(), {
832 prefix: "t3-provider-registry-",
463833 }),
464834 ),
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),
465842 );
466843 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
467844 Scope.provide(scope),
......@@ -469,24 +846,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
469846
470847 yield* Effect.gen(function* () {
471848 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.
473883 const refreshed = yield* Effect.gen(function* () {
474 for (let remainingAttempts = 50; remainingAttempts > 0; remainingAttempts -= 1) {
884 for (let attempts = 0; attempts < 60; attempts += 1) {
475885 const providers = yield* registry.getProviders;
476 const claudeProvider = providers.find(
477 (provider) => provider.provider === "claudeAgent",
478 );
479 if (claudeProvider?.status === "ready") {
886 const codex = providers.find((provider) => provider.instanceId === "codex");
887 if (codex !== undefined && codex.checkedAt !== initialCheckedAt) {
480888 return providers;
481889 }
482 yield* Effect.sleep("10 millis");
890 yield* Effect.sleep("50 millis");
483891 }
484892 return yield* registry.getProviders;
485893 });
486 assert.strictEqual(
487 refreshed.find((provider) => provider.provider === "claudeAgent")?.status,
488 "ready",
894
895 const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex");
896 assert.notStrictEqual(
897 reprobedCodex?.checkedAt,
898 initialCheckedAt,
899 "Expected a fresh probe after settings change, got the stale snapshot",
489900 );
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/);
490956 }).pipe(Effect.provide(runtimeServices));
491957 }),
492958 );
......@@ -513,12 +979,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
513979 const scope = yield* Scope.make();
514980 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
515981 const providerRegistryLayer = ProviderRegistryLive.pipe(
982 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
516983 Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)),
517984 Layer.provideMerge(
518985 ServerConfig.layerTest(process.cwd(), {
519986 prefix: "t3-provider-registry-",
520987 }),
521988 ),
989 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
990 Layer.provideMerge(OpenCodeRuntimeLive),
522991 Layer.provideMerge(
523992 mockCommandSpawnerLayer((command, args) => {
524993 if (command === "agent") {
......@@ -526,10 +995,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
526995 }
527996 const joined = args.join(" ");
528997 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 };
5301003 }
5311004 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 };
5331010 }
5341011 throw new Error(`Unexpected args: ${command} ${joined}`);
5351012 }),
......@@ -545,12 +1022,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
5451022 yield* Effect.gen(function* () {
5461023 const registry = yield* ProviderRegistry;
5471024 const providers = yield* registry.getProviders;
548 const cursorProvider = providers.find((provider) => provider.provider === "cursor");
549
550 assert.deepStrictEqual(
551 providers.map((provider) => provider.provider),
552 ["codex", "claudeAgent", "opencode", "cursor"],
1025 const cursorProvider = providers.find(
1026 (provider) => provider.instanceId === ProviderInstanceId.make("cursor"),
5531027 );
1028
1029 assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [
1030 "claudeAgent",
1031 "codex",
1032 "cursor",
1033 "opencode",
1034 ]);
5541035 assert.strictEqual(cursorProvider?.enabled, false);
5551036 assert.strictEqual(cursorProvider?.status, "disabled");
5561037 assert.strictEqual(
......@@ -564,20 +1045,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
5641045
5651046 it.effect("skips codex probes entirely when the provider is disabled", () =>
5661047 Effect.gen(function* () {
567 const serverSettingsLayer = ServerSettingsService.layerTest({
568 providers: {
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 ),
1048 const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe(
1049 Effect.provide(failingSpawnerLayer("spawn codex ENOENT")),
5791050 );
580 assert.strictEqual(status.provider, "codex");
5811051 assert.strictEqual(status.enabled, false);
5821052 assert.strictEqual(status.status, "disabled");
5831053 assert.strictEqual(status.installed, false);
......@@ -591,8 +1061,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
5911061 describe("checkClaudeProviderStatus", () => {
5921062 it.effect("returns ready when claude is installed and authenticated", () =>
5931063 Effect.gen(function* () {
594 const status = yield* checkClaudeProviderStatus();
595 assert.strictEqual(status.provider, "claudeAgent");
1064 const status = yield* checkClaudeProviderStatus(
1065 defaultClaudeSettings,
1066 claudeCapabilities(),
1067 );
5961068 assert.strictEqual(status.status, "ready");
5971069 assert.strictEqual(status.installed, true);
5981070 assert.strictEqual(status.auth.status, "authenticated");
......@@ -617,7 +1089,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
6171089 "includes Claude Opus 4.7 with xhigh as the default effort on supported versions",
6181090 () =>
6191091 Effect.gen(function* () {
620 const status = yield* checkClaudeProviderStatus();
1092 const status = yield* checkClaudeProviderStatus(
1093 defaultClaudeSettings,
1094 claudeCapabilities(),
1095 );
6211096 const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7");
6221097 if (!opus47) {
6231098 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()))(
6551130
6561131 it.effect("hides Claude Opus 4.7 on older Claude Code versions", () =>
6571132 Effect.gen(function* () {
658 const status = yield* checkClaudeProviderStatus();
1133 const status = yield* checkClaudeProviderStatus(
1134 defaultClaudeSettings,
1135 claudeCapabilities(),
1136 );
6591137 assert.strictEqual(
6601138 status.models.some((model) => model.slug === "claude-opus-4-7"),
6611139 false,
......@@ -683,8 +1161,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
6831161
6841162 it.effect("returns a display label for claude subscription types", () =>
6851163 Effect.gen(function* () {
686 const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan"));
687 assert.strictEqual(status.provider, "claudeAgent");
1164 const status = yield* checkClaudeProviderStatus(
1165 defaultClaudeSettings,
1166 claudeCapabilities({ subscriptionType: "maxplan" }),
1167 );
6881168 assert.strictEqual(status.status, "ready");
6891169 assert.strictEqual(status.auth.status, "authenticated");
6901170 assert.strictEqual(status.auth.type, "maxplan");
......@@ -706,18 +1186,120 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
7061186 ),
7071187 );
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
7091289 it.effect("includes probed claude slash commands in the provider snapshot", () =>
7101290 Effect.gen(function* () {
7111291 const status = yield* checkClaudeProviderStatus(
712 () => Effect.succeed("maxplan"),
713 () =>
714 Effect.succeed([
1292 defaultClaudeSettings,
1293 claudeCapabilities({
1294 subscriptionType: "maxplan",
1295 slashCommands: [
7151296 {
7161297 name: "review",
7171298 description: "Review a pull request",
7181299 input: { hint: "pr-or-branch" },
7191300 },
720 ]),
1301 ],
1302 }),
7211303 );
7221304
7231305 assert.deepStrictEqual(status.slashCommands, [
......@@ -747,9 +1329,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
7471329 it.effect("deduplicates probed claude slash commands by name", () =>
7481330 Effect.gen(function* () {
7491331 const status = yield* checkClaudeProviderStatus(
750 () => Effect.succeed("maxplan"),
751 () =>
752 Effect.succeed([
1332 defaultClaudeSettings,
1333 claudeCapabilities({
1334 subscriptionType: "maxplan",
1335 slashCommands: [
7531336 {
7541337 name: "ui",
7551338 description: "Explore and refine UI",
......@@ -758,7 +1341,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
7581341 name: "ui",
7591342 input: { hint: "component-or-screen" },
7601343 },
761 ]),
1344 ],
1345 }),
7621346 );
7631347
7641348 assert.deepStrictEqual(status.slashCommands, [
......@@ -787,8 +1371,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
7871371
7881372 it.effect("returns an api key label for claude api key auth", () =>
7891373 Effect.gen(function* () {
790 const status = yield* checkClaudeProviderStatus();
791 assert.strictEqual(status.provider, "claudeAgent");
1374 const status = yield* checkClaudeProviderStatus(
1375 defaultClaudeSettings,
1376 claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }),
1377 );
7921378 assert.strictEqual(status.status, "ready");
7931379 assert.strictEqual(status.auth.status, "authenticated");
7941380 assert.strictEqual(status.auth.type, "apiKey");
......@@ -812,8 +1398,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8121398
8131399 it.effect("returns unavailable when claude is missing", () =>
8141400 Effect.gen(function* () {
815 const status = yield* checkClaudeProviderStatus();
816 assert.strictEqual(status.provider, "claudeAgent");
1401 const status = yield* checkClaudeProviderStatus(
1402 defaultClaudeSettings,
1403 claudeCapabilities(),
1404 );
8171405 assert.strictEqual(status.status, "error");
8181406 assert.strictEqual(status.installed, false);
8191407 assert.strictEqual(status.auth.status, "unknown");
......@@ -826,8 +1414,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8261414
8271415 it.effect("returns error when version check fails with non-zero exit code", () =>
8281416 Effect.gen(function* () {
829 const status = yield* checkClaudeProviderStatus();
830 assert.strictEqual(status.provider, "claudeAgent");
1417 const status = yield* checkClaudeProviderStatus(
1418 defaultClaudeSettings,
1419 claudeCapabilities(),
1420 );
8311421 assert.strictEqual(status.status, "error");
8321422 assert.strictEqual(status.installed, true);
8331423 }).pipe(
......@@ -835,33 +1425,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8351425 mockSpawnerLayer((args) => {
8361426 const joined = args.join(" ");
8371427 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")
8621428 return {
863 stdout: '{"loggedIn":false}\n',
864 stderr: "",
1429 stdout: "",
1430 stderr: "Something went wrong",
8651431 code: 1,
8661432 };
8671433 throw new Error(`Unexpected args: ${joined}`);
......@@ -870,36 +1436,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8701436 ),
8711437 );
8721438
873 it.effect("returns unauthenticated when output includes 'not logged in'", () =>
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", () =>
1439 it.effect("returns warning when the Claude initialization result is unavailable", () =>
8941440 Effect.gen(function* () {
895 const status = yield* checkClaudeProviderStatus();
896 assert.strictEqual(status.provider, "claudeAgent");
1441 const status = yield* checkClaudeProviderStatus(
1442 defaultClaudeSettings,
1443 noClaudeCapabilities,
1444 );
8971445 assert.strictEqual(status.status, "warning");
8981446 assert.strictEqual(status.installed, true);
8991447 assert.strictEqual(status.auth.status, "unknown");
9001448 assert.strictEqual(
9011449 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.",
9031451 );
9041452 }).pipe(
9051453 Effect.provide(
......@@ -907,52 +1455,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
9071455 const joined = args.join(" ");
9081456 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
9091457 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 };
9111463 throw new Error(`Unexpected args: ${joined}`);
9121464 }),
9131465 ),
9141466 ),
9151467 );
9161468 });
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 });
9571469 },
9581470);
apps/server/src/provider/Layers/ProviderRegistry.ts+422-124
......@@ -1,39 +1,63 @@
11/**
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.
322 *
423 * @module ProviderRegistryLive
524 */
6import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
7import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";
25import {
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
934import { ServerConfig } from "../../config.ts";
10import { ClaudeProviderLive } from "./ClaudeProvider.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";
35import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
1836import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
19import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
2037import {
2138 hydrateCachedProvider,
22 PROVIDER_CACHE_IDS,
39 isCachedProviderCorrelated,
2340 orderProviderSnapshots,
2441 readProviderStatusCache,
2542 resolveProviderStatusCachePath,
2643 writeProviderStatusCache,
2744} from "../providerStatusCache.ts";
28import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts";
45import type { ProviderInstance } from "../ProviderDriver.ts";
2946import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
3047
3148const loadProviders = (
3249 providerSources: ReadonlyArray<ProviderSnapshotSource>,
3350): Effect.Effect<ReadonlyArray<ServerProvider>> =>
34 Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, {
35 concurrency: "unbounded",
36 });
51 Effect.forEach(
52 providerSources,
53 (providerSource) =>
54 providerSource.getSnapshot.pipe(
55 Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)),
56 ),
57 {
58 concurrency: "unbounded",
59 },
60 );
3761
3862const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
3963 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
......@@ -77,63 +101,123 @@ export const haveProvidersChanged = (
77101 nextProviders: ReadonlyArray<ServerProvider>,
78102): 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(
81148 ProviderRegistry,
82149 Effect.gen(function* () {
83 const codexProvider = yield* CodexProvider;
84 const claudeProvider = yield* ClaudeProvider;
85 const openCodeProvider = yield* OpenCodeProvider;
150 const instanceRegistry = yield* ProviderInstanceRegistry;
86151 const config = yield* ServerConfig;
87152 const fileSystem = yield* FileSystem.FileSystem;
88153 const path = yield* Path.Path;
89154
90 const cursorProvider = yield* CursorProvider;
91
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;
155 // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for
156 // coalesced updates across every instance.
99157 const changesPubSub = yield* Effect.acquireRelease(
100158 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
101159 PubSub.shutdown,
102160 );
103 const fallbackProviders = yield* loadProviders(providerSources);
104 const cachePathByProvider = new Map(
105 activeProviders.map(
106 (provider) =>
107 [
108 provider,
109 resolveProviderStatusCachePath({
110 cacheDir: config.providerStatusCacheDir,
111 provider,
112 }),
113 ] as const,
114 ),
115 );
116 const fallbackByProvider = new Map(
117 fallbackProviders.map((provider) => [provider.provider, provider] as const),
118 );
161
162 // Boot-only: hydrate `providersRef` from the on-disk per-instance
163 // cache so the UI has something to render during the first refresh.
164 // Instances added post-boot skip this path; their first entry in
165 // `providersRef` comes from the reactive `syncLiveSources` pass
166 // below.
167 const bootInstances = yield* instanceRegistry.listInstances;
168 const bootSources = bootInstances.map(buildSnapshotSource);
169 const fallbackProviders = yield* loadProviders(bootSources);
170 const fallbackByInstance = new Map<ProviderInstanceId, ServerProvider>();
171 for (let index = 0; index < fallbackProviders.length; index++) {
172 const provider = fallbackProviders[index];
173 const source = bootSources[index];
174 if (provider === undefined || source === undefined) {
175 continue;
176 }
177 fallbackByInstance.set(source.instanceId, provider);
178 }
119179
120180 const cachedProviders = yield* Effect.forEach(
121 activeProviders,
122 (provider) => {
123 const filePath = cachePathByProvider.get(provider)!;
124 const fallbackProvider = fallbackByProvider.get(provider)!;
125 return readProviderStatusCache(filePath).pipe(
126 Effect.provideService(FileSystem.FileSystem, fileSystem),
127 Effect.map((cachedProvider) =>
128 cachedProvider === undefined
129 ? undefined
130 : hydrateCachedProvider({
131 cachedProvider,
132 fallbackProvider,
133 }),
134 ),
135 );
136 },
181 bootSources,
182 (source) =>
183 Effect.gen(function* () {
184 // One cache file per configured instance. For the default
185 // instance of a built-in kind the path equals `<kind>.json` —
186 // identical to the legacy filename. We still require the cache
187 // payload to carry matching instance id + driver kind; old
188 // identity-less payloads are discarded and the awaited refresh
189 // below repopulates the cache.
190 const filePath = yield* resolveProviderStatusCachePath({
191 cacheDir: config.providerStatusCacheDir,
192 instanceId: source.instanceId,
193 }).pipe(Effect.provideService(Path.Path, path));
194 const fallbackProvider = fallbackByInstance.get(source.instanceId);
195 if (fallbackProvider === undefined) {
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 }),
137221 { concurrency: "unbounded" },
138222 ).pipe(
139223 Effect.map((providers) =>
......@@ -144,34 +228,64 @@ const ProviderRegistryLiveBase = Layer.effect(
144228 );
145229 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
147246 const persistProvider = (provider: ServerProvider) =>
148 writeProviderStatusCache({
149 filePath: cachePathByProvider.get(provider.provider)!,
150 provider,
151 }).pipe(
152 Effect.provideService(FileSystem.FileSystem, fileSystem),
153 Effect.provideService(Path.Path, path),
154 Effect.tapError(Effect.logError),
155 Effect.ignore,
156 );
247 Effect.gen(function* () {
248 // Persist every instance — the file name is the instance id, so
249 // multi-instance setups (e.g. `codex_personal`, `codex_work`) each
250 // get their own cache. We resolve the path fresh so snapshots
251 // produced by newly-added instances post-boot still land on disk
252 // without the aggregator holding a stale `cachePathByInstance`
253 // entry.
254 const key = snapshotInstanceKey(provider);
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
158267 const upsertProviders = Effect.fn("upsertProviders")(function* (
159268 nextProviders: ReadonlyArray<ServerProvider>,
160269 options?: {
161270 readonly publish?: boolean;
271 readonly persist?: boolean;
272 readonly replace?: boolean;
162273 },
163274 ) {
164275 const [previousProviders, providers] = yield* Ref.modify(
165276 providersRef,
166277 (previousProviders) => {
167278 const mergedProviders = new Map(
168 previousProviders.map((provider) => [provider.provider, provider] as const),
279 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
169280 );
170281
171282 for (const provider of nextProviders) {
283 const key = snapshotInstanceKey(provider);
172284 mergedProviders.set(
173 provider.provider,
174 mergeProviderSnapshot(mergedProviders.get(provider.provider), provider),
285 key,
286 options?.replace === true
287 ? provider
288 : mergeProviderSnapshot(mergedProviders.get(key), provider),
175289 );
176290 }
177291
......@@ -181,10 +295,12 @@ const ProviderRegistryLiveBase = Layer.effect(
181295 );
182296
183297 if (haveProvidersChanged(previousProviders, providers)) {
184 yield* Effect.forEach(nextProviders, persistProvider, {
185 concurrency: "unbounded",
186 discard: true,
187 });
298 if (options?.persist !== false) {
299 yield* Effect.forEach(nextProviders, persistProvider, {
300 concurrency: "unbounded",
301 discard: true,
302 });
303 }
188304 if (options?.publish !== false) {
189305 yield* PubSub.publish(changesPubSub, providers);
190306 }
......@@ -202,64 +318,246 @@ const ProviderRegistryLiveBase = Layer.effect(
202318 return yield* upsertProviders([provider], options);
203319 });
204320
205 const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) {
206 if (provider) {
207 const providerSource = providerSources.find((candidate) => candidate.provider === provider);
208 if (!providerSource) {
209 return yield* Ref.get(providersRef);
210 }
211 return yield* providerSource.refresh.pipe(
212 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
213 );
214 }
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)));
321 const refreshOneSource = Effect.fn("refreshOneSource")(function* (
322 providerSource: ProviderSnapshotSource,
323 ) {
324 return yield* providerSource.refresh.pipe(
325 Effect.flatMap((nextProvider) =>
326 correlateSnapshotWithSource(providerSource, nextProvider).pipe(
327 Effect.flatMap(syncProvider),
328 ),
329 ),
330 );
224331 });
225332
226 yield* Effect.forEach(
227 providerSources,
228 (providerSource) =>
229 Stream.runForEach(providerSource.streamChanges, (provider) => syncProvider(provider)).pipe(
230 Effect.forkScoped,
231 ),
232 {
333 const refreshAll = Effect.fn("refreshAll")(function* () {
334 const sources = yield* getLiveSources;
335 return yield* Effect.forEach(sources, (source) => refreshOneSource(source), {
233336 concurrency: "unbounded",
234337 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 }),
236476 );
237 yield* loadProviders(providerSources).pipe(
238 Effect.flatMap((providers) => upsertProviders(providers, { publish: false })),
477 const syncLiveSourcesAndContinue = syncLiveSources.pipe(
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 }),
239489 );
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
241552 return {
242553 getProviders: Ref.get(providersRef),
243 refresh: (provider?: ProviderKind) =>
244 refresh(provider).pipe(
245 Effect.tapError(Effect.logError),
246 Effect.orElseSucceed(() => [] as ReadonlyArray<ServerProvider>),
247 ),
554 refresh: (provider?: ProviderDriverKind) =>
555 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
556 refreshInstance: (instanceId: ProviderInstanceId) =>
557 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
248558 get streamChanges() {
249559 return Stream.fromPubSub(changesPubSub);
250560 },
251561 } satisfies ProviderRegistryShape;
252562 }),
253563);
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 {
1212import {
1313 ApprovalRequestId,
1414 EventId,
15 type ProviderKind,
15 ProviderDriverKind,
16 ProviderInstanceId,
1617 ProviderSessionStartInput,
1718 ThreadId,
1819 TurnId,
......@@ -20,20 +21,25 @@ import {
2021import { createModelSelection } from "@t3tools/shared/model";
2122import { 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";
2425import * as SqlClient from "effect/unstable/sql/SqlClient";
2526
2627import {
28 ProviderAdapterRequestError,
2729 ProviderAdapterSessionNotFoundError,
2830 ProviderUnsupportedError,
2931 ProviderValidationError,
3032 type ProviderAdapterError,
3133} from "../Errors.ts";
3234import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
33import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
35import {
36 ProviderAdapterRegistry,
37 type ProviderAdapterRegistryShape,
38} from "../Services/ProviderAdapterRegistry.ts";
3439import { ProviderService } from "../Services/ProviderService.ts";
3540import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
3641import { makeProviderServiceLive } from "./ProviderService.ts";
42import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
3743import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
3844import * as NodeServices from "@effect/platform-node/NodeServices";
3945import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
......@@ -44,6 +50,7 @@ import {
4450} from "../../persistence/Layers/Sqlite.ts";
4551import { ServerSettingsService } from "../../serverSettings.ts";
4652import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
53import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
4754
4855const defaultServerSettingsLayer = ServerSettingsService.layerTest();
4956
......@@ -51,11 +58,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make
5158const asEventId = (value: string): EventId => EventId.make(value);
5259const asThreadId = (value: string): ThreadId => ThreadId.make(value);
5360const 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
5567type LegacyProviderRuntimeEvent = {
5668 readonly type: string;
5769 readonly eventId: EventId;
58 readonly provider: ProviderKind;
70 readonly provider: ProviderDriverKind;
5971 readonly createdAt: string;
6072 readonly threadId: ThreadId;
6173 readonly turnId?: string | undefined;
......@@ -65,7 +77,7 @@ type LegacyProviderRuntimeEvent = {
6577 readonly [key: string]: unknown;
6678};
6779
68function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
80function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
6981 const sessions = new Map<ThreadId, ProviderSession>();
7082 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
7183
......@@ -74,10 +86,15 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
7486 const now = new Date().toISOString();
7587 const session: ProviderSession = {
7688 provider,
89 ...(input.providerInstanceId !== undefined
90 ? { providerInstanceId: input.providerInstanceId }
91 : {}),
7792 status: "ready",
7893 runtimeMode: input.runtimeMode,
7994 threadId: input.threadId,
80 resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` },
95 resumeCursor: input.resumeCursor ?? {
96 opaque: `resume-${String(input.threadId)}`,
97 },
8198 cwd: input.cwd ?? process.cwd(),
8299 createdAt: now,
83100 updatedAt: now,
......@@ -245,19 +262,13 @@ const hasMetricSnapshot = (
245262
246263function makeProviderServiceLayer() {
247264 const codex = makeFakeCodexAdapter();
248 const claude = makeFakeCodexAdapter("claudeAgent");
249 const cursor = makeFakeCodexAdapter("cursor");
250 const registry: typeof ProviderAdapterRegistry.Service = {
251 getByProvider: (provider) =>
252 provider === "codex"
253 ? Effect.succeed(codex.adapter)
254 : provider === "claudeAgent"
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 };
265 const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
266 const cursor = makeFakeCodexAdapter(CURSOR_DRIVER);
267 const registry = makeAdapterRegistryMock({
268 [ProviderDriverKind.make("codex")]: codex.adapter,
269 [ProviderDriverKind.make("claudeAgent")]: claude.adapter,
270 [ProviderDriverKind.make("cursor")]: cursor.adapter,
271 });
261272
262273 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
263274 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
......@@ -272,6 +283,7 @@ function makeProviderServiceLayer() {
272283 Layer.provide(directoryLayer),
273284 Layer.provide(defaultServerSettingsLayer),
274285 Layer.provideMerge(AnalyticsService.layerTest),
286 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
275287 ),
276288 directoryLayer,
277289
......@@ -288,27 +300,76 @@ function makeProviderServiceLayer() {
288300 };
289301}
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
291348it.effect("ProviderServiceLive rejects new sessions for disabled providers", () =>
292349 Effect.gen(function* () {
293350 const codex = makeFakeCodexAdapter();
294 const claude = makeFakeCodexAdapter("claudeAgent");
295 const registry: typeof ProviderAdapterRegistry.Service = {
296 getByProvider: (provider) =>
297 provider === "codex"
298 ? Effect.succeed(codex.adapter)
299 : provider === "claudeAgent"
300 ? Effect.succeed(claude.adapter)
301 : Effect.fail(new ProviderUnsupportedError({ provider })),
302 listProviders: () => Effect.succeed(["codex", "claudeAgent"]),
351 const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
352 const registryBase = makeAdapterRegistryMock({
353 [CODEX_DRIVER]: codex.adapter,
354 [CLAUDE_AGENT_DRIVER]: claude.adapter,
355 });
356 const registry: ProviderAdapterRegistryShape = {
357 ...registryBase,
358 getInstanceInfo: (instanceId) =>
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),
303371 };
304372 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
305 const serverSettingsLayer = ServerSettingsService.layerTest({
306 providers: {
307 claudeAgent: {
308 enabled: false,
309 },
310 },
311 });
312373 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
313374 Layer.provide(SqlitePersistenceMemory),
314375 );
......@@ -316,15 +377,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
316377 const providerLayer = makeProviderServiceLive().pipe(
317378 Layer.provide(providerAdapterLayer),
318379 Layer.provide(directoryLayer),
319 Layer.provide(serverSettingsLayer),
380 Layer.provide(defaultServerSettingsLayer),
320381 Layer.provide(AnalyticsService.layerTest),
382 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
321383 );
322384
323385 const failure = yield* Effect.flip(
324386 Effect.gen(function* () {
325387 const provider = yield* ProviderService;
326388 return yield* provider.startSession(asThreadId("thread-disabled"), {
327 provider: "claudeAgent",
389 provider: ProviderDriverKind.make("claudeAgent"),
390 providerInstanceId: claudeAgentInstanceId,
328391 threadId: asThreadId("thread-disabled"),
329392 runtimeMode: "full-access",
330393 });
......@@ -332,11 +395,149 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
332395 );
333396
334397 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");
336399 assert.equal(claude.startSession.mock.calls.length, 0);
337400 }).pipe(Effect.provide(NodeServices.layer)),
338401);
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
340541const routing = makeProviderServiceLayer();
341542
342543it.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
344545 const codex = makeFakeCodexAdapter();
345546 const canonicalEvents: ProviderRuntimeEvent[] = [];
346547 const canonicalThreadIds: Array<string | null> = [];
347 const registry: typeof ProviderAdapterRegistry.Service = {
348 getByProvider: (provider) =>
349 provider === "codex"
350 ? Effect.succeed(codex.adapter)
351 : Effect.fail(new ProviderUnsupportedError({ provider })),
352 listProviders: () => Effect.succeed(["codex"]),
353 };
548 const registry = makeAdapterRegistryMock({
549 [ProviderDriverKind.make("codex")]: codex.adapter,
550 });
354551 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
355552 Layer.provide(SqlitePersistenceMemory),
356553 );
......@@ -370,6 +567,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se
370567 Layer.provide(directoryLayer),
371568 Layer.provide(defaultServerSettingsLayer),
372569 Layer.provide(AnalyticsService.layerTest),
570 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
373571 );
374572
375573 yield* Effect.gen(function* () {
......@@ -377,7 +575,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se
377575 yield* sleep(10);
378576 codex.emit({
379577 eventId: asEventId("evt-canonical-thread-segment"),
380 provider: "codex",
578 provider: ProviderDriverKind.make("codex"),
381579 threadId: asThreadId("thread-canonical-thread-segment"),
382580 createdAt: new Date().toISOString(),
383581 type: "turn.completed",
......@@ -400,13 +598,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
400598 const dbPath = path.join(tempDir, "orchestration.sqlite");
401599
402600 const codex = makeFakeCodexAdapter();
403 const registry: typeof ProviderAdapterRegistry.Service = {
404 getByProvider: (provider) =>
405 provider === "codex"
406 ? Effect.succeed(codex.adapter)
407 : Effect.fail(new ProviderUnsupportedError({ provider })),
408 listProviders: () => Effect.succeed(["codex"]),
409 };
601 const registry = makeAdapterRegistryMock({
602 [ProviderDriverKind.make("codex")]: codex.adapter,
603 });
410604
411605 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
412606 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
......@@ -417,7 +611,8 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
417611 yield* Effect.gen(function* () {
418612 const directory = yield* ProviderSessionDirectory;
419613 yield* directory.upsert({
420 provider: "codex",
614 provider: ProviderDriverKind.make("codex"),
615 providerInstanceId: codexInstanceId,
421616 threadId: ThreadId.make("thread-stale"),
422617 });
423618 }).pipe(Effect.provide(directoryLayer));
......@@ -427,6 +622,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
427622 Layer.provide(directoryLayer),
428623 Layer.provide(defaultServerSettingsLayer),
429624 Layer.provide(AnalyticsService.layerTest),
625 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
430626 );
431627
432628 yield* Effect.gen(function* () {
......@@ -441,7 +637,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
441637
442638 const runtime = yield* Effect.gen(function* () {
443639 const repository = yield* ProviderSessionRuntimeRepository;
444 return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") });
640 return yield* repository.getByThreadId({
641 threadId: asThreadId("thread-stale"),
642 });
445643 }).pipe(Effect.provide(runtimeRepositoryLayer));
446644 assert.equal(Option.isSome(runtime), true);
447645
......@@ -471,13 +669,9 @@ it.effect(
471669 );
472670
473671 const firstCodex = makeFakeCodexAdapter();
474 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
475 getByProvider: (provider) =>
476 provider === "codex"
477 ? Effect.succeed(firstCodex.adapter)
478 : Effect.fail(new ProviderUnsupportedError({ provider })),
479 listProviders: () => Effect.succeed(["codex"]),
480 };
672 const firstRegistry = makeAdapterRegistryMock({
673 [ProviderDriverKind.make("codex")]: firstCodex.adapter,
674 });
481675
482676 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
483677 Layer.provide(runtimeRepositoryLayer),
......@@ -487,6 +681,7 @@ it.effect(
487681 Layer.provide(firstDirectoryLayer),
488682 Layer.provide(defaultServerSettingsLayer),
489683 Layer.provide(AnalyticsService.layerTest),
684 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
490685 );
491686 const updatedResumeCursor = {
492687 threadId: asThreadId("thread-1"),
......@@ -499,7 +694,8 @@ it.effect(
499694 const provider = yield* ProviderService;
500695 const threadId = asThreadId("thread-1");
501696 const session = yield* provider.startSession(threadId, {
502 provider: "codex",
697 provider: ProviderDriverKind.make("codex"),
698 providerInstanceId: codexInstanceId,
503699 cwd: "/tmp/project",
504700 runtimeMode: "full-access",
505701 threadId,
......@@ -515,7 +711,9 @@ it.effect(
515711
516712 const persistedAfterStopAll = yield* Effect.gen(function* () {
517713 const repository = yield* ProviderSessionRuntimeRepository;
518 return yield* repository.getByThreadId({ threadId: startedSession.threadId });
714 return yield* repository.getByThreadId({
715 threadId: startedSession.threadId,
716 });
519717 }).pipe(Effect.provide(runtimeRepositoryLayer));
520718 assert.equal(Option.isSome(persistedAfterStopAll), true);
521719 if (Option.isSome(persistedAfterStopAll)) {
......@@ -524,13 +722,9 @@ it.effect(
524722 }
525723
526724 const secondCodex = makeFakeCodexAdapter();
527 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
528 getByProvider: (provider) =>
529 provider === "codex"
530 ? Effect.succeed(secondCodex.adapter)
531 : Effect.fail(new ProviderUnsupportedError({ provider })),
532 listProviders: () => Effect.succeed(["codex"]),
533 };
725 const secondRegistry = makeAdapterRegistryMock({
726 [ProviderDriverKind.make("codex")]: secondCodex.adapter,
727 });
534728 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
535729 Layer.provide(runtimeRepositoryLayer),
536730 );
......@@ -539,6 +733,7 @@ it.effect(
539733 Layer.provide(secondDirectoryLayer),
540734 Layer.provide(defaultServerSettingsLayer),
541735 Layer.provide(AnalyticsService.layerTest),
736 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
542737 );
543738
544739 secondCodex.startSession.mockClear();
......@@ -582,7 +777,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
582777 const provider = yield* ProviderService;
583778
584779 const session = yield* provider.startSession(asThreadId("thread-1"), {
585 provider: "codex",
780 provider: ProviderDriverKind.make("codex"),
781 providerInstanceId: codexInstanceId,
586782 threadId: asThreadId("thread-1"),
587783 cwd: "/tmp/project",
588784 runtimeMode: "full-access",
......@@ -667,7 +863,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
667863 const provider = yield* ProviderService;
668864
669865 const initial = yield* provider.startSession(asThreadId("thread-1"), {
670 provider: "codex",
866 provider: ProviderDriverKind.make("codex"),
867 providerInstanceId: codexInstanceId,
671868 threadId: asThreadId("thread-1"),
672869 cwd: "/tmp/project",
673870 runtimeMode: "full-access",
......@@ -708,7 +905,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
708905 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
709906
710907 const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), {
711 provider: "codex",
908 provider: ProviderDriverKind.make("codex"),
909 providerInstanceId: codexInstanceId,
712910 threadId: asThreadId("thread-reap-preserve"),
713911 cwd: "/tmp/project-reap-preserve",
714912 runtimeMode: "full-access",
......@@ -758,7 +956,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
758956 const provider = yield* ProviderService;
759957
760958 const session = yield* provider.startSession(asThreadId("thread-claude"), {
761 provider: "claudeAgent",
959 provider: ProviderDriverKind.make("claudeAgent"),
960 providerInstanceId: claudeAgentInstanceId,
762961 threadId: asThreadId("thread-claude"),
763962 cwd: "/tmp/project-claude",
764963 runtimeMode: "full-access",
......@@ -769,20 +968,57 @@ routing.layer("ProviderServiceLive routing", (it) => {
769968 const startInput = routing.claude.startSession.mock.calls[0]?.[0];
770969 assert.equal(typeof startInput === "object" && startInput !== null, true);
771970 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 };
773976 assert.equal(startPayload.provider, "claudeAgent");
977 assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId);
774978 assert.equal(startPayload.cwd, "/tmp/project-claude");
775979 }
776980 }),
777981 );
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
7791014 it.effect("stops stale sessions in other providers after a successful replacement start", () =>
7801015 Effect.gen(function* () {
7811016 const provider = yield* ProviderService;
7821017 const threadId = asThreadId("thread-provider-replacement");
7831018
7841019 const codexSession = yield* provider.startSession(threadId, {
785 provider: "codex",
1020 provider: ProviderDriverKind.make("codex"),
1021 providerInstanceId: codexInstanceId,
7861022 threadId,
7871023 cwd: "/tmp/project-provider-replacement",
7881024 runtimeMode: "full-access",
......@@ -792,7 +1028,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
7921028 routing.claude.stopSession.mockClear();
7931029
7941030 const claudeSession = yield* provider.startSession(threadId, {
795 provider: "claudeAgent",
1031 provider: ProviderDriverKind.make("claudeAgent"),
1032 providerInstanceId: claudeAgentInstanceId,
7961033 threadId,
7971034 cwd: "/tmp/project-provider-replacement",
7981035 runtimeMode: "full-access",
......@@ -818,7 +1055,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
8181055 const provider = yield* ProviderService;
8191056
8201057 const initial = yield* provider.startSession(asThreadId("thread-1"), {
821 provider: "codex",
1058 provider: ProviderDriverKind.make("codex"),
1059 providerInstanceId: codexInstanceId,
8221060 threadId: asThreadId("thread-1"),
8231061 cwd: "/tmp/project-send-turn",
8241062 runtimeMode: "full-access",
......@@ -858,12 +1096,15 @@ routing.layer("ProviderServiceLive routing", (it) => {
8581096 const provider = yield* ProviderService;
8591097
8601098 const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), {
861 provider: "claudeAgent",
1099 provider: ProviderDriverKind.make("claudeAgent"),
1100 providerInstanceId: claudeAgentInstanceId,
8621101 threadId: asThreadId("thread-claude-send-turn"),
8631102 cwd: "/tmp/project-claude-send-turn",
864 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
865 { id: "effort", value: "max" },
866 ]),
1103 modelSelection: createModelSelection(
1104 ProviderInstanceId.make("claudeAgent"),
1105 "claude-opus-4-6",
1106 [{ id: "effort", value: "max" }],
1107 ),
8671108 runtimeMode: "full-access",
8681109 });
8691110
......@@ -892,7 +1133,9 @@ routing.layer("ProviderServiceLive routing", (it) => {
8921133 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
8931134 assert.deepEqual(
8941135 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 ]),
8961139 );
8971140 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
8981141 assert.equal(startPayload.threadId, initial.threadId);
......@@ -906,12 +1149,14 @@ routing.layer("ProviderServiceLive routing", (it) => {
9061149 const provider = yield* ProviderService;
9071150
9081151 yield* provider.startSession(asThreadId("thread-1"), {
909 provider: "codex",
1152 provider: ProviderDriverKind.make("codex"),
1153 providerInstanceId: codexInstanceId,
9101154 threadId: asThreadId("thread-1"),
9111155 runtimeMode: "full-access",
9121156 });
9131157 yield* provider.startSession(asThreadId("thread-2"), {
914 provider: "codex",
1158 provider: ProviderDriverKind.make("codex"),
1159 providerInstanceId: codexInstanceId,
9151160 threadId: asThreadId("thread-2"),
9161161 runtimeMode: "full-access",
9171162 });
......@@ -931,7 +1176,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
9311176
9321177 const threadId = asThreadId("thread-runtime-status");
9331178 const session = yield* provider.startSession(threadId, {
934 provider: "codex",
1179 provider: ProviderDriverKind.make("codex"),
1180 providerInstanceId: codexInstanceId,
9351181 threadId,
9361182 runtimeMode: "full-access",
9371183 });
......@@ -977,14 +1223,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
9771223 Layer.provide(persistenceLayer),
9781224 );
9791225
980 const firstClaude = makeFakeCodexAdapter("claudeAgent");
981 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
982 getByProvider: (provider) =>
983 provider === "claudeAgent"
984 ? Effect.succeed(firstClaude.adapter)
985 : Effect.fail(new ProviderUnsupportedError({ provider })),
986 listProviders: () => Effect.succeed(["claudeAgent"]),
987 };
1226 const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
1227 const firstRegistry = makeAdapterRegistryMock({
1228 [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter,
1229 });
9881230 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
9891231 Layer.provide(runtimeRepositoryLayer),
9901232 );
......@@ -993,12 +1235,14 @@ routing.layer("ProviderServiceLive routing", (it) => {
9931235 Layer.provide(firstDirectoryLayer),
9941236 Layer.provide(defaultServerSettingsLayer),
9951237 Layer.provide(AnalyticsService.layerTest),
1238 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
9961239 );
9971240
9981241 const initial = yield* Effect.gen(function* () {
9991242 const provider = yield* ProviderService;
10001243 return yield* provider.startSession(asThreadId("thread-claude-start"), {
1001 provider: "claudeAgent",
1244 provider: ProviderDriverKind.make("claudeAgent"),
1245 providerInstanceId: claudeAgentInstanceId,
10021246 threadId: asThreadId("thread-claude-start"),
10031247 cwd: "/tmp/project-claude-start",
10041248 runtimeMode: "full-access",
......@@ -1010,14 +1254,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
10101254 yield* provider.listSessions();
10111255 }).pipe(Effect.provide(firstProviderLayer));
10121256
1013 const secondClaude = makeFakeCodexAdapter("claudeAgent");
1014 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
1015 getByProvider: (provider) =>
1016 provider === "claudeAgent"
1017 ? Effect.succeed(secondClaude.adapter)
1018 : Effect.fail(new ProviderUnsupportedError({ provider })),
1019 listProviders: () => Effect.succeed(["claudeAgent"]),
1020 };
1257 const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
1258 const secondRegistry = makeAdapterRegistryMock({
1259 [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter,
1260 });
10211261 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
10221262 Layer.provide(runtimeRepositoryLayer),
10231263 );
......@@ -1026,6 +1266,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10261266 Layer.provide(secondDirectoryLayer),
10271267 Layer.provide(defaultServerSettingsLayer),
10281268 Layer.provide(AnalyticsService.layerTest),
1269 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
10291270 );
10301271
10311272 secondClaude.startSession.mockClear();
......@@ -1033,7 +1274,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
10331274 yield* Effect.gen(function* () {
10341275 const provider = yield* ProviderService;
10351276 yield* provider.startSession(initial.threadId, {
1036 provider: "claudeAgent",
1277 provider: ProviderDriverKind.make("claudeAgent"),
1278 providerInstanceId: claudeAgentInstanceId,
10371279 threadId: initial.threadId,
10381280 cwd: "/tmp/project-claude-start",
10391281 runtimeMode: "full-access",
......@@ -1071,14 +1313,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
10711313 Layer.provide(persistenceLayer),
10721314 );
10731315
1074 const firstClaude = makeFakeCodexAdapter("claudeAgent");
1075 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
1076 getByProvider: (provider) =>
1077 provider === "claudeAgent"
1078 ? Effect.succeed(firstClaude.adapter)
1079 : Effect.fail(new ProviderUnsupportedError({ provider })),
1080 listProviders: () => Effect.succeed(["claudeAgent"]),
1081 };
1316 const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
1317 const firstRegistry = makeAdapterRegistryMock({
1318 [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter,
1319 });
10821320 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
10831321 Layer.provide(runtimeRepositoryLayer),
10841322 );
......@@ -1087,26 +1325,24 @@ routing.layer("ProviderServiceLive routing", (it) => {
10871325 Layer.provide(firstDirectoryLayer),
10881326 Layer.provide(defaultServerSettingsLayer),
10891327 Layer.provide(AnalyticsService.layerTest),
1328 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
10901329 );
10911330
10921331 const initial = yield* Effect.gen(function* () {
10931332 const provider = yield* ProviderService;
10941333 return yield* provider.startSession(asThreadId("thread-claude-cwd"), {
1095 provider: "claudeAgent",
1334 provider: ProviderDriverKind.make("claudeAgent"),
1335 providerInstanceId: claudeAgentInstanceId,
10961336 threadId: asThreadId("thread-claude-cwd"),
10971337 cwd: "/tmp/project-claude-cwd",
10981338 runtimeMode: "full-access",
10991339 });
11001340 }).pipe(Effect.provide(firstProviderLayer));
11011341
1102 const secondClaude = makeFakeCodexAdapter("claudeAgent");
1103 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
1104 getByProvider: (provider) =>
1105 provider === "claudeAgent"
1106 ? Effect.succeed(secondClaude.adapter)
1107 : Effect.fail(new ProviderUnsupportedError({ provider })),
1108 listProviders: () => Effect.succeed(["claudeAgent"]),
1109 };
1342 const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
1343 const secondRegistry = makeAdapterRegistryMock({
1344 [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter,
1345 });
11101346 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
11111347 Layer.provide(runtimeRepositoryLayer),
11121348 );
......@@ -1115,6 +1351,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
11151351 Layer.provide(secondDirectoryLayer),
11161352 Layer.provide(defaultServerSettingsLayer),
11171353 Layer.provide(AnalyticsService.layerTest),
1354 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
11181355 );
11191356
11201357 secondClaude.startSession.mockClear();
......@@ -1122,7 +1359,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
11221359 yield* Effect.gen(function* () {
11231360 const provider = yield* ProviderService;
11241361 yield* provider.startSession(initial.threadId, {
1125 provider: "claudeAgent",
1362 provider: ProviderDriverKind.make("claudeAgent"),
1363 providerInstanceId: claudeAgentInstanceId,
11261364 threadId: initial.threadId,
11271365 runtimeMode: "full-access",
11281366 });
......@@ -1155,7 +1393,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
11551393 Effect.gen(function* () {
11561394 const provider = yield* ProviderService;
11571395 const session = yield* provider.startSession(asThreadId("thread-1"), {
1158 provider: "codex",
1396 provider: ProviderDriverKind.make("codex"),
1397 providerInstanceId: codexInstanceId,
11591398 threadId: asThreadId("thread-1"),
11601399 runtimeMode: "full-access",
11611400 });
......@@ -1169,7 +1408,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
11691408 const completedEvent: LegacyProviderRuntimeEvent = {
11701409 type: "turn.completed",
11711410 eventId: asEventId("evt-1"),
1172 provider: "codex",
1411 provider: ProviderDriverKind.make("codex"),
11731412 createdAt: new Date().toISOString(),
11741413 threadId: session.threadId,
11751414 turnId: asTurnId("turn-1"),
......@@ -1186,6 +1425,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
11861425 events.some((entry) => entry.type === "turn.completed"),
11871426 true,
11881427 );
1428 assert.equal(
1429 events.some(
1430 (entry) =>
1431 entry.type === "turn.completed" && entry.providerInstanceId === codexInstanceId,
1432 ),
1433 true,
1434 );
11891435 }),
11901436 );
11911437
......@@ -1193,7 +1439,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
11931439 Effect.gen(function* () {
11941440 const provider = yield* ProviderService;
11951441 const session = yield* provider.startSession(asThreadId("thread-seq"), {
1196 provider: "codex",
1442 provider: ProviderDriverKind.make("codex"),
1443 providerInstanceId: codexInstanceId,
11971444 threadId: asThreadId("thread-seq"),
11981445 runtimeMode: "full-access",
11991446 });
......@@ -1208,7 +1455,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12081455 fanout.codex.emit({
12091456 type: "tool.started",
12101457 eventId: asEventId("evt-seq-1"),
1211 provider: "codex",
1458 provider: ProviderDriverKind.make("codex"),
12121459 createdAt: new Date().toISOString(),
12131460 threadId: session.threadId,
12141461 turnId: asTurnId("turn-1"),
......@@ -1218,7 +1465,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12181465 fanout.codex.emit({
12191466 type: "tool.completed",
12201467 eventId: asEventId("evt-seq-2"),
1221 provider: "codex",
1468 provider: ProviderDriverKind.make("codex"),
12221469 createdAt: new Date().toISOString(),
12231470 threadId: session.threadId,
12241471 turnId: asTurnId("turn-1"),
......@@ -1228,7 +1475,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12281475 fanout.codex.emit({
12291476 type: "turn.completed",
12301477 eventId: asEventId("evt-seq-3"),
1231 provider: "codex",
1478 provider: ProviderDriverKind.make("codex"),
12321479 createdAt: new Date().toISOString(),
12331480 threadId: session.threadId,
12341481 turnId: asTurnId("turn-1"),
......@@ -1248,7 +1495,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12481495 Effect.gen(function* () {
12491496 const provider = yield* ProviderService;
12501497 const session = yield* provider.startSession(asThreadId("thread-1"), {
1251 provider: "codex",
1498 provider: ProviderDriverKind.make("codex"),
1499 providerInstanceId: codexInstanceId,
12521500 threadId: asThreadId("thread-1"),
12531501 runtimeMode: "full-access",
12541502 });
......@@ -1273,7 +1521,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12731521 {
12741522 type: "tool.completed",
12751523 eventId: asEventId("evt-ordered-1"),
1276 provider: "codex",
1524 provider: ProviderDriverKind.make("codex"),
12771525 createdAt: new Date().toISOString(),
12781526 threadId: session.threadId,
12791527 turnId: asTurnId("turn-1"),
......@@ -1284,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12841532 {
12851533 type: "message.delta",
12861534 eventId: asEventId("evt-ordered-2"),
1287 provider: "codex",
1535 provider: ProviderDriverKind.make("codex"),
12881536 createdAt: new Date().toISOString(),
12891537 threadId: session.threadId,
12901538 turnId: asTurnId("turn-1"),
......@@ -1293,7 +1541,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
12931541 {
12941542 type: "turn.completed",
12951543 eventId: asEventId("evt-ordered-3"),
1296 provider: "codex",
1544 provider: ProviderDriverKind.make("codex"),
12971545 createdAt: new Date().toISOString(),
12981546 threadId: session.threadId,
12991547 turnId: asTurnId("turn-1"),
......@@ -1320,7 +1568,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13201568 const provider = yield* ProviderService;
13211569
13221570 const session = yield* provider.startSession(asThreadId("thread-metrics"), {
1323 provider: "claudeAgent",
1571 provider: ProviderDriverKind.make("claudeAgent"),
1572 providerInstanceId: claudeAgentInstanceId,
13241573 threadId: asThreadId("thread-metrics"),
13251574 cwd: "/tmp/project",
13261575 runtimeMode: "full-access",
......@@ -1349,7 +1598,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13491598
13501599 assert.equal(
13511600 hasMetricSnapshot(snapshots, "t3_provider_turns_total", {
1352 provider: "claudeAgent",
1601 provider: ProviderDriverKind.make("claudeAgent"),
13531602 operation: "interrupt",
13541603 outcome: "success",
13551604 }),
......@@ -1357,7 +1606,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13571606 );
13581607 assert.equal(
13591608 hasMetricSnapshot(snapshots, "t3_provider_turns_total", {
1360 provider: "claudeAgent",
1609 provider: ProviderDriverKind.make("claudeAgent"),
13611610 operation: "approval-response",
13621611 outcome: "success",
13631612 }),
......@@ -1365,7 +1614,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13651614 );
13661615 assert.equal(
13671616 hasMetricSnapshot(snapshots, "t3_provider_turns_total", {
1368 provider: "claudeAgent",
1617 provider: ProviderDriverKind.make("claudeAgent"),
13691618 operation: "user-input-response",
13701619 outcome: "success",
13711620 }),
......@@ -1373,7 +1622,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13731622 );
13741623 assert.equal(
13751624 hasMetricSnapshot(snapshots, "t3_provider_turns_total", {
1376 provider: "claudeAgent",
1625 provider: ProviderDriverKind.make("claudeAgent"),
13771626 operation: "rollback",
13781627 outcome: "success",
13791628 }),
......@@ -1381,7 +1630,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13811630 );
13821631 assert.equal(
13831632 hasMetricSnapshot(snapshots, "t3_provider_sessions_total", {
1384 provider: "claudeAgent",
1633 provider: ProviderDriverKind.make("claudeAgent"),
13851634 operation: "stop",
13861635 outcome: "success",
13871636 }),
......@@ -1397,7 +1646,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
13971646 const provider = yield* ProviderService;
13981647
13991648 const session = yield* provider.startSession(asThreadId("thread-send-metrics"), {
1400 provider: "claudeAgent",
1649 provider: ProviderDriverKind.make("claudeAgent"),
1650 providerInstanceId: claudeAgentInstanceId,
14011651 threadId: asThreadId("thread-send-metrics"),
14021652 cwd: "/tmp/project-send-metrics",
14031653 runtimeMode: "full-access",
......@@ -1413,7 +1663,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14131663
14141664 assert.equal(
14151665 hasMetricSnapshot(snapshots, "t3_provider_turns_total", {
1416 provider: "claudeAgent",
1666 provider: ProviderDriverKind.make("claudeAgent"),
14171667 operation: "send",
14181668 outcome: "success",
14191669 }),
......@@ -1421,7 +1671,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14211671 );
14221672 assert.equal(
14231673 hasMetricSnapshot(snapshots, "t3_provider_turn_duration", {
1424 provider: "claudeAgent",
1674 provider: ProviderDriverKind.make("claudeAgent"),
14251675 operation: "send",
14261676 }),
14271677 true,
......@@ -1432,6 +1682,50 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14321682
14331683const validation = makeProviderServiceLayer();
14341684validation.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
14351729 it.effect("returns ProviderValidationError for invalid input payloads", () =>
14361730 Effect.gen(function* () {
14371731 const provider = yield* ProviderService;
......@@ -1466,7 +1760,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
14661760 Effect.sync(() => {
14671761 const now = new Date().toISOString();
14681762 return {
1469 provider: "codex",
1763 provider: ProviderDriverKind.make("codex"),
14701764 status: "ready",
14711765 threadId: input.threadId,
14721766 runtimeMode: input.runtimeMode,
......@@ -1478,7 +1772,8 @@ validation.layer("ProviderServiceLive validation", (it) => {
14781772 );
14791773
14801774 const session = yield* provider.startSession(asThreadId("thread-missing"), {
1481 provider: "codex",
1775 provider: ProviderDriverKind.make("codex"),
1776 providerInstanceId: codexInstanceId,
14821777 threadId: asThreadId("thread-missing"),
14831778 cwd: "/tmp/project",
14841779 runtimeMode: "full-access",
apps/server/src/provider/Layers/ProviderService.ts+316-102
......@@ -19,10 +19,12 @@ import {
1919 ProviderSendTurnInput,
2020 ProviderSessionStartInput,
2121 ProviderStopSessionInput,
22 type ProviderInstanceId,
23 type ProviderDriverKind,
2224 type ProviderRuntimeEvent,
2325 type ProviderSession,
2426} 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
2729import {
2830 increment,
......@@ -34,19 +36,24 @@ import {
3436 providerTurnMetricAttributes,
3537 withMetrics,
3638} from "../../observability/Metrics.ts";
37import { ProviderValidationError } from "../Errors.ts";
39import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
40import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
3841import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
3942import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
4043import {
4144 ProviderSessionDirectory,
4245 type ProviderRuntimeBinding,
4346} from "../Services/ProviderSessionDirectory.ts";
44import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
47import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
48import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";
4549import { 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 */
4856export interface ProviderServiceLiveOptions {
49 readonly canonicalEventLogPath?: string;
5057 readonly canonicalEventLogger?: EventNdjsonLogger;
5158}
5259
......@@ -141,18 +148,53 @@ function readPersistedCwd(
141148 return trimmed.length > 0 ? trimmed : undefined;
142149}
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
144188const makeProviderService = Effect.fn("makeProviderService")(function* (
145189 options?: ProviderServiceLiveOptions,
146190) {
147191 const analytics = yield* Effect.service(AnalyticsService);
148 const serverSettings = yield* ServerSettingsService;
149 const canonicalEventLogger =
150 options?.canonicalEventLogger ??
151 (options?.canonicalEventLogPath !== undefined
152 ? yield* makeEventNdjsonLogger(options.canonicalEventLogPath, {
153 stream: "canonical",
154 })
155 : undefined);
192 const eventLoggers = yield* ProviderEventLoggers;
193 // Options-provided logger wins (test overrides); otherwise we take whatever
194 // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical
195 // log writer is attached", which downstream code already handles as a
196 // no-op.
197 const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical;
156198
157199 const registry = yield* ProviderAdapterRegistry;
158200 const directory = yield* ProviderSessionDirectory;
......@@ -169,6 +211,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
169211 Effect.asVoid,
170212 );
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
172232 const upsertSessionBinding = (
173233 session: ProviderSession,
174234 threadId: ThreadId,
......@@ -178,38 +238,106 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
178238 readonly lastRuntimeEventAt?: string;
179239 },
180240 ) =>
181 directory.upsert({
182 threadId,
183 provider: session.provider,
184 runtimeMode: session.runtimeMode,
185 status: toRuntimeStatus(session),
186 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
187 runtimePayload: toRuntimePayloadFromSession(session, extra),
241 Effect.gen(function* () {
242 const providerInstanceId = yield* requireBindingInstanceId(
243 "ProviderService.upsertSessionBinding",
244 session,
245 );
246 yield* directory.upsert({
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 });
188255 });
189256
190 const providers = yield* registry.listProviders();
191 const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider));
192 const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
193 increment(providerRuntimeEventsTotal, {
194 provider: event.provider,
195 eventType: event.type,
196 }).pipe(Effect.andThen(publishRuntimeEvent(event)));
257 const processRuntimeEvent = (
258 source: {
259 readonly instanceId: ProviderInstanceId;
260 readonly provider: ProviderDriverKind;
261 },
262 event: ProviderRuntimeEvent,
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) =>
199 Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped),
200 ).pipe(Effect.asVoid);
285 const getAdapterEntries = Ref.get(subscribedAdapters).pipe(
286 Effect.map((map) => Array.from(map.entries())),
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
202328 const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: {
203329 readonly binding: ProviderRuntimeBinding;
204330 readonly operation: string;
205331 }) {
332 const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding);
206333 yield* Effect.annotateCurrentSpan({
207334 "provider.operation": "recover-session",
208335 "provider.kind": input.binding.provider,
336 "provider.instance_id": bindingInstanceId,
209337 "provider.thread_id": input.binding.threadId,
210338 });
211339 return yield* Effect.gen(function* () {
212 const adapter = yield* registry.getByProvider(input.binding.provider);
340 const adapter = yield* registry.getByInstance(bindingInstanceId);
213341 const hasResumeCursor =
214342 input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined;
215343 const hasActiveSession = yield* adapter.hasSession(input.binding.threadId);
......@@ -219,7 +347,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
219347 (session) => session.threadId === input.binding.threadId,
220348 );
221349 if (existing) {
222 yield* upsertSessionBinding(existing, input.binding.threadId);
350 yield* upsertSessionBinding(
351 { ...existing, providerInstanceId: bindingInstanceId },
352 input.binding.threadId,
353 );
223354 yield* analytics.record("provider.session.recovered", {
224355 provider: existing.provider,
225356 strategy: "adopt-existing",
......@@ -242,6 +373,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
242373 const resumed = yield* adapter.startSession({
243374 threadId: input.binding.threadId,
244375 provider: input.binding.provider,
376 providerInstanceId: bindingInstanceId,
245377 ...(persistedCwd ? { cwd: persistedCwd } : {}),
246378 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
247379 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
......@@ -254,7 +386,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
254386 );
255387 }
256388
257 yield* upsertSessionBinding(resumed, input.binding.threadId);
389 yield* upsertSessionBinding(
390 { ...resumed, providerInstanceId: bindingInstanceId },
391 input.binding.threadId,
392 );
258393 yield* analytics.record("provider.session.recovered", {
259394 provider: resumed.provider,
260395 strategy: "resume-thread",
......@@ -284,29 +419,49 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
284419 `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`,
285420 );
286421 }
287 const adapter = yield* registry.getByProvider(binding.provider);
422 const instanceId = yield* requireBindingInstanceId(input.operation, binding);
423 const adapter = yield* registry.getByInstance(instanceId);
288424
289425 const hasRequestedSession = yield* adapter.hasSession(input.threadId);
290426 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;
292433 }
293434
294435 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;
296442 }
297443
298 const recovered = yield* recoverSessionForThread({ binding, operation: input.operation });
299 return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const;
444 const recovered = yield* recoverSessionForThread({
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;
300454 });
301455
302456 const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: {
303457 readonly threadId: ThreadId;
304 readonly currentProvider: ProviderSession["provider"];
458 readonly currentInstanceId: ProviderInstanceId;
305459 }) {
460 const currentAdapters = yield* getAdapterEntries;
306461 yield* Effect.forEach(
307 adapters,
308 (adapter) =>
309 adapter.provider === input.currentProvider
462 currentAdapters,
463 ([instanceId, adapter]) =>
464 instanceId === input.currentInstanceId
310465 ? Effect.void
311466 : Effect.gen(function* () {
312467 const hasSession = yield* adapter.hasSession(input.threadId);
......@@ -341,63 +496,72 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
341496 payload: rawInput,
342497 });
343498
344 const input = {
345 ...parsed,
346 threadId,
347 provider: parsed.provider ?? "codex",
348 };
499 const resolvedInstanceId = yield* requireBindingInstanceId(
500 "ProviderService.startSession",
501 parsed,
502 );
503 let metricProvider = parsed.provider ?? String(resolvedInstanceId);
349504 yield* Effect.annotateCurrentSpan({
350505 "provider.operation": "start-session",
351 "provider.kind": input.provider,
506 "provider.instance_id": resolvedInstanceId,
352507 "provider.thread_id": threadId,
353 "provider.runtime_mode": input.runtimeMode,
508 "provider.runtime_mode": parsed.runtimeMode,
354509 });
355510 return yield* Effect.gen(function* () {
356 const settings = yield* serverSettings.getSettings.pipe(
357 Effect.mapError((error) =>
358 toValidationError(
359 "ProviderService.startSession",
360 `Failed to load provider settings: ${error.message}`,
361 error,
362 ),
363 ),
364 );
365 if (!settings.providers[input.provider].enabled) {
511 const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId);
512 const resolvedProvider = instanceInfo.driverKind;
513 metricProvider = resolvedProvider;
514 if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) {
366515 return yield* toValidationError(
367516 "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.`,
369529 );
370530 }
371531 const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId));
372532 const effectiveResumeCursor =
373533 input.resumeCursor ??
374 (persistedBinding?.provider === input.provider
534 (persistedBinding?.providerInstanceId === resolvedInstanceId
375535 ? persistedBinding.resumeCursor
376536 : undefined);
377537 const effectiveCwd =
378538 input.cwd ??
379 (persistedBinding?.provider === input.provider
539 (persistedBinding?.providerInstanceId === resolvedInstanceId
380540 ? readPersistedCwd(persistedBinding.runtimePayload)
381541 : undefined);
382542 yield* Effect.annotateCurrentSpan({
543 "provider.kind": resolvedProvider,
383544 "provider.resume_cursor.source":
384545 input.resumeCursor !== undefined
385546 ? "request"
386 : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider
547 : effectiveResumeCursor !== undefined &&
548 persistedBinding?.providerInstanceId === resolvedInstanceId
387549 ? "persisted"
388550 : "none",
389551 "provider.resume_cursor.present": effectiveResumeCursor !== undefined,
390552 "provider.cwd.source":
391553 input.cwd !== undefined
392554 ? "request"
393 : effectiveCwd !== undefined && persistedBinding?.provider === input.provider
555 : effectiveCwd !== undefined &&
556 persistedBinding?.providerInstanceId === resolvedInstanceId
394557 ? "persisted"
395558 : "none",
396559 "provider.cwd.effective": effectiveCwd ?? "",
397560 });
398 const adapter = yield* registry.getByProvider(input.provider);
561 const adapter = yield* registry.getByInstance(resolvedInstanceId);
399562 const session = yield* adapter.startSession({
400563 ...input,
564 providerInstanceId: resolvedInstanceId,
401565 ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
402566 ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}),
403567 });
......@@ -408,31 +572,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
408572 `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`,
409573 );
410574 }
575 const sessionWithInstance = {
576 ...session,
577 providerInstanceId: resolvedInstanceId,
578 };
411579
412580 yield* stopStaleSessionsForThread({
413581 threadId,
414 currentProvider: adapter.provider,
582 currentInstanceId: resolvedInstanceId,
415583 });
416 yield* upsertSessionBinding(session, threadId, {
584 yield* upsertSessionBinding(sessionWithInstance, threadId, {
417585 modelSelection: input.modelSelection,
418586 });
419587 yield* analytics.record("provider.session.started", {
420 provider: session.provider,
588 provider: sessionWithInstance.provider,
421589 runtimeMode: input.runtimeMode,
422 hasResumeCursor: session.resumeCursor !== undefined,
590 hasResumeCursor: sessionWithInstance.resumeCursor !== undefined,
423591 hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0,
424592 hasModel:
425593 typeof input.modelSelection?.model === "string" &&
426594 input.modelSelection.model.trim().length > 0,
427595 });
428596
429 return session;
597 return sessionWithInstance;
430598 }).pipe(
431599 withMetrics({
432600 counter: providerSessionsTotal,
433 attributes: providerMetricAttributes(input.provider, {
434 operation: "start",
435 }),
601 attributes: () =>
602 providerMetricAttributes(metricProvider, {
603 operation: "start",
604 }),
436605 }),
437606 );
438607 },
......@@ -479,6 +648,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
479648 yield* directory.upsert({
480649 threadId: input.threadId,
481650 provider: routed.adapter.provider,
651 providerInstanceId: routed.instanceId,
482652 status: "running",
483653 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
484654 runtimePayload: {
......@@ -647,6 +817,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
647817 yield* directory.upsert({
648818 threadId: input.threadId,
649819 provider: routed.adapter.provider,
820 providerInstanceId: routed.instanceId,
650821 status: "stopped",
651822 runtimePayload: {
652823 activeTurnId: null,
......@@ -669,8 +840,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
669840
670841 const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")(
671842 function* () {
672 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) =>
673 adapter.listSessions(),
843 const currentAdapters = yield* getAdapterEntries;
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 ),
674853 );
675854 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
676855 const persistedBindings = yield* directory.listThreadIds().pipe(
......@@ -694,29 +873,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
694873 }
695874 }
696875
697 return activeSessions.map((session) => {
876 const sessions: ProviderSession[] = [];
877 for (const session of activeSessions) {
698878 const binding = bindingsByThreadId.get(session.threadId);
699879 if (!binding) {
700 return session;
880 sessions.push(session);
881 continue;
701882 }
702883
703884 const overrides: {
704885 resumeCursor?: ProviderSession["resumeCursor"];
705886 runtimeMode?: ProviderSession["runtimeMode"];
887 providerInstanceId?: ProviderSession["providerInstanceId"];
706888 } = {};
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 }
707907 if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) {
708908 overrides.resumeCursor = binding.resumeCursor;
709909 }
710910 if (binding.runtimeMode !== undefined) {
711911 overrides.runtimeMode = binding.runtimeMode;
712912 }
713 return Object.assign({}, session, overrides);
714 });
913 sessions.push(Object.assign({}, session, overrides));
914 }
915 return sessions;
715916 },
716917 );
717918
718 const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) =>
719 registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities));
919 const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) =>
920 registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities));
921
922 const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) =>
923 registry.getInstanceInfo(instanceId);
720924
721925 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn(
722926 "rollbackConversation",
......@@ -761,8 +965,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
761965
762966 const runStopAll = Effect.fn("runStopAll")(function* () {
763967 const threadIds = yield* directory.listThreadIds();
764 const activeSessions = yield* Effect.forEach(adapters, (adapter) =>
765 adapter.listSessions(),
968 const currentAdapters = yield* getAdapterEntries;
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 ),
766978 ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)));
767979 yield* Effect.forEach(activeSessions, (session) =>
768980 upsertSessionBinding(session, session.threadId, {
......@@ -770,23 +982,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
770982 lastRuntimeEventAt: new Date().toISOString(),
771983 }),
772984 ).pipe(Effect.asVoid);
773 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
774 yield* Effect.forEach(threadIds, (threadId) =>
775 directory.getProvider(threadId).pipe(
776 Effect.flatMap((provider) =>
777 directory.upsert({
778 threadId,
779 provider,
780 status: "stopped",
781 runtimePayload: {
782 activeTurnId: null,
783 lastRuntimeEvent: "provider.stopAll",
784 lastRuntimeEventAt: new Date().toISOString(),
785 },
786 }),
787 ),
788 ),
789 ).pipe(Effect.asVoid);
985 yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid);
986 const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => []));
987 yield* Effect.forEach(bindings, (binding) => {
988 const providerInstanceId = dieOnMissingBindingInstanceId("ProviderService.stopAll", binding);
989 return directory.upsert({
990 threadId: binding.threadId,
991 provider: binding.provider,
992 providerInstanceId,
993 status: "stopped",
994 runtimePayload: {
995 activeTurnId: null,
996 lastRuntimeEvent: "provider.stopAll",
997 lastRuntimeEventAt: new Date().toISOString(),
998 },
999 });
1000 }).pipe(Effect.asVoid);
7901001 yield* analytics.record("provider.sessions.stopped_all", {
7911002 sessionCount: threadIds.length,
7921003 });
......@@ -794,8 +1005,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
7941005 });
7951006
7961007 yield* Effect.addFinalizer(() =>
797 Effect.catch(runStopAll(), (cause) =>
798 Effect.logWarning("failed to stop provider service", { cause }),
1008 runStopAll().pipe(
1009 Effect.catchCause((cause) =>
1010 Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }),
1011 ),
7991012 ),
8001013 );
8011014
......@@ -808,6 +1021,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
8081021 stopSession,
8091022 listSessions,
8101023 getCapabilities,
1024 getInstanceInfo,
8111025 rollbackConversation,
8121026 // Each access creates a fresh PubSub subscription so that multiple
8131027 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+14-11
......@@ -3,7 +3,7 @@ import os from "node:os";
33import path from "node:path";
44
55import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ThreadId } from "@t3tools/contracts";
6import { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
77import { it, assert } from "@effect/vitest";
88import { assertSome } from "@effect/vitest/utils";
99import { Effect, Layer, Option } from "effect";
......@@ -38,7 +38,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
3838 const initialThreadId = ThreadId.make("thread-1");
3939
4040 yield* directory.upsert({
41 provider: "codex",
41 provider: ProviderDriverKind.make("codex"),
4242 threadId: initialThreadId,
4343 });
4444
......@@ -47,7 +47,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
4747 const resolvedBinding = yield* directory.getBinding(initialThreadId);
4848 assertSome(resolvedBinding, {
4949 threadId: initialThreadId,
50 provider: "codex",
50 provider: ProviderDriverKind.make("codex"),
5151 });
5252 if (Option.isSome(resolvedBinding)) {
5353 assert.equal(resolvedBinding.value.threadId, initialThreadId);
......@@ -56,7 +56,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
5656 const nextThreadId = ThreadId.make("thread-2");
5757
5858 yield* directory.upsert({
59 provider: "codex",
59 provider: ProviderDriverKind.make("codex"),
6060 threadId: nextThreadId,
6161 });
6262 const updatedBinding = yield* directory.getBinding(nextThreadId);
......@@ -85,7 +85,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
8585 const threadId = ThreadId.make("thread-runtime");
8686
8787 yield* directory.upsert({
88 provider: "codex",
88 provider: ProviderDriverKind.make("codex"),
8989 threadId,
9090 status: "starting",
9191 resumeCursor: {
......@@ -98,7 +98,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
9898 });
9999
100100 yield* directory.upsert({
101 provider: "codex",
101 provider: ProviderDriverKind.make("codex"),
102102 threadId,
103103 status: "running",
104104 runtimePayload: {
......@@ -133,6 +133,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
133133 yield* runtimeRepository.upsert({
134134 threadId: newerThreadId,
135135 providerName: "codex",
136 providerInstanceId: null,
136137 adapterKey: "codex",
137138 runtimeMode: "full-access",
138139 status: "running",
......@@ -148,6 +149,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
148149 yield* runtimeRepository.upsert({
149150 threadId: olderThreadId,
150151 providerName: "claudeAgent",
152 providerInstanceId: null,
151153 adapterKey: "claudeAgent",
152154 runtimeMode: "approval-required",
153155 status: "starting",
......@@ -165,7 +167,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
165167 assert.deepEqual(bindings, [
166168 {
167169 threadId: olderThreadId,
168 provider: "claudeAgent",
170 provider: ProviderDriverKind.make("claudeAgent"),
169171 adapterKey: "claudeAgent",
170172 runtimeMode: "approval-required",
171173 status: "starting",
......@@ -179,7 +181,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
179181 },
180182 {
181183 threadId: newerThreadId,
182 provider: "codex",
184 provider: ProviderDriverKind.make("codex"),
183185 adapterKey: "codex",
184186 runtimeMode: "full-access",
185187 status: "running",
......@@ -203,6 +205,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
203205 yield* runtimeRepository.upsert({
204206 threadId,
205207 providerName: "claudeAgent",
208 providerInstanceId: null,
206209 adapterKey: "claudeAgent",
207210 runtimeMode: "full-access",
208211 status: "running",
......@@ -212,7 +215,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
212215 });
213216
214217 yield* directory.upsert({
215 provider: "codex",
218 provider: ProviderDriverKind.make("codex"),
216219 threadId,
217220 });
218221
......@@ -235,7 +238,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
235238 yield* Effect.gen(function* () {
236239 const directory = yield* ProviderSessionDirectory;
237240 yield* directory.upsert({
238 provider: "codex",
241 provider: ProviderDriverKind.make("codex"),
239242 threadId,
240243 });
241244 }).pipe(Effect.provide(directoryLayer));
......@@ -249,7 +252,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
249252 const resolvedBinding = yield* directory.getBinding(threadId);
250253 assertSome(resolvedBinding, {
251254 threadId,
252 provider: "codex",
255 provider: ProviderDriverKind.make("codex"),
253256 });
254257 if (Option.isSome(resolvedBinding)) {
255258 assert.equal(resolvedBinding.value.threadId, threadId);
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+19-5
......@@ -1,4 +1,4 @@
1import { ProviderKind, type ThreadId } from "@t3tools/contracts";
1import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } from "@t3tools/contracts";
22import { Effect, Layer, Option, Schema } from "effect";
33
44import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";
......@@ -20,11 +20,11 @@ function toPersistenceError(operation: string) {
2020 });
2121}
2222
23function decodeProviderKind(
23function decodeProviderDriverKind(
2424 providerName: string,
2525 operation: string,
26): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
27 return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe(
26): Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryPersistenceError> {
27 return Schema.decodeUnknownEffect(ProviderDriverKind)(providerName).pipe(
2828 Effect.mapError(
2929 (cause) =>
3030 new ProviderSessionDirectoryPersistenceError({
......@@ -57,12 +57,17 @@ function toRuntimeBinding(
5757 runtime: ProviderSessionRuntime,
5858 operation: string,
5959): Effect.Effect<ProviderRuntimeBindingWithMetadata, ProviderSessionDirectoryPersistenceError> {
60 return decodeProviderKind(runtime.providerName, operation).pipe(
60 return decodeProviderDriverKind(runtime.providerName, operation).pipe(
6161 Effect.map(
6262 (provider) =>
6363 ({
6464 threadId: runtime.threadId,
6565 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),
6671 adapterKey: runtime.adapterKey,
6772 runtimeMode: runtime.runtimeMode,
6873 status: runtime.status,
......@@ -108,10 +113,19 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
108113 const now = new Date().toISOString();
109114 const providerChanged =
110115 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 }
111124 yield* repository
112125 .upsert({
113126 threadId: resolvedThreadId,
114127 providerName: binding.provider,
128 providerInstanceId,
115129 adapterKey:
116130 binding.adapterKey ??
117131 (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)),
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+29-2
......@@ -1,5 +1,11 @@
11import * 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";
39import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";
410import { afterEach, describe, expect, it, vi } from "vitest";
511
......@@ -17,7 +23,7 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
1723import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts";
1824
1925const defaultModelSelection = {
20 provider: "codex",
26 instanceId: ProviderInstanceId.make("codex"),
2127 model: "gpt-5-codex",
2228} as const;
2329
......@@ -140,6 +146,19 @@ describe("ProviderSessionReaper", () => {
140146 stopSession,
141147 listSessions: () => Effect.succeed([]),
142148 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 },
143162 rollbackConversation: () => unsupported(),
144163 streamEvents: Stream.empty,
145164 };
......@@ -197,6 +216,7 @@ describe("ProviderSessionReaper", () => {
197216 repository.upsert({
198217 threadId,
199218 providerName: "claudeAgent",
219 providerInstanceId: null,
200220 adapterKey: "claudeAgent",
201221 runtimeMode: "full-access",
202222 status: "running",
......@@ -244,6 +264,7 @@ describe("ProviderSessionReaper", () => {
244264 repository.upsert({
245265 threadId,
246266 providerName: "claudeAgent",
267 providerInstanceId: null,
247268 adapterKey: "claudeAgent",
248269 runtimeMode: "full-access",
249270 status: "running",
......@@ -290,6 +311,7 @@ describe("ProviderSessionReaper", () => {
290311 repository.upsert({
291312 threadId,
292313 providerName: "claudeAgent",
314 providerInstanceId: null,
293315 adapterKey: "claudeAgent",
294316 runtimeMode: "full-access",
295317 status: "running",
......@@ -336,6 +358,7 @@ describe("ProviderSessionReaper", () => {
336358 repository.upsert({
337359 threadId,
338360 providerName: "claudeAgent",
361 providerInstanceId: null,
339362 adapterKey: "claudeAgent",
340363 runtimeMode: "full-access",
341364 status: "stopped",
......@@ -404,6 +427,7 @@ describe("ProviderSessionReaper", () => {
404427 repository.upsert({
405428 threadId: failedThreadId,
406429 providerName: "claudeAgent",
430 providerInstanceId: null,
407431 adapterKey: "claudeAgent",
408432 runtimeMode: "full-access",
409433 status: "running",
......@@ -418,6 +442,7 @@ describe("ProviderSessionReaper", () => {
418442 repository.upsert({
419443 threadId: reapedThreadId,
420444 providerName: "codex",
445 providerInstanceId: null,
421446 adapterKey: "codex",
422447 runtimeMode: "full-access",
423448 status: "running",
......@@ -483,6 +508,7 @@ describe("ProviderSessionReaper", () => {
483508 repository.upsert({
484509 threadId: defectThreadId,
485510 providerName: "claudeAgent",
511 providerInstanceId: null,
486512 adapterKey: "claudeAgent",
487513 runtimeMode: "full-access",
488514 status: "running",
......@@ -497,6 +523,7 @@ describe("ProviderSessionReaper", () => {
497523 repository.upsert({
498524 threadId: reapedThreadId,
499525 providerName: "codex",
526 providerInstanceId: null,
500527 adapterKey: "codex",
501528 runtimeMode: "full-access",
502529 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 @@
11/**
2 * ClaudeAdapter - Claude Agent implementation of the generic provider adapter contract.
2 * ClaudeAdapter — shape type for the Claude provider adapter.
33 *
4 * This service owns Claude runtime/session semantics and emits canonical
5 * provider runtime events. It does not perform cross-provider routing, shared
6 * event fan-out, or checkpoint orchestration.
7 *
8 * Uses Effect `Context.Service` for dependency injection and returns the
9 * shared provider-adapter error channel with `provider: "claudeAgent"` context.
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/ClaudeDriver}) 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.
109 *
1110 * @module ClaudeAdapter
1211 */
13import { Context } from "effect";
14
1512import type { ProviderAdapterError } from "../Errors.ts";
1613import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
1714
1815/**
19 * ClaudeAdapterShape - Service API for the Claude Agent provider adapter.
20 */
21export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "claudeAgent";
23}
24
25/**
26 * ClaudeAdapter - Service tag for Claude Agent provider adapter operations.
16 * ClaudeAdapterShape — per-instance Claude adapter contract. Carries
17 * a branded driver kind as the nominal discriminant.
2718 */
28export class ClaudeAdapter extends Context.Service<ClaudeAdapter, ClaudeAdapterShape>()(
29 "t3/provider/Services/ClaudeAdapter",
30) {}
19export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
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 @@
11/**
2 * CodexAdapter - Codex implementation of the generic provider adapter contract.
2 * CodexAdapter — shape type for the Codex provider adapter.
33 *
4 * This service owns Codex app-server process / JSON-RPC semantics and emits
5 * Codex provider events. It does not perform cross-provider routing, shared
6 * event fan-out, or checkpoint orchestration.
7 *
8 * Uses Effect `Context.Service` for dependency injection and returns the
9 * shared provider-adapter error channel with `provider: "codex"` context.
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/CodexDriver}) 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.
109 *
1110 * @module CodexAdapter
1211 */
13import { Context } from "effect";
14
1512import type { ProviderAdapterError } from "../Errors.ts";
1613import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
1714
1815/**
19 * CodexAdapterShape - Service API for the Codex provider adapter.
20 */
21export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "codex";
23}
24
25/**
26 * CodexAdapter - Service tag for Codex provider adapter operations.
16 * CodexAdapterShape — per-instance Codex adapter contract. Carries
17 * a branded driver kind as the nominal discriminant.
2718 */
28export class CodexAdapter extends Context.Service<CodexAdapter, CodexAdapterShape>()(
29 "t3/provider/Services/CodexAdapter",
30) {}
19export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
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 @@
1import { Context } from "effect";
2
1/**
2 * CursorAdapter — shape type for the Cursor provider adapter.
3 *
4 * Historically this module exposed a `Context.Service` tag so consumers
5 * could inject the adapter through the Effect layer graph. The driver
6 * model ({@link ../Drivers/CursorDriver}) bundles one adapter per
7 * instance as a captured closure instead, so the tag is gone — we only
8 * retain the shape interface as a naming anchor for the driver bundle.
9 *
10 * @module CursorAdapter
11 */
312import type { ProviderAdapterError } from "../Errors.ts";
413import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
514
6export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7 readonly provider: "cursor";
8}
9
10export class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()(
11 "t3/provider/Services/CursorAdapter",
12) {}
15/**
16 * CursorAdapterShape — per-instance Cursor adapter contract. Carries
17 * a branded driver kind as the nominal discriminant.
18 */
19export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
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 @@
1import { Context } from "effect";
2
1/**
2 * OpenCodeAdapter — shape type for the OpenCode provider adapter.
3 *
4 * Historically this module exposed a `Context.Service` tag so consumers
5 * could inject the adapter through the Effect layer graph. The driver
6 * model ({@link ../Drivers/OpenCodeDriver}) bundles one adapter per
7 * instance as a captured closure instead, so the tag is gone — we only
8 * retain the shape interface as a naming anchor for the driver bundle.
9 *
10 * @module OpenCodeAdapter
11 */
312import type { ProviderAdapterError } from "../Errors.ts";
413import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
514
6export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7 readonly provider: "opencode";
8}
9
10export class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(
11 "t3/provider/Services/OpenCodeAdapter",
12) {}
15/**
16 * OpenCodeAdapterShape — per-instance OpenCode adapter contract. Carries
17 * a branded driver kind as the nominal discriminant.
18 */
19export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
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 @@
1010import type {
1111 ApprovalRequestId,
1212 ProviderApprovalDecision,
13 ProviderKind,
13 ProviderDriverKind,
1414 ProviderUserInputAnswers,
1515 ProviderRuntimeEvent,
1616 ProviderSendTurnInput,
......@@ -46,7 +46,7 @@ export interface ProviderAdapterShape<TError> {
4646 /**
4747 * Provider kind implemented by this adapter.
4848 */
49 readonly provider: ProviderKind;
49 readonly provider: ProviderDriverKind;
5050 readonly capabilities: ProviderAdapterCapabilities;
5151
5252 /**
apps/server/src/provider/Services/ProviderAdapterRegistry.ts+68-13
......@@ -1,34 +1,91 @@
11/**
22 * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations.
33 *
4 * Maps a provider kind to the concrete adapter service (Codex, Claude, etc).
5 * It does not own session lifecycle or routing rules; `ProviderService` uses
6 * this registry together with `ProviderSessionDirectory`.
4 * Maps a `ProviderInstanceId` (the new per-instance routing key) or a
5 * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete
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.
719 *
820 * @module ProviderAdapterRegistry
921 */
10import type { ProviderKind } from "@t3tools/contracts";
22import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
1123import { Context } from "effect";
12import type { Effect } from "effect";
24import type { Effect, PubSub, Scope, Stream } from "effect";
1325
1426import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts";
1527import 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
1739/**
18 * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind.
40 * ProviderAdapterRegistryShape - Service API for adapter lookup.
1941 */
2042export interface ProviderAdapterRegistryShape {
2143 /**
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).
2349 */
24 readonly getByProvider: (
25 provider: ProviderKind,
50 readonly getByInstance: (
51 instanceId: ProviderInstanceId,
2652 ) => 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
2864 /**
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.
3070 */
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>;
3289}
3390
3491/**
......@@ -38,5 +95,3 @@ export class ProviderAdapterRegistry extends Context.Service<
3895 ProviderAdapterRegistry,
3996 ProviderAdapterRegistryShape
4097>()("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 @@
66 *
77 * @module ProviderRegistry
88 */
9import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
9import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts";
1010import { Context } from "effect";
1111import type { Effect, Stream } from "effect";
1212
1313export interface ProviderRegistryShape {
1414 /**
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`.
1618 */
1719 readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>;
1820
1921 /**
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.
2129 */
22 readonly refresh: (provider?: ProviderKind) => Effect.Effect<ReadonlyArray<ServerProvider>>;
30 readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect<ReadonlyArray<ServerProvider>>;
2331
2432 /**
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.
2645 */
2746 readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>;
2847}
apps/server/src/provider/Services/ProviderService.ts+8-3
......@@ -13,7 +13,7 @@
1313 */
1414import type {
1515 ProviderInterruptTurnInput,
16 ProviderKind,
16 ProviderInstanceId,
1717 ProviderRespondToRequestInput,
1818 ProviderRespondToUserInputInput,
1919 ProviderRuntimeEvent,
......@@ -29,6 +29,7 @@ import type { Effect, Stream } from "effect";
2929
3030import type { ProviderServiceError } from "../Errors.ts";
3131import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts";
32import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts";
3233
3334/**
3435 * ProviderServiceShape - Service API for provider session and turn orchestration.
......@@ -85,12 +86,16 @@ export interface ProviderServiceShape {
8586 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;
8687
8788 /**
88 * Read static capabilities for a provider adapter.
89 * Read capabilities for the adapter bound to a configured provider instance.
8990 */
9091 readonly getCapabilities: (
91 provider: ProviderKind,
92 instanceId: ProviderInstanceId,
9293 ) => Effect.Effect<ProviderAdapterCapabilities, ProviderServiceError>;
9394
95 readonly getInstanceInfo: (
96 instanceId: ProviderInstanceId,
97 ) => Effect.Effect<ProviderInstanceRoutingInfo, ProviderServiceError>;
98
9499 /**
95100 * Roll back provider conversation state by a number of turns.
96101 */
apps/server/src/provider/Services/ProviderSessionDirectory.ts+10-3
......@@ -1,5 +1,6 @@
11import type {
2 ProviderKind,
2 ProviderInstanceId,
3 ProviderDriverKind,
34 ProviderSessionRuntimeStatus,
45 RuntimeMode,
56 ThreadId,
......@@ -14,7 +15,13 @@ import type {
1415
1516export interface ProviderRuntimeBinding {
1617 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;
1825 readonly adapterKey?: string;
1926 readonly status?: ProviderSessionRuntimeStatus;
2027 readonly resumeCursor?: unknown | null;
......@@ -39,7 +46,7 @@ export interface ProviderSessionDirectoryShape {
3946
4047 readonly getProvider: (
4148 threadId: ThreadId,
42 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;
49 ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>;
4350
4451 readonly getBinding: (
4552 threadId: ThreadId,
apps/server/src/provider/acp/AcpAdapterSupport.test.ts+2-1
......@@ -1,5 +1,6 @@
11import { describe, expect, it } from "vitest";
22import * as EffectAcpErrors from "effect-acp/errors";
3import { ProviderDriverKind } from "@t3tools/contracts";
34
45import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts";
56
......@@ -12,7 +13,7 @@ describe("AcpAdapterSupport", () => {
1213
1314 it("maps ACP request errors to provider adapter request errors", () => {
1415 const error = mapAcpToAdapterError(
15 "cursor",
16 ProviderDriverKind.make("cursor"),
1617 "thread-1" as never,
1718 "session/prompt",
1819 new EffectAcpErrors.AcpRequestError({
apps/server/src/provider/acp/AcpAdapterSupport.ts+2-2
......@@ -1,6 +1,6 @@
11import {
22 type ProviderApprovalDecision,
3 type ProviderKind,
3 type ProviderDriverKind,
44 type ThreadId,
55} from "@t3tools/contracts";
66import { Schema } from "effect";
......@@ -13,7 +13,7 @@ import {
1313} from "../Errors.ts";
1414
1515export function mapAcpToAdapterError(
16 provider: ProviderKind,
16 provider: ProviderDriverKind,
1717 threadId: ThreadId,
1818 method: string,
1919 error: EffectAcpErrors.AcpError,
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts+7-7
......@@ -1,4 +1,4 @@
1import { RuntimeRequestId, TurnId } from "@t3tools/contracts";
1import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import {
......@@ -30,7 +30,7 @@ describe("AcpCoreRuntimeEvents", () => {
3030 expect(
3131 makeAcpRequestOpenedEvent({
3232 stamp,
33 provider: "cursor",
33 provider: ProviderDriverKind.make("cursor"),
3434 threadId: "thread-1" as never,
3535 turnId,
3636 requestId: RuntimeRequestId.make("request-1"),
......@@ -52,7 +52,7 @@ describe("AcpCoreRuntimeEvents", () => {
5252 expect(
5353 makeAcpRequestResolvedEvent({
5454 stamp,
55 provider: "cursor",
55 provider: ProviderDriverKind.make("cursor"),
5656 threadId: "thread-1" as never,
5757 turnId,
5858 requestId: RuntimeRequestId.make("request-1"),
......@@ -75,7 +75,7 @@ describe("AcpCoreRuntimeEvents", () => {
7575 expect(
7676 makeAcpPlanUpdatedEvent({
7777 stamp,
78 provider: "cursor",
78 provider: ProviderDriverKind.make("cursor"),
7979 threadId: "thread-1" as never,
8080 turnId,
8181 payload: {
......@@ -95,7 +95,7 @@ describe("AcpCoreRuntimeEvents", () => {
9595 expect(
9696 makeAcpToolCallEvent({
9797 stamp,
98 provider: "cursor",
98 provider: ProviderDriverKind.make("cursor"),
9999 threadId: "thread-1" as never,
100100 turnId,
101101 toolCall: {
......@@ -119,7 +119,7 @@ describe("AcpCoreRuntimeEvents", () => {
119119 expect(
120120 makeAcpContentDeltaEvent({
121121 stamp,
122 provider: "cursor",
122 provider: ProviderDriverKind.make("cursor"),
123123 threadId: "thread-1" as never,
124124 turnId,
125125 itemId: "assistant:session-1:segment:0",
......@@ -137,7 +137,7 @@ describe("AcpCoreRuntimeEvents", () => {
137137 expect(
138138 makeAcpAssistantItemEvent({
139139 stamp,
140 provider: "cursor",
140 provider: ProviderDriverKind.make("cursor"),
141141 threadId: "thread-1" as never,
142142 turnId,
143143 itemId: "assistant:session-1:segment:0",
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts+7-7
......@@ -4,7 +4,7 @@ import {
44 type CanonicalRequestType,
55 type EventId,
66 type ProviderApprovalDecision,
7 type ProviderKind,
7 type ProviderDriverKind,
88 type ProviderRuntimeEvent,
99 type RuntimeRequestId,
1010 type ThreadId,
......@@ -78,7 +78,7 @@ function runtimeItemStatusFromAcpToolStatus(
7878
7979export function makeAcpRequestOpenedEvent(input: {
8080 readonly stamp: AcpEventStamp;
81 readonly provider: ProviderKind;
81 readonly provider: ProviderDriverKind;
8282 readonly threadId: ThreadId;
8383 readonly turnId: TurnId | undefined;
8484 readonly requestId: RuntimeRequestId;
......@@ -111,7 +111,7 @@ export function makeAcpRequestOpenedEvent(input: {
111111
112112export function makeAcpRequestResolvedEvent(input: {
113113 readonly stamp: AcpEventStamp;
114 readonly provider: ProviderKind;
114 readonly provider: ProviderDriverKind;
115115 readonly threadId: ThreadId;
116116 readonly turnId: TurnId | undefined;
117117 readonly requestId: RuntimeRequestId;
......@@ -134,7 +134,7 @@ export function makeAcpRequestResolvedEvent(input: {
134134
135135export function makeAcpPlanUpdatedEvent(input: {
136136 readonly stamp: AcpEventStamp;
137 readonly provider: ProviderKind;
137 readonly provider: ProviderDriverKind;
138138 readonly threadId: ThreadId;
139139 readonly turnId: TurnId | undefined;
140140 readonly payload: AcpPlanUpdate;
......@@ -159,7 +159,7 @@ export function makeAcpPlanUpdatedEvent(input: {
159159
160160export function makeAcpToolCallEvent(input: {
161161 readonly stamp: AcpEventStamp;
162 readonly provider: ProviderKind;
162 readonly provider: ProviderDriverKind;
163163 readonly threadId: ThreadId;
164164 readonly turnId: TurnId | undefined;
165165 readonly toolCall: AcpToolCallState;
......@@ -193,7 +193,7 @@ export function makeAcpToolCallEvent(input: {
193193
194194export function makeAcpAssistantItemEvent(input: {
195195 readonly stamp: AcpEventStamp;
196 readonly provider: ProviderKind;
196 readonly provider: ProviderDriverKind;
197197 readonly threadId: ThreadId;
198198 readonly turnId: TurnId | undefined;
199199 readonly itemId: string;
......@@ -215,7 +215,7 @@ export function makeAcpAssistantItemEvent(input: {
215215
216216export function makeAcpContentDeltaEvent(input: {
217217 readonly stamp: AcpEventStamp;
218 readonly provider: ProviderKind;
218 readonly provider: ProviderDriverKind;
219219 readonly threadId: ThreadId;
220220 readonly turnId: TurnId | undefined;
221221 readonly itemId?: string;
apps/server/src/provider/acp/AcpNativeLogging.ts+3-3
......@@ -1,4 +1,4 @@
1import type { ProviderKind, ThreadId } from "@t3tools/contracts";
1import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
22import { Cause, Effect } from "effect";
33import type * as EffectAcpProtocol from "effect-acp/protocol";
44
......@@ -7,7 +7,7 @@ import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpS
77
88function writeNativeAcpLog(input: {
99 readonly nativeEventLogger: EventNdjsonLogger | undefined;
10 readonly provider: ProviderKind;
10 readonly provider: ProviderDriverKind;
1111 readonly threadId: ThreadId;
1212 readonly kind: "request" | "protocol";
1313 readonly payload: unknown;
......@@ -44,7 +44,7 @@ function formatRequestLogPayload(event: AcpSessionRequestLogEvent) {
4444
4545export function makeAcpNativeLoggers(input: {
4646 readonly nativeEventLogger: EventNdjsonLogger | undefined;
47 readonly provider: ProviderKind;
47 readonly provider: ProviderDriverKind;
4848 readonly threadId: ThreadId;
4949}): Pick<AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> {
5050 return {
apps/server/src/provider/acp/AcpSessionRuntime.ts+1-1
......@@ -21,7 +21,7 @@ export interface AcpSpawnInput {
2121 readonly command: string;
2222 readonly args: ReadonlyArray<string>;
2323 readonly cwd?: string;
24 readonly env?: Readonly<Record<string, string>>;
24 readonly env?: NodeJS.ProcessEnv;
2525}
2626
2727export interface AcpSessionRuntimeOptions {
apps/server/src/provider/acp/CursorAcpSupport.ts+4-1
......@@ -23,6 +23,7 @@ export interface CursorAcpRuntimeInput extends Omit<
2323> {
2424 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
2525 readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined;
26 readonly environment?: NodeJS.ProcessEnv;
2627}
2728
2829export interface CursorAcpModelSelectionErrorContext {
......@@ -34,6 +35,7 @@ export interface CursorAcpModelSelectionErrorContext {
3435export function buildCursorAcpSpawnInput(
3536 cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined,
3637 cwd: string,
38 environment?: NodeJS.ProcessEnv,
3739): AcpSpawnInput {
3840 return {
3941 command: cursorSettings?.binaryPath || "agent",
......@@ -42,6 +44,7 @@ export function buildCursorAcpSpawnInput(
4244 "acp",
4345 ],
4446 cwd,
47 ...(environment ? { env: environment } : {}),
4548 };
4649}
4750
......@@ -52,7 +55,7 @@ export const makeCursorAcpRuntime = (
5255 const acpContext = yield* Layer.build(
5356 AcpSessionRuntime.layer({
5457 ...input,
55 spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd),
58 spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment),
5659 authMethodId: "cursor_login",
5760 clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
5861 }).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 @@
1import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
1import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
22import type { Stream } from "effect";
3import type { ProviderAdapterError } from "./Errors.ts";
4import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
53import type { ServerProviderShape } from "./Services/ServerProvider.ts";
64
75export 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;
914 readonly getSnapshot: ServerProviderShape["getSnapshot"];
1015 readonly refresh: ServerProviderShape["refresh"];
1116 readonly streamChanges: Stream.Stream<ServerProvider>;
1217};
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 @@
11import { describe, it, assert } from "@effect/vitest";
2import type { ServerProvider } from "@t3tools/contracts";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
33import { createModelCapabilities } from "@t3tools/shared/model";
44import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";
55
......@@ -21,7 +21,8 @@ interface TestSettings {
2121}
2222
2323const initialSnapshot: ServerProvider = {
24 provider: "codex",
24 instanceId: ProviderInstanceId.make("codex"),
25 driver: ProviderDriverKind.make("codex"),
2526 enabled: true,
2627 installed: true,
2728 version: null,
......@@ -35,7 +36,8 @@ const initialSnapshot: ServerProvider = {
3536};
3637
3738const refreshedSnapshot: ServerProvider = {
38 provider: "codex",
39 instanceId: ProviderInstanceId.make("codex"),
40 driver: ProviderDriverKind.make("codex"),
3941 enabled: true,
4042 installed: true,
4143 version: "1.0.0",
apps/server/src/provider/opencodeRuntime.ts+6-2
......@@ -109,6 +109,7 @@ export interface OpenCodeRuntimeShape {
109109 */
110110 readonly startOpenCodeServerProcess: (input: {
111111 readonly binaryPath: string;
112 readonly environment?: NodeJS.ProcessEnv;
112113 readonly port?: number;
113114 readonly hostname?: string;
114115 readonly timeoutMs?: number;
......@@ -121,6 +122,7 @@ export interface OpenCodeRuntimeShape {
121122 readonly connectToOpenCodeServer: (input: {
122123 readonly binaryPath: string;
123124 readonly serverUrl?: string | null;
125 readonly environment?: NodeJS.ProcessEnv;
124126 readonly port?: number;
125127 readonly hostname?: string;
126128 readonly timeoutMs?: number;
......@@ -128,6 +130,7 @@ export interface OpenCodeRuntimeShape {
128130 readonly runOpenCodeCommand: (input: {
129131 readonly binaryPath: string;
130132 readonly args: ReadonlyArray<string>;
133 readonly environment?: NodeJS.ProcessEnv;
131134 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
132135 readonly createOpenCodeSdkClient: (input: {
133136 readonly baseUrl: string;
......@@ -274,7 +277,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
274277 const child = yield* spawner.spawn(
275278 ChildProcess.make(input.binaryPath, [...input.args], {
276279 shell: process.platform === "win32",
277 env: process.env,
280 env: input.environment ?? process.env,
278281 }),
279282 );
280283 const [stdout, stderr, code] = yield* Effect.all(
......@@ -332,7 +335,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
332335 ChildProcess.make(input.binaryPath, args, {
333336 detached: process.platform !== "win32",
334337 env: {
335 ...process.env,
338 ...(input.environment ?? process.env),
336339 OPENCODE_CONFIG_CONTENT: JSON.stringify({}),
337340 },
338341 }),
......@@ -472,6 +475,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
472475
473476 return startOpenCodeServerProcess({
474477 binaryPath: input.binaryPath,
478 ...(input.environment !== undefined ? { environment: input.environment } : {}),
475479 ...(input.port !== undefined ? { port: input.port } : {}),
476480 ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
477481 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
apps/server/src/provider/providerSnapshot.test.ts+2-2
......@@ -1,5 +1,5 @@
11import { describe, expect, it } from "vitest";
2import type { ModelCapabilities } from "@t3tools/contracts";
2import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts";
33import { createModelCapabilities } from "@t3tools/shared/model";
44
55import { providerModelsFromSettings } from "./providerSnapshot.ts";
......@@ -27,7 +27,7 @@ describe("providerModelsFromSettings", () => {
2727 it("applies the provided capabilities to custom models", () => {
2828 const models = providerModelsFromSettings(
2929 [],
30 "opencode",
30 ProviderDriverKind.make("opencode"),
3131 ["openai/gpt-5"],
3232 OPENCODE_CUSTOM_MODEL_CAPABILITIES,
3333 );
apps/server/src/provider/providerSnapshot.ts+5-4
......@@ -1,4 +1,5 @@
11import type {
2 ProviderDriverKind,
23 ModelCapabilities,
34 ServerProvider,
45 ServerProviderAuth,
......@@ -36,6 +37,8 @@ export interface ServerProviderPresentation {
3637 readonly showInteractionModeToggle?: boolean;
3738}
3839
40export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
41
3942export function nonEmptyTrimmed(value: string | undefined): string | undefined {
4043 if (!value) return undefined;
4144 const trimmed = value.trim();
......@@ -110,7 +113,7 @@ export function parseGenericCliVersion(output: string): string | null {
110113
111114export function providerModelsFromSettings(
112115 builtInModels: ReadonlyArray<ServerProviderModel>,
113 provider: ServerProvider["provider"],
116 provider: ProviderDriverKind,
114117 customModels: ReadonlyArray<string>,
115118 customModelCapabilities: ModelCapabilities,
116119): ReadonlyArray<ServerProviderModel> {
......@@ -179,7 +182,6 @@ export function buildBooleanOptionDescriptor(input: {
179182}
180183
181184export function buildServerProvider(input: {
182 provider: ServerProvider["provider"];
183185 presentation: ServerProviderPresentation;
184186 enabled: boolean;
185187 checkedAt: string;
......@@ -187,9 +189,8 @@ export function buildServerProvider(input: {
187189 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
188190 skills?: ReadonlyArray<ServerProviderSkill>;
189191 probe: ProviderProbeResult;
190}): ServerProvider {
192}): ServerProviderDraft {
191193 return {
192 provider: input.provider,
193194 displayName: input.presentation.displayName,
194195 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
195196 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
apps/server/src/provider/providerStatusCache.test.ts+90-16
......@@ -1,23 +1,33 @@
11import * 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";
38import { createModelCapabilities } from "@t3tools/shared/model";
49import { assert, it } from "@effect/vitest";
510import { Effect, FileSystem } from "effect";
611
712import {
813 hydrateCachedProvider,
14 isCachedProviderCorrelated,
915 readProviderStatusCache,
1016 resolveProviderStatusCachePath,
1117 writeProviderStatusCache,
1218} from "./providerStatusCache.ts";
1319
1420const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
21const CODEX_DRIVER = ProviderDriverKind.make("codex");
22const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
23const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
1524
1625const makeProvider = (
17 provider: ServerProvider["provider"],
26 provider: ProviderDriverKind,
1827 overrides?: Partial<ServerProvider>,
1928): ServerProvider => ({
20 provider,
29 instanceId: defaultInstanceIdForDriver(provider),
30 driver: provider,
2131 enabled: true,
2232 installed: true,
2333 version: "1.0.0",
......@@ -35,26 +45,26 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
3545 Effect.gen(function* () {
3646 const fs = yield* FileSystem.FileSystem;
3747 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" });
38 const codexProvider = makeProvider("codex");
39 const claudeProvider = makeProvider("claudeAgent", {
48 const codexProvider = makeProvider(CODEX_DRIVER);
49 const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, {
4050 status: "warning",
4151 auth: { status: "unknown" },
4252 });
43 const openCodeProvider = makeProvider("opencode", {
53 const openCodeProvider = makeProvider(OPENCODE_DRIVER, {
4454 status: "warning",
4555 auth: { status: "unknown", type: "opencode" },
4656 });
47 const codexPath = resolveProviderStatusCachePath({
57 const codexPath = yield* resolveProviderStatusCachePath({
4858 cacheDir: tempDir,
49 provider: "codex",
59 instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")),
5060 });
51 const claudePath = resolveProviderStatusCachePath({
61 const claudePath = yield* resolveProviderStatusCachePath({
5262 cacheDir: tempDir,
53 provider: "claudeAgent",
63 instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("claudeAgent")),
5464 });
55 const openCodePath = resolveProviderStatusCachePath({
65 const openCodePath = yield* resolveProviderStatusCachePath({
5666 cacheDir: tempDir,
57 provider: "opencode",
67 instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("opencode")),
5868 });
5969
6070 yield* writeProviderStatusCache({
......@@ -77,7 +87,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
7787 );
7888
7989 it("hydrates cached provider status while preserving current settings-derived models", () => {
80 const cachedCodex = makeProvider("codex", {
90 const cachedCodex = makeProvider(CODEX_DRIVER, {
8191 checkedAt: "2026-04-10T12:00:00.000Z",
8292 models: [
8393 {
......@@ -97,7 +107,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
97107 },
98108 ],
99109 });
100 const fallbackCodex = makeProvider("codex", {
110 const fallbackCodex = makeProvider(CODEX_DRIVER, {
101111 models: [
102112 {
103113 slug: "gpt-5.4",
......@@ -138,11 +148,11 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
138148 });
139149
140150 it("ignores stale cached enabled state when the provider is now disabled", () => {
141 const cachedCodex = makeProvider("codex", {
151 const cachedCodex = makeProvider(CODEX_DRIVER, {
142152 checkedAt: "2026-04-10T12:00:00.000Z",
143153 message: "Cached ready status",
144154 });
145 const disabledFallback = makeProvider("codex", {
155 const disabledFallback = makeProvider(CODEX_DRIVER, {
146156 enabled: false,
147157 installed: false,
148158 version: null,
......@@ -159,4 +169,68 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
159169 disabledFallback,
160170 );
161171 });
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 });
162236});
apps/server/src/provider/providerStatusCache.ts+61-19
......@@ -1,25 +1,17 @@
1import * as nodePath from "node:path";
2import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";
3import { Cause, Effect, FileSystem, Schema } from "effect";
1import {
2 type ProviderDriverKind,
3 type ProviderInstanceId,
4 type ServerProvider,
5 ServerProvider as ServerProviderSchema,
6} from "@t3tools/contracts";
7import { Cause, Effect, FileSystem, Path, Schema } from "effect";
48
59import { 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
1411const decodeProviderStatusCache = Schema.decodeUnknownEffect(
1512 Schema.fromJsonString(ServerProviderSchema),
1613);
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
2315const mergeProviderModels = (
2416 fallbackModels: ReadonlyArray<ServerProvider["models"][number]>,
2517 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,
......@@ -32,13 +24,27 @@ export const orderProviderSnapshots = (
3224 providers: ReadonlyArray<ServerProvider>,
3325): ReadonlyArray<ServerProvider> =>
3426 [...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),
3631 );
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
3840export const hydrateCachedProvider = (input: {
3941 readonly cachedProvider: ServerProvider;
4042 readonly fallbackProvider: ServerProvider;
4143}): ServerProvider => {
44 if (!isCachedProviderCorrelated(input)) {
45 return input.fallbackProvider;
46 }
47
4248 if (
4349 !input.fallbackProvider.enabled ||
4450 input.cachedProvider.enabled !== input.fallbackProvider.enabled
......@@ -64,10 +70,46 @@ export const hydrateCachedProvider = (input: {
6470 : hydratedProvider;
6571};
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: {
68107 readonly cacheDir: string;
69 readonly provider: ServerProvider["provider"];
70}) => nodePath.join(input.cacheDir, `${input.provider}.json`);
108 readonly provider: ProviderDriverKind;
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
72114export const readProviderStatusCache = (filePath: string) =>
73115 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 {
1717 type OrchestrationEvent,
1818 ORCHESTRATION_WS_METHODS,
1919 ProjectId,
20 ProviderDriverKind,
21 ProviderInstanceId,
2022 ResolvedKeybindingRule,
2123 ThreadId,
2224 WS_METHODS,
......@@ -110,7 +112,7 @@ const defaultProjectId = ProjectId.make("project-default");
110112const defaultThreadId = ThreadId.make("thread-default");
111113const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token";
112114const defaultModelSelection = {
113 provider: "codex",
115 instanceId: ProviderInstanceId.make("codex"),
114116 model: "gpt-5-codex",
115117} as const;
116118const testEnvironmentDescriptor = {
......@@ -1845,7 +1847,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18451847 Effect.gen(function* () {
18461848 const providers = [
18471849 {
1848 provider: "codex" as const,
1850 instanceId: ProviderInstanceId.make("codex"),
1851 driver: ProviderDriverKind.make("codex"),
18491852 enabled: true,
18501853 installed: true,
18511854 version: "1.0.0",
......@@ -1915,7 +1918,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19151918 Effect.gen(function* () {
19161919 const nextProviders = [
19171920 {
1918 provider: "codex" as const,
1921 instanceId: ProviderInstanceId.make("codex"),
1922 driver: ProviderDriverKind.make("codex"),
19191923 enabled: true,
19201924 installed: true,
19211925 version: "1.0.0",
......@@ -2164,7 +2168,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21642168 workspaceRoot: missingWorkspaceRoot,
21652169 createWorkspaceRootIfMissing: true,
21662170 defaultModelSelection: {
2167 provider: "codex",
2171 instanceId: ProviderInstanceId.make("codex"),
21682172 model: "gpt-5-codex",
21692173 },
21702174 createdAt: new Date().toISOString(),
apps/server/src/server.ts+33-43
......@@ -16,22 +16,20 @@ import { OpenLive } from "./open.ts";
1616import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
1717import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
1818import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";
19import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts";
2019import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
2120import { 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";
2621import { 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";
2824import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
25import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
2926import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
3027import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
3128import { GitCoreLive } from "./git/Layers/GitCore.ts";
3229import { GitHubCliLive } from "./git/Layers/GitHubCli.ts";
3330import { 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";
3533import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
3634import { GitManagerLive } from "./git/Layers/GitManager.ts";
3735import { KeybindingsLive } from "./keybindings.ts";
......@@ -144,41 +142,15 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe(
144142 Layer.provide(ProviderSessionRuntimeRepositoryLive),
145143);
146144
147const ProviderLayerLive = Layer.unwrap(
148 Effect.gen(function* () {
149 const { providerEventLogPath } = yield* ServerConfig;
150 const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
151 stream: "native",
152 });
153 const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
154 stream: "canonical",
155 });
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 }),
145// `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter
146// by looking up the default `ProviderInstance` per driver in the instance
147// registry. Adapter construction itself moved inside each driver's
148// `create()`; `ProviderEventLoggersLive` owns the shared native/canonical
149// NDJSON writers and is provided at the outer runtime layer so both
150// `ProviderService` and the per-instance drivers read the same logger pair.
151const ProviderLayerLive = ProviderServiceLive.pipe(
152 Layer.provide(ProviderAdapterRegistryLive),
153 Layer.provideMerge(ProviderSessionDirectoryLayerLive),
182154);
183155
184156const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
......@@ -187,7 +159,7 @@ const GitManagerLayerLive = GitManagerLive.pipe(
187159 Layer.provideMerge(ProjectSetupScriptRunnerLive),
188160 Layer.provideMerge(GitCoreLive),
189161 Layer.provideMerge(GitHubCliLive),
190 Layer.provideMerge(RoutingTextGenerationLive),
162 Layer.provideMerge(TextGenerationLive),
191163);
192164
193165const GitLayerLive = Layer.empty.pipe(
......@@ -233,6 +205,24 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
233205 Layer.provideMerge(PersistenceLayerLive),
234206 Layer.provideMerge(KeybindingsLive),
235207 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),
236226 Layer.provideMerge(ServerSettingsLive),
237227 Layer.provideMerge(WorkspaceLayerLive),
238228 Layer.provideMerge(ProjectFaviconResolverLive),
apps/server/src/serverRuntimeStartup.test.ts+3-3
......@@ -1,5 +1,5 @@
11import * 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";
33import { assert, it } from "@effect/vitest";
44import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect";
55
......@@ -21,8 +21,8 @@ import {
2121
2222it("uses the canonical Codex default for auto-bootstrapped model selection", () => {
2323 assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), {
24 provider: "codex",
25 model: DEFAULT_MODEL_BY_PROVIDER.codex,
24 instanceId: ProviderInstanceId.make("codex"),
25 model: DEFAULT_MODEL,
2626 });
2727});
2828
apps/server/src/serverRuntimeStartup.ts+4-3
......@@ -1,9 +1,10 @@
11import {
22 CommandId,
3 DEFAULT_MODEL_BY_PROVIDER,
3 DEFAULT_MODEL,
44 DEFAULT_PROVIDER_INTERACTION_MODE,
55 type ModelSelection,
66 ProjectId,
7 ProviderInstanceId,
78 ThreadId,
89} from "@t3tools/contracts";
910import {
......@@ -154,8 +155,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(
154155);
155156
156157export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
157 provider: "codex",
158 model: DEFAULT_MODEL_BY_PROVIDER.codex,
158 instanceId: ProviderInstanceId.make("codex"),
159 model: DEFAULT_MODEL,
159160});
160161
161162export const resolveWelcomeBase = Effect.gen(function* () {
apps/server/src/serverSettings.test.ts+222-20
......@@ -1,5 +1,11 @@
11import * 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";
39import { createModelSelection } from "@t3tools/shared/model";
410import { assert, it } from "@effect/vitest";
511import { Effect, FileSystem, Layer, Schema } from "effect";
......@@ -49,14 +55,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
4955
5056 const decoded = decode({
5157 textGenerationModelSelection: {
52 provider: "codex",
58 provider: ProviderDriverKind.make("codex"),
5359 model: "gpt-5.4-mini",
5460 options: { reasoningEffort: "low" },
5561 },
5662 });
5763
5864 assert.deepEqual(decoded.textGenerationModelSelection, {
59 provider: "codex",
65 instanceId: ProviderInstanceId.make("codex"),
6066 model: "gpt-5.4-mini",
6167 options: [{ id: "reasoningEffort", value: "low" }],
6268 });
......@@ -79,10 +85,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
7985 },
8086 },
8187 textGenerationModelSelection: {
82 provider: "codex",
88 instanceId: ProviderInstanceId.make("codex"),
8389 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
8490 options: createModelSelection(
85 "codex",
91 ProviderInstanceId.make("codex"),
8692 DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
8793 [
8894 { id: "reasoningEffort", value: "high" },
......@@ -107,20 +113,26 @@ it.layer(NodeServices.layer)("server settings", (it) => {
107113 enabled: true,
108114 binaryPath: "/opt/homebrew/bin/codex",
109115 homePath: "/Users/julius/.codex",
116 shadowHomePath: "",
110117 customModels: [],
111118 });
112119 assert.deepEqual(next.providers.claudeAgent, {
113120 enabled: true,
114121 binaryPath: "/usr/local/bin/claude",
122 homePath: "",
115123 customModels: ["claude-custom"],
116124 launchArgs: "",
117125 });
118126 assert.deepEqual(
119127 next.textGenerationModelSelection,
120 createModelSelection("codex", DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [
121 { id: "reasoningEffort", value: "high" },
122 { id: "fastMode", value: false },
123 ]),
128 createModelSelection(
129 ProviderInstanceId.make("codex"),
130 DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
131 [
132 { id: "reasoningEffort", value: "high" },
133 { id: "fastMode", value: false },
134 ],
135 ),
124136 );
125137 }).pipe(Effect.provide(makeServerSettingsLayer())),
126138 );
......@@ -132,11 +144,13 @@ it.layer(NodeServices.layer)("server settings", (it) => {
132144 // Start with Claude text generation selection
133145 yield* serverSettings.updateSettings({
134146 textGenerationModelSelection: {
135 provider: "claudeAgent",
147 instanceId: ProviderInstanceId.make("claudeAgent"),
136148 model: "claude-sonnet-4-6",
137 options: createModelSelection("claudeAgent", "claude-sonnet-4-6", [
138 { id: "effort", value: "high" },
139 ]).options!,
149 options: createModelSelection(
150 ProviderInstanceId.make("claudeAgent"),
151 "claude-sonnet-4-6",
152 [{ id: "effort", value: "high" }],
153 ).options!,
140154 },
141155 });
142156
......@@ -144,9 +158,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
144158 // cause the update to lose the selected model.
145159 const next = yield* serverSettings.updateSettings({
146160 textGenerationModelSelection: {
147 provider: "codex",
161 instanceId: ProviderInstanceId.make("codex"),
148162 model: "gpt-5.4",
149 options: createModelSelection("codex", "gpt-5.4", [
163 options: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
150164 { id: "reasoningEffort", value: "high" },
151165 ]).options!,
152166 },
......@@ -154,21 +168,107 @@ it.layer(NodeServices.layer)("server settings", (it) => {
154168
155169 assert.deepEqual(
156170 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 ]),
158174 );
159175 }).pipe(Effect.provide(makeServerSettingsLayer())),
160176 );
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
162262 it.effect("drops stale text generation options when resetting model selection", () =>
163263 Effect.gen(function* () {
164264 const serverSettings = yield* ServerSettingsService;
165265
166266 yield* serverSettings.updateSettings({
167267 textGenerationModelSelection: {
168 provider: "codex",
268 instanceId: ProviderInstanceId.make("codex"),
169269 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
170270 options: createModelSelection(
171 "codex",
271 ProviderInstanceId.make("codex"),
172272 DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
173273 [
174274 { id: "reasoningEffort", value: "high" },
......@@ -180,18 +280,55 @@ it.layer(NodeServices.layer)("server settings", (it) => {
180280
181281 const next = yield* serverSettings.updateSettings({
182282 textGenerationModelSelection: {
183 provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider,
283 instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId,
184284 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
185285 },
186286 });
187287
188288 assert.deepEqual(next.textGenerationModelSelection, {
189 provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider,
289 instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId,
190290 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
191291 });
192292 }).pipe(Effect.provide(makeServerSettingsLayer())),
193293 );
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
195332 it.effect("trims provider path settings when updates are applied", () =>
196333 Effect.gen(function* () {
197334 const serverSettings = yield* ServerSettingsService;
......@@ -217,11 +354,13 @@ it.layer(NodeServices.layer)("server settings", (it) => {
217354 enabled: true,
218355 binaryPath: "/opt/homebrew/bin/codex",
219356 homePath: "",
357 shadowHomePath: "",
220358 customModels: [],
221359 });
222360 assert.deepEqual(next.providers.claudeAgent, {
223361 enabled: true,
224362 binaryPath: "/opt/homebrew/bin/claude",
363 homePath: "",
225364 customModels: [],
226365 launchArgs: "",
227366 });
......@@ -318,4 +457,67 @@ it.layer(NodeServices.layer)("server settings", (it) => {
318457 });
319458 }).pipe(Effect.provide(makeServerSettingsLayer())),
320459 );
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 );
321523});
apps/server/src/serverSettings.ts+232-14
......@@ -11,10 +11,15 @@
1111 * @module ServerSettings
1212 */
1313import {
14 DEFAULT_GIT_TEXT_GENERATION_MODEL,
1415 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
1516 DEFAULT_SERVER_SETTINGS,
17 isProviderDriverKind,
1618 type ModelSelection,
17 type ProviderKind,
19 type ProviderInstanceConfig,
20 type ProviderInstanceEnvironmentVariable,
21 ProviderDriverKind,
22 ProviderInstanceId,
1823 ServerSettings,
1924 ServerSettingsError,
2025 type ServerSettingsPatch,
......@@ -44,6 +49,47 @@ import { ServerConfig } from "./config.ts";
4449import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
4550import { fromLenientJson } from "@t3tools/shared/schemaJson";
4651import { 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
4894export interface ServerSettingsShape {
4995 /** Start the settings runtime and attach file watching. */
......@@ -106,7 +152,13 @@ export class ServerSettingsService extends Context.Service<
106152
107153const 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
111163/**
112164 * Ensure the `textGenerationModelSelection` points to an enabled provider.
......@@ -116,22 +168,36 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "openco
116168 */
117169function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
118170 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 ) {
120180 return settings;
121181 }
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;
124189 if (!fallback) {
125 // No providers enabled — return as-is; callers will report the error.
126190 return settings;
127191 }
128192
129193 return {
130194 ...settings,
131195 textGenerationModelSelection: {
132 provider: fallback,
133 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback],
134 } as ModelSelection,
196 instanceId: ProviderInstanceId.make(fallback),
197 model:
198 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
199 DEFAULT_GIT_TEXT_GENERATION_MODEL,
200 } satisfies ModelSelection,
135201 };
136202}
137203
......@@ -176,6 +242,7 @@ const makeServerSettings = Effect.gen(function* () {
176242 const { settingsPath } = yield* ServerConfig;
177243 const fs = yield* FileSystem.FileSystem;
178244 const pathService = yield* Path.Path;
245 const secretStore = yield* ServerSecretStore;
179246 const writeSemaphore = yield* Semaphore.make(1);
180247 const cacheKey = "settings" as const;
181248 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
......@@ -233,6 +300,138 @@ const makeServerSettings = Effect.gen(function* () {
233300
234301 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
236435 const writeSettingsAtomically = (settings: ServerSettings) => {
237436 const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
238437
......@@ -324,14 +523,19 @@ const makeServerSettings = Effect.gen(function* () {
324523 return {
325524 start,
326525 ready: Deferred.await(startedDeferred),
327 getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)),
526 getSettings: getSettingsFromCache.pipe(
527 Effect.flatMap(materializeProviderEnvironmentSecrets),
528 Effect.map(resolveTextGenerationProvider),
529 ),
328530 updateSettings: (patch) =>
329531 writeSemaphore.withPermits(1)(
330532 Effect.gen(function* () {
331533 const current = yield* getSettingsFromCache;
332 const next = yield* Schema.decodeEffect(ServerSettings)(
534 const nextPersisted = yield* persistProviderEnvironmentSecrets(
535 current,
333536 applyServerSettingsPatch(current, patch),
334 ).pipe(
537 );
538 const next = yield* Schema.decodeEffect(ServerSettings)(nextPersisted).pipe(
335539 Effect.mapError(
336540 (cause) =>
337541 new ServerSettingsError({
......@@ -344,13 +548,27 @@ const makeServerSettings = Effect.gen(function* () {
344548 yield* writeSettingsAtomically(next);
345549 yield* Cache.set(settingsCache, cacheKey, next);
346550 yield* emitChange(next);
347 return resolveTextGenerationProvider(next);
551 const materialized = yield* materializeProviderEnvironmentSecrets(next);
552 return resolveTextGenerationProvider(materialized);
348553 }),
349554 ),
350555 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 );
352568 },
353569 } satisfies ServerSettingsShape;
354570});
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 {
4545import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
4646import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
4747import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
48import { ServerSettingsService } from "./serverSettings.ts";
48import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
4949import { TerminalManager } from "./terminal/Services/Manager.ts";
5050import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
5151import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
......@@ -512,7 +512,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
512512 const loadServerConfig = Effect.gen(function* () {
513513 const keybindingsConfig = yield* keybindings.loadConfigState;
514514 const providers = yield* providerRegistry.getProviders;
515 const settings = yield* serverSettings.getSettings;
515 const settings = redactServerSettingsForClient(yield* serverSettings.getSettings);
516516 const environment = yield* serverEnvironment.getDescriptor;
517517 const auth = yield* serverAuth.getDescriptor();
518518
......@@ -749,10 +749,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
749749 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {
750750 "rpc.aggregate": "server",
751751 }),
752 [WS_METHODS.serverRefreshProviders]: (_input) =>
752 [WS_METHODS.serverRefreshProviders]: (input) =>
753753 observeRpcEffect(
754754 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 }))),
756759 { "rpc.aggregate": "server" },
757760 ),
758761 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
......@@ -765,13 +768,21 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
765768 { "rpc.aggregate": "server" },
766769 ),
767770 [WS_METHODS.serverGetSettings]: (_input) =>
768 observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, {
769 "rpc.aggregate": "server",
770 }),
771 observeRpcEffect(
772 WS_METHODS.serverGetSettings,
773 serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)),
774 {
775 "rpc.aggregate": "server",
776 },
777 ),
771778 [WS_METHODS.serverUpdateSettings]: ({ patch }) =>
772 observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), {
773 "rpc.aggregate": "server",
774 }),
779 observeRpcEffect(
780 WS_METHODS.serverUpdateSettings,
781 serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)),
782 {
783 "rpc.aggregate": "server",
784 },
785 ),
775786 [WS_METHODS.projectsSearchEntries]: (input) =>
776787 observeRpcEffect(
777788 WS_METHODS.projectsSearchEntries,
......@@ -976,6 +987,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
976987 Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)),
977988 );
978989 const settingsUpdates = serverSettings.streamChanges.pipe(
990 Stream.map((settings) => redactServerSettingsForClient(settings)),
979991 Stream.map((settings) => ({
980992 version: 1 as const,
981993 type: "settingsUpdated" as const,
......@@ -983,13 +995,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
983995 })),
984996 );
985997
986 yield* Effect.all(
987 [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")],
988 {
989 concurrency: "unbounded",
990 discard: true,
991 },
992 ).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
998 yield* providerRegistry
999 .refresh()
1000 .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
9931001
9941002 const liveUpdates = Stream.merge(
9951003 keybindingsUpdates,
apps/web/src/components/ChatView.browser.tsx+184-37
......@@ -9,6 +9,8 @@ import {
99 type MessageId,
1010 type OrchestrationReadModel,
1111 type ProjectId,
12 ProviderDriverKind,
13 ProviderInstanceId,
1214 type ServerConfig,
1315 type ServerLifecycleWelcomePayload,
1416 type ThreadId,
......@@ -167,7 +169,8 @@ function createBaseServerConfig(): ServerConfig {
167169 issues: [],
168170 providers: [
169171 {
170 provider: "codex",
172 driver: ProviderDriverKind.make("codex"),
173 instanceId: ProviderInstanceId.make("codex"),
171174 enabled: true,
172175 installed: true,
173176 version: "0.116.0",
......@@ -323,7 +326,7 @@ function createSnapshotForTargetUser(options: {
323326 title: "Project",
324327 workspaceRoot: "/repo/project",
325328 defaultModelSelection: {
326 provider: "codex",
329 instanceId: ProviderInstanceId.make("codex"),
327330 model: "gpt-5",
328331 },
329332 scripts: [],
......@@ -338,7 +341,7 @@ function createSnapshotForTargetUser(options: {
338341 projectId: PROJECT_ID,
339342 title: THREAD_TITLE,
340343 modelSelection: {
341 provider: "codex",
344 instanceId: ProviderInstanceId.make("codex"),
342345 model: "gpt-5",
343346 },
344347 interactionMode: "default",
......@@ -403,7 +406,7 @@ function addThreadToSnapshot(
403406 projectId: PROJECT_ID,
404407 title: "New thread",
405408 modelSelection: {
406 provider: "codex",
409 instanceId: ProviderInstanceId.make("codex"),
407410 model: "gpt-5",
408411 },
409412 interactionMode: "default",
......@@ -740,7 +743,7 @@ function createSnapshotWithSecondaryProject(options?: {
740743 id: "thread-secondary-project" as ThreadId,
741744 projectId: SECOND_PROJECT_ID,
742745 title: "Release checklist",
743 modelSelection: { provider: "codex", model: "gpt-5" },
746 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
744747 interactionMode: "default",
745748 runtimeMode: "full-access",
746749 branch: "release/docs-portal",
......@@ -772,7 +775,7 @@ function createSnapshotWithSecondaryProject(options?: {
772775 id: ARCHIVED_SECONDARY_THREAD_ID,
773776 projectId: SECOND_PROJECT_ID,
774777 title: "Archived Docs Notes",
775 modelSelection: { provider: "codex", model: "gpt-5" },
778 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
776779 interactionMode: "default",
777780 runtimeMode: "full-access",
778781 branch: "release/docs-archive",
......@@ -807,7 +810,7 @@ function createSnapshotWithSecondaryProject(options?: {
807810 id: SECOND_PROJECT_ID,
808811 title: "Docs Portal",
809812 workspaceRoot: "/repo/clients/docs-portal",
810 defaultModelSelection: { provider: "codex", model: "gpt-5" },
813 defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
811814 scripts: [],
812815 createdAt: NOW_ISO,
813816 updatedAt: NOW_ISO,
......@@ -884,7 +887,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
884887}
885888
886889function createSnapshotWithPlanFollowUpPrompt(options?: {
887 modelSelection?: { provider: "codex"; model: string };
890 modelSelection?: { instanceId: ProviderInstanceId; model: string };
888891 planMarkdown?: string;
889892}): OrchestrationReadModel {
890893 const snapshot = createSnapshotForTargetUser({
......@@ -892,7 +895,7 @@ function createSnapshotWithPlanFollowUpPrompt(options?: {
892895 targetText: "plan follow-up thread",
893896 });
894897 const modelSelection = options?.modelSelection ?? {
895 provider: "codex" as const,
898 instanceId: ProviderInstanceId.make("codex"),
896899 model: "gpt-5",
897900 };
898901 const planMarkdown =
......@@ -2438,6 +2441,126 @@ describe("ChatView timeline estimator parity (full app)", () => {
24382441 }
24392442 });
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
24412564 it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => {
24422565 const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
24432566 const mounted = await mountChatView({
......@@ -3778,12 +3901,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
37783901 it("snapshots sticky codex settings into a new draft thread", async () => {
37793902 useComposerDraftStore.setState({
37803903 stickyModelSelectionByProvider: {
3781 codex: createModelSelection("codex", "gpt-5.3-codex", [
3782 { id: "reasoningEffort", value: "medium" },
3783 { id: "fastMode", value: true },
3784 ]),
3904 [ProviderInstanceId.make("codex")]: createModelSelection(
3905 ProviderInstanceId.make("codex"),
3906 "gpt-5.3-codex",
3907 [
3908 { id: "reasoningEffort", value: "medium" },
3909 { id: "fastMode", value: true },
3910 ],
3911 ),
37853912 },
3786 stickyActiveProvider: "codex",
3913 stickyActiveProvider: ProviderInstanceId.make("codex"),
37873914 });
37883915
37893916 const mounted = await mountChatView({
......@@ -3814,7 +3941,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
38143941 expect(composerDraftFor(newDraftId)).toMatchObject({
38153942 modelSelectionByProvider: {
38163943 codex: {
3817 provider: "codex",
3944 instanceId: ProviderInstanceId.make("codex"),
38183945 model: "gpt-5.3-codex",
38193946 options: expect.arrayContaining([{ id: "fastMode", value: true }]),
38203947 },
......@@ -3829,12 +3956,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
38293956 it("hydrates the provider alongside a sticky claude model", async () => {
38303957 useComposerDraftStore.setState({
38313958 stickyModelSelectionByProvider: {
3832 claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [
3833 { id: "effort", value: "max" },
3834 { id: "fastMode", value: true },
3835 ]),
3959 [ProviderInstanceId.make("claudeAgent")]: createModelSelection(
3960 ProviderInstanceId.make("claudeAgent"),
3961 "claude-opus-4-6",
3962 [
3963 { id: "effort", value: "max" },
3964 { id: "fastMode", value: true },
3965 ],
3966 ),
38363967 },
3837 stickyActiveProvider: "claudeAgent",
3968 stickyActiveProvider: ProviderInstanceId.make("claudeAgent"),
38383969 });
38393970
38403971 const mounted = await mountChatView({
......@@ -3860,10 +3991,14 @@ describe("ChatView timeline estimator parity (full app)", () => {
38603991
38613992 expect(composerDraftFor(newDraftId)).toMatchObject({
38623993 modelSelectionByProvider: {
3863 claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [
3864 { id: "effort", value: "max" },
3865 { id: "fastMode", value: true },
3866 ]),
3994 claudeAgent: createModelSelection(
3995 ProviderInstanceId.make("claudeAgent"),
3996 "claude-opus-4-6",
3997 [
3998 { id: "effort", value: "max" },
3999 { id: "fastMode", value: true },
4000 ],
4001 ),
38674002 },
38684003 activeProvider: "claudeAgent",
38694004 });
......@@ -3903,12 +4038,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
39034038 it("prefers draft state over sticky composer settings and defaults", async () => {
39044039 useComposerDraftStore.setState({
39054040 stickyModelSelectionByProvider: {
3906 codex: createModelSelection("codex", "gpt-5.3-codex", [
3907 { id: "reasoningEffort", value: "medium" },
3908 { id: "fastMode", value: true },
3909 ]),
4041 [ProviderInstanceId.make("codex")]: createModelSelection(
4042 ProviderInstanceId.make("codex"),
4043 "gpt-5.3-codex",
4044 [
4045 { id: "reasoningEffort", value: "medium" },
4046 { id: "fastMode", value: true },
4047 ],
4048 ),
39104049 },
3911 stickyActiveProvider: "codex",
4050 stickyActiveProvider: ProviderInstanceId.make("codex"),
39124051 });
39134052
39144053 const mounted = await mountChatView({
......@@ -3938,7 +4077,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
39384077 expect(composerDraftFor(draftId)).toMatchObject({
39394078 modelSelectionByProvider: {
39404079 codex: {
3941 provider: "codex",
4080 instanceId: ProviderInstanceId.make("codex"),
39424081 model: "gpt-5.3-codex",
39434082 options: expect.arrayContaining([{ id: "fastMode", value: true }]),
39444083 },
......@@ -3948,7 +4087,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
39484087
39494088 useComposerDraftStore.getState().setModelSelection(
39504089 draftId,
3951 createModelSelection("codex", "gpt-5.4", [
4090 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
39524091 { id: "reasoningEffort", value: "low" },
39534092 { id: "fastMode", value: true },
39544093 ]),
......@@ -3963,7 +4102,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
39634102 );
39644103 expect(composerDraftFor(draftId)).toMatchObject({
39654104 modelSelectionByProvider: {
3966 codex: createModelSelection("codex", "gpt-5.4", [
4105 codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
39674106 { id: "reasoningEffort", value: "low" },
39684107 { id: "fastMode", value: true },
39694108 ]),
......@@ -5435,7 +5574,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
54355574 const mounted = await mountChatView({
54365575 viewport: WIDE_FOOTER_VIEWPORT,
54375576 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 },
54395581 planMarkdown:
54405582 "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative",
54415583 }),
......@@ -5465,7 +5607,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
54655607 const mounted = await mountChatView({
54665608 viewport: WIDE_FOOTER_VIEWPORT,
54675609 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 },
54695614 planMarkdown:
54705615 "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative",
54715616 }),
......@@ -5589,14 +5734,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
55895734 projects: snapshot.projects.map((project) =>
55905735 project.id === PROJECT_ID
55915736 ? Object.assign({}, project, {
5592 defaultModelSelection: { provider: "codex", model: "gpt-5.4" },
5737 defaultModelSelection: {
5738 instanceId: ProviderInstanceId.make("codex"),
5739 model: "gpt-5.4",
5740 },
55935741 })
55945742 : project,
55955743 ),
55965744 threads: snapshot.threads.map((thread) =>
55975745 thread.id === THREAD_ID
55985746 ? Object.assign({}, thread, {
5599 modelSelection: { provider: "codex", model: "gpt-5.4" },
5747 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
56005748 })
56015749 : thread,
56025750 ),
......@@ -5647,7 +5795,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
56475795 providers: [
56485796 {
56495797 ...nextFixture.serverConfig.providers[0]!,
5650 provider: "codex",
56515798 models: [
56525799 {
56535800 slug: "gpt-5.1-codex-max",
apps/web/src/components/ChatView.logic.test.ts+17-10
......@@ -1,5 +1,12 @@
11import { 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";
310import { afterEach, describe, expect, it, vi } from "vitest";
411import { type EnvironmentState, useStore } from "../store";
512import { type Thread } from "../types";
......@@ -220,7 +227,7 @@ const makeThread = (input?: {
220227 codexThreadId: null,
221228 projectId: ProjectId.make("project-1"),
222229 title: "Thread",
223 modelSelection: { provider: "codex" as const, model: "gpt-5.4" },
230 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
224231 runtimeMode: "full-access" as const,
225232 interactionMode: "default" as const,
226233 session: null,
......@@ -253,7 +260,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>)
253260 name: "Project",
254261 cwd: "/tmp/project",
255262 defaultModelSelection: {
256 provider: "codex",
263 instanceId: ProviderInstanceId.make("codex"),
257264 model: "gpt-5.4",
258265 },
259266 createdAt: "2026-03-29T00:00:00.000Z",
......@@ -452,7 +459,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
452459 };
453460
454461 const previousSession = {
455 provider: "codex" as const,
462 provider: ProviderDriverKind.make("codex"),
456463 status: "ready" as const,
457464 createdAt: "2026-03-29T00:00:00.000Z",
458465 updatedAt: "2026-03-29T00:00:10.000Z",
......@@ -466,7 +473,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
466473 codexThreadId: null,
467474 projectId,
468475 title: "Thread",
469 modelSelection: { provider: "codex", model: "gpt-5.4" },
476 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
470477 runtimeMode: "full-access",
471478 interactionMode: "default",
472479 session: previousSession,
......@@ -503,7 +510,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
503510 codexThreadId: null,
504511 projectId,
505512 title: "Thread",
506 modelSelection: { provider: "codex", model: "gpt-5.4" },
513 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
507514 runtimeMode: "full-access",
508515 interactionMode: "default",
509516 session: previousSession,
......@@ -549,7 +556,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
549556 codexThreadId: null,
550557 projectId,
551558 title: "Thread",
552 modelSelection: { provider: "codex", model: "gpt-5.4" },
559 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
553560 runtimeMode: "full-access",
554561 interactionMode: "default",
555562 session: previousSession,
......@@ -592,7 +599,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
592599 codexThreadId: null,
593600 projectId,
594601 title: "Thread",
595 modelSelection: { provider: "codex", model: "gpt-5.4" },
602 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
596603 runtimeMode: "full-access",
597604 interactionMode: "default",
598605 session: previousSession,
......@@ -635,7 +642,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
635642 codexThreadId: null,
636643 projectId,
637644 title: "Thread",
638 modelSelection: { provider: "codex", model: "gpt-5.4" },
645 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
639646 runtimeMode: "full-access",
640647 interactionMode: "default",
641648 session: previousSession,
......@@ -685,7 +692,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
685692 codexThreadId: null,
686693 projectId,
687694 title: "Thread",
688 modelSelection: { provider: "codex", model: "gpt-5.4" },
695 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
689696 runtimeMode: "full-access",
690697 interactionMode: "default",
691698 session: previousSession,
apps/web/src/components/ChatView.logic.ts+30-5
......@@ -1,8 +1,9 @@
11import {
22 type EnvironmentId,
3 isProviderDriverKind,
34 ProjectId,
45 type ModelSelection,
5 type ProviderKind,
6 type ProviderDriverKind,
67 type ScopedThreadRef,
78 type ThreadId,
89 type TurnId,
......@@ -226,15 +227,39 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {
226227 );
227228}
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.
229242export function deriveLockedProvider(input: {
230243 thread: Thread | null | undefined;
231 selectedProvider: ProviderKind | null;
232 threadProvider: ProviderKind | null;
233}): ProviderKind | null {
244 selectedProvider: string | null;
245 threadProvider: string | null;
246}): ProviderDriverKind | null {
234247 if (!threadHasStarted(input.thread)) {
235248 return null;
236249 }
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;
238263}
239264
240265export async function waitForStartedServerThread(
apps/web/src/components/ChatView.tsx+65-24
......@@ -1,13 +1,14 @@
11import {
22 type ApprovalRequestId,
3 DEFAULT_MODEL_BY_PROVIDER,
3 DEFAULT_MODEL,
4 defaultInstanceIdForDriver,
45 type EnvironmentId,
56 type MessageId,
67 type ModelSelection,
78 type ProjectScript,
8 type ProviderKind,
99 type ProjectId,
1010 type ProviderApprovalDecision,
11 ProviderInstanceId,
1112 type ServerProvider,
1213 type ResolvedKeybindingsConfig,
1314 type ScopedThreadRef,
......@@ -16,6 +17,7 @@ import {
1617 type KeybindingCommand,
1718 OrchestrationThreadActivity,
1819 ProviderInteractionMode,
20 ProviderDriverKind,
1921 RuntimeMode,
2022 TerminalOpenInput,
2123} from "@t3tools/contracts";
......@@ -115,7 +117,7 @@ import {
115117import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
116118import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
117119import { useSettings } from "../hooks/useSettings";
118import { resolveAppModelSelection } from "../modelSelection";
120import { resolveAppModelSelectionForInstance } from "../modelSelection";
119121import { isTerminalFocused } from "../lib/terminalFocus";
120122import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
121123import {
......@@ -302,7 +304,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog
302304}
303305
304306function formatOutgoingPrompt(params: {
305 provider: ProviderKind;
307 provider: ProviderDriverKind;
306308 model: string | null;
307309 models: ReadonlyArray<ServerProvider["models"][number]>;
308310 effort: string | null;
......@@ -777,8 +779,8 @@ export default function ChatView(props: ChatViewProps) {
777779 threadId,
778780 draftThread,
779781 fallbackDraftProject?.defaultModelSelection ?? {
780 provider: "codex",
781 model: DEFAULT_MODEL_BY_PROVIDER.codex,
782 instanceId: ProviderInstanceId.make("codex"),
783 model: DEFAULT_MODEL,
782784 },
783785 localDraftError,
784786 )
......@@ -1031,7 +1033,9 @@ export default function ChatView(props: ChatViewProps) {
10311033
10321034 const selectedProviderByThreadId = composerActiveProvider ?? null;
10331035 const threadProvider =
1034 activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null;
1036 activeThread?.modelSelection.instanceId ??
1037 activeProject?.defaultModelSelection?.instanceId ??
1038 null;
10351039 const lockedProvider = deriveLockedProvider({
10361040 thread: activeThread,
10371041 selectedProvider: selectedProviderByThreadId,
......@@ -1051,9 +1055,9 @@ export default function ChatView(props: ChatViewProps) {
10511055 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
10521056 const unlockedSelectedProvider = resolveSelectableProvider(
10531057 providerStatuses,
1054 selectedProviderByThreadId ?? threadProvider ?? "codex",
1058 selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"),
10551059 );
1056 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
1060 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
10571061 const phase = derivePhase(activeThread?.session ?? null);
10581062 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
10591063 const workLogEntries = useMemo(
......@@ -1425,10 +1429,24 @@ export default function ChatView(props: ChatViewProps) {
14251429 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
14261430 const keybindings = useServerKeybindings();
14271431 const availableEditors = useServerAvailableEditors();
1428 const activeProviderStatus = useMemo(
1429 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
1430 [selectedProvider, providerStatuses],
1431 );
1432 // Prefer an instance-id match so a custom Codex instance (e.g.
1433 // `codex_personal`) surfaces its own status/message in the banner rather
1434 // than the default Codex's. Falls back to first-match-by-kind when no
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]);
14321450 const activeProjectCwd = activeProject?.cwd ?? null;
14331451 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
14341452 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
......@@ -1945,7 +1963,7 @@ export default function ChatView(props: ChatViewProps) {
19451963 if (
19461964 input.modelSelection !== undefined &&
19471965 (input.modelSelection.model !== serverThread.modelSelection.model ||
1948 input.modelSelection.provider !== serverThread.modelSelection.provider ||
1966 input.modelSelection.instanceId !== serverThread.modelSelection.instanceId ||
19491967 JSON.stringify(input.modelSelection.options ?? null) !==
19501968 JSON.stringify(serverThread.modelSelection.options ?? null))
19511969 ) {
......@@ -2559,10 +2577,8 @@ export default function ChatView(props: ChatViewProps) {
25592577 }
25602578 const title = truncate(titleSeed);
25612579 const threadCreateModelSelection = createModelSelection(
2562 ctxSelectedProvider,
2563 ctxSelectedModel ||
2564 activeProject.defaultModelSelection?.model ||
2565 DEFAULT_MODEL_BY_PROVIDER.codex,
2580 ctxSelectedModelSelection.instanceId,
2581 ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL,
25662582 ctxSelectedModelSelection.options,
25672583 );
25682584
......@@ -3128,21 +3144,46 @@ export default function ChatView(props: ChatViewProps) {
31283144 ]);
31293145
31303146 const onProviderModelSelect = useCallback(
3131 (provider: ProviderKind, model: string) => {
3147 (instanceId: ProviderInstanceId, model: string) => {
31323148 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 ) {
31343159 scheduleComposerFocus();
31353160 return;
31363161 }
3137 const resolvedProvider = resolveSelectableProvider(providerStatuses, provider);
3138 const resolvedModel = resolveAppModelSelection(
3139 resolvedProvider,
3162 if (lockedProvider !== null && activeThread.session?.providerInstanceId) {
3163 const currentEntry = providerStatuses.find(
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,
31403177 settings,
31413178 providerStatuses,
31423179 model,
31433180 );
3181 if (!resolvedModel) {
3182 scheduleComposerFocus();
3183 return;
3184 }
31443185 const nextModelSelection: ModelSelection = {
3145 provider: resolvedProvider,
3186 instanceId,
31463187 model: resolvedModel,
31473188 };
31483189 setComposerDraftModelSelection(
apps/web/src/components/CommandPalette.logic.test.ts+2-2
......@@ -1,5 +1,5 @@
11import { describe, expect, it, vi } from "vitest";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
33import type { Thread } from "../types";
44import {
55 buildThreadActionItems,
......@@ -17,7 +17,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1717 codexThreadId: null,
1818 projectId: PROJECT_ID,
1919 title: "Thread",
20 modelSelection: { provider: "codex", model: "gpt-5" },
20 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
2121 runtimeMode: "full-access",
2222 interactionMode: "default",
2323 session: null,
apps/web/src/components/CommandPalette.tsx+4-3
......@@ -2,10 +2,11 @@
22
33import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
44import {
5 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_MODEL,
66 type EnvironmentId,
77 type FilesystemBrowseResult,
88 type ProjectId,
9 ProviderInstanceId,
910} from "@t3tools/contracts";
1011import { useQuery, useQueryClient } from "@tanstack/react-query";
1112import { useNavigate, useParams } from "@tanstack/react-router";
......@@ -786,8 +787,8 @@ function OpenCommandPaletteDialog() {
786787 workspaceRoot: cwd,
787788 createWorkspaceRootIfMissing: true,
788789 defaultModelSelection: {
789 provider: "codex",
790 model: DEFAULT_MODEL_BY_PROVIDER.codex,
790 instanceId: ProviderInstanceId.make("codex"),
791 model: DEFAULT_MODEL,
791792 },
792793 createdAt: new Date().toISOString(),
793794 });
apps/web/src/components/Icons.tsx+23
......@@ -588,3 +588,26 @@ export const GithubCopilotIcon: Icon = ({ className, ...props }) => (
588588 <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" />
589589 </svg>
590590);
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 {
77 type MessageId,
88 type OrchestrationReadModel,
99 type ProjectId,
10 ProviderDriverKind,
11 ProviderInstanceId,
1012 type ServerConfig,
1113 type ServerLifecycleWelcomePayload,
1214 type ThreadId,
......@@ -72,7 +74,8 @@ function createBaseServerConfig(): ServerConfig {
7274 issues: [],
7375 providers: [
7476 {
75 provider: "codex",
77 driver: ProviderDriverKind.make("codex"),
78 instanceId: ProviderInstanceId.make("codex"),
7679 enabled: true,
7780 installed: true,
7881 version: "0.116.0",
......@@ -95,10 +98,25 @@ function createBaseServerConfig(): ServerConfig {
9598 ...DEFAULT_SERVER_SETTINGS,
9699 enableAssistantStreaming: false,
97100 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 },
99105 providers: {
100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },
106 codex: {
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 },
102120 cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] },
103121 opencode: {
104122 enabled: true,
......@@ -121,7 +139,7 @@ function createMinimalSnapshot(): OrchestrationReadModel {
121139 title: "Project",
122140 workspaceRoot: "/repo/project",
123141 defaultModelSelection: {
124 provider: "codex",
142 instanceId: ProviderInstanceId.make("codex"),
125143 model: "gpt-5",
126144 },
127145 scripts: [],
......@@ -136,7 +154,7 @@ function createMinimalSnapshot(): OrchestrationReadModel {
136154 projectId: PROJECT_ID,
137155 title: "Test thread",
138156 modelSelection: {
139 provider: "codex",
157 instanceId: ProviderInstanceId.make("codex"),
140158 model: "gpt-5",
141159 },
142160 interactionMode: "default",
apps/web/src/components/Sidebar.logic.test.ts+11-4
......@@ -1,4 +1,5 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
23
34import {
45 createThreadJumpHintVisibilityController,
......@@ -20,7 +21,13 @@ import {
2021 sortProjectsForSidebar,
2122 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2223} 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";
2431import {
2532 DEFAULT_INTERACTION_MODE,
2633 DEFAULT_RUNTIME_MODE,
......@@ -474,7 +481,7 @@ describe("resolveThreadStatusPill", () => {
474481 latestTurn: null,
475482 lastVisitedAt: undefined,
476483 session: {
477 provider: "codex" as const,
484 provider: ProviderDriverKind.make("codex"),
478485 status: "running" as const,
479486 createdAt: "2026-03-09T10:00:00.000Z",
480487 updatedAt: "2026-03-09T10:00:00.000Z",
......@@ -698,7 +705,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
698705 name: "Project",
699706 cwd: "/tmp/project",
700707 defaultModelSelection: {
701 provider: "codex",
708 instanceId: ProviderInstanceId.make("codex"),
702709 model: "gpt-5.4",
703710 ...defaultModelSelection,
704711 },
......@@ -717,7 +724,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
717724 projectId: ProjectId.make("project-1"),
718725 title: "Thread",
719726 modelSelection: {
720 provider: "codex",
727 instanceId: ProviderInstanceId.make("codex"),
721728 model: "gpt-5.4",
722729 ...overrides?.modelSelection,
723730 },
apps/web/src/components/chat/ChatComposer.tsx+149-38
......@@ -5,7 +5,6 @@ import type {
55 ProjectEntry,
66 ProviderApprovalDecision,
77 ProviderInteractionMode,
8 ProviderKind,
98 ResolvedKeybindingsConfig,
109 RuntimeMode,
1110 ScopedThreadRef,
......@@ -14,6 +13,8 @@ import type {
1413 TurnId,
1514} from "@t3tools/contracts";
1615import {
16 ProviderDriverKind,
17 ProviderInstanceId,
1718 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1819 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1920} from "@t3tools/contracts";
......@@ -95,11 +96,14 @@ import {
9596 XIcon,
9697} from "lucide-react";
9798import { proposedPlanTitle } from "../../proposedPlan";
99import { getProviderInteractionModeToggle } from "../../providerModels";
98100import {
99 getProviderInteractionModeToggle,
100 getProviderModels,
101 resolveSelectableProvider,
102} from "../../providerModels";
101 deriveProviderInstanceEntries,
102 resolveProviderDriverKindForInstanceSelection,
103 sortProviderInstanceEntries,
104 type ProviderInstanceEntry,
105} from "../../providerInstances";
106import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
103107import type { UnifiedSettings } from "@t3tools/contracts/settings";
104108import type { SessionPhase, Thread } from "../../types";
105109import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
......@@ -346,7 +350,7 @@ export interface ChatComposerHandle {
346350 selectedPromptEffort: string | null;
347351 selectedModelOptionsForDispatch: unknown;
348352 selectedModelSelection: ModelSelection;
349 selectedProvider: ProviderKind;
353 selectedProvider: ProviderDriverKind;
350354 selectedModel: string;
351355 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
352356 };
......@@ -406,7 +410,7 @@ export interface ChatComposerProps {
406410 interactionMode: ProviderInteractionMode;
407411
408412 // Provider / model
409 lockedProvider: ProviderKind | null;
413 lockedProvider: ProviderDriverKind | null;
410414 providerStatuses: ServerProvider[];
411415 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
412416 activeThreadModelSelection: ModelSelection | null | undefined;
......@@ -449,7 +453,7 @@ export interface ChatComposerProps {
449453 cursorAdjacentToMention: boolean,
450454 ) => void;
451455
452 onProviderModelSelect: (provider: ProviderKind, model: string) => void;
456 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;
453457 toggleInteractionMode: () => void;
454458 handleRuntimeModeChange: (mode: RuntimeMode) => void;
455459 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
......@@ -566,29 +570,134 @@ export const ChatComposer = memo(
566570 // ------------------------------------------------------------------
567571 // Model state
568572 // ------------------------------------------------------------------
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 );
569580 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
570581 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(
574 providerStatuses,
575 selectedProviderByThreadId ?? threadProvider ?? "codex",
576 );
577 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
611 // Resolve which configured instance the composer is currently targeting.
612 // Priority:
613 // 1. The composer draft's `activeProvider` — the user's unsaved pick
614 // from the model picker (must win, otherwise the UI appears to
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
579677 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
580678 threadRef: composerDraftTarget,
581679 providers: providerStatuses,
582680 selectedProvider,
681 selectedInstanceId,
583682 threadModelSelection: activeThreadModelSelection,
584683 projectModelSelection: activeProjectDefaultModelSelection,
585684 settings,
586685 });
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 );
589694 const selectedProviderStatus = useMemo(
590 () => providerStatuses.find((provider) => provider.provider === selectedProvider),
591 [providerStatuses, selectedProvider],
695 () => selectedProviderEntry?.snapshot ?? null,
696 [selectedProviderEntry],
697 );
698 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
699 () => selectedProviderEntry?.models ?? [],
700 [selectedProviderEntry],
592701 );
593702
594703 const composerProviderState = useMemo(
......@@ -615,29 +724,30 @@ export const ChatComposer = memo(
615724 [providerStatuses, selectedProvider],
616725 );
617726 const selectedModelSelection = useMemo<ModelSelection>(
618 () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch),
619 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
727 () =>
728 createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
729 [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
620730 );
621731 const selectedModelForPicker = selectedModel;
622 const modelOptionsByProvider = useMemo<
623 Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>>
624 >(
625 () => ({
626 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
627 claudeAgent:
628 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
629 opencode:
630 providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [],
631 cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [],
632 }),
633 [providerStatuses],
634 );
732 // Instance-keyed option list so the picker can show each configured
733 // instance (built-in + custom) as a first-class sidebar entry. The
734 // options are server-reported models plus that exact instance's
735 // configured custom models; selected slugs are not injected into lists.
736 const modelOptionsByInstance = useMemo<
737 ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>
738 >(() => {
739 const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
740 for (const entry of providerInstanceEntries) {
741 out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
742 }
743 return out;
744 }, [providerInstanceEntries, settings]);
635745 const selectedModelForPickerWithCustomFallback = useMemo(() => {
636 const currentOptions = modelOptionsByProvider[selectedProvider];
746 const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? [];
637747 return currentOptions.some((option) => option.slug === selectedModelForPicker)
638748 ? selectedModelForPicker
639749 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
640 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
750 }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]);
641751
642752 // ------------------------------------------------------------------
643753 // Context window
......@@ -1889,12 +1999,13 @@ export const ChatComposer = memo(
18891999 <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">
18902000 <ProviderModelPicker
18912001 compact={isComposerFooterCompact}
1892 provider={selectedProvider}
2002 activeInstanceId={selectedInstanceId}
18932003 model={selectedModelForPickerWithCustomFallback}
18942004 lockedProvider={lockedProvider}
1895 providers={providerStatuses}
2005 lockedContinuationGroupKey={lockedContinuationGroupKey}
2006 instanceEntries={providerInstanceEntries}
18962007 keybindings={keybindings}
1897 modelOptionsByProvider={modelOptionsByProvider}
2008 modelOptionsByInstance={modelOptionsByInstance}
18982009 terminalOpen={terminalOpen}
18992010 open={isComposerModelPickerOpen}
19002011 {...(composerProviderState.modelPickerIconClassName
......@@ -1906,7 +2017,7 @@ export const ChatComposer = memo(
19062017 onOpenChange={(open) => {
19072018 setIsComposerModelPickerOpen(open);
19082019 }}
1909 onProviderModelChange={onProviderModelSelect}
2020 onInstanceModelChange={onProviderModelSelect}
19102021 />
19112022
19122023 {isComposerFooterCompact ? (
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+36-16
......@@ -1,7 +1,10 @@
11import {
2 DEFAULT_MODEL,
23 DEFAULT_MODEL_BY_PROVIDER,
34 EnvironmentId,
45 ModelSelection,
6 ProviderInstanceId,
7 ProviderDriverKind,
58 ThreadId,
69} from "@t3tools/contracts";
710import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
......@@ -50,8 +53,10 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
5053 const threadId = ThreadId.make("thread-compact-menu");
5154 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
5255 const threadKey = scopedThreadKey(threadRef);
53 const provider = props?.modelSelection?.provider ?? "claudeAgent";
54 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];
56 const provider = ProviderDriverKind.make("claudeAgent");
57 const instanceId = ProviderInstanceId.make(props?.modelSelection?.instanceId ?? provider);
58 const model =
59 props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
5560
5661 useComposerDraftStore.setState({
5762 draftsByThreadKey: {
......@@ -62,9 +67,9 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
6267 persistedAttachments: [],
6368 terminalContexts: [],
6469 modelSelectionByProvider: {
65 [provider]: createModelSelection(provider, model, props?.modelSelection?.options),
70 [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options),
6671 },
67 activeProvider: provider,
72 activeProvider: instanceId,
6873 runtimeMode: null,
6974 interactionMode: null,
7075 },
......@@ -178,7 +183,10 @@ describe("CompactComposerControlsMenu", () => {
178183
179184 it("shows fast mode controls for Opus", async () => {
180185 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 ),
182190 });
183191
184192 await page.getByLabelText("More composer controls").click();
......@@ -193,7 +201,10 @@ describe("CompactComposerControlsMenu", () => {
193201
194202 it("hides fast mode controls for non-Opus Claude models", async () => {
195203 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 ),
197208 });
198209
199210 await page.getByLabelText("More composer controls").click();
......@@ -205,7 +216,10 @@ describe("CompactComposerControlsMenu", () => {
205216
206217 it("shows only the provided effort options", async () => {
207218 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 ),
209223 });
210224
211225 await page.getByLabelText("More composer controls").click();
......@@ -222,9 +236,11 @@ describe("CompactComposerControlsMenu", () => {
222236
223237 it("shows a Claude thinking on/off section for Haiku", async () => {
224238 await using _ = await mountMenu({
225 modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [
226 { id: "thinking", value: true },
227 ]),
239 modelSelection: createModelSelection(
240 ProviderInstanceId.make("claudeAgent"),
241 "claude-haiku-4-5",
242 [{ id: "thinking", value: true }],
243 ),
228244 });
229245
230246 await page.getByLabelText("More composer controls").click();
......@@ -239,9 +255,11 @@ describe("CompactComposerControlsMenu", () => {
239255
240256 it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => {
241257 await using _ = await mountMenu({
242 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
243 { id: "effort", value: "high" },
244 ]),
258 modelSelection: createModelSelection(
259 ProviderInstanceId.make("claudeAgent"),
260 "claude-opus-4-6",
261 [{ id: "effort", value: "high" }],
262 ),
245263 prompt: "Ultrathink:\nInvestigate this",
246264 });
247265
......@@ -256,9 +274,11 @@ describe("CompactComposerControlsMenu", () => {
256274
257275 it("warns when ultrathink appears in prompt body text", async () => {
258276 await using _ = await mountMenu({
259 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [
260 { id: "effort", value: "high" },
261 ]),
277 modelSelection: createModelSelection(
278 ProviderInstanceId.make("claudeAgent"),
279 "claude-opus-4-6",
280 [{ id: "effort", value: "high" }],
281 ),
262282 prompt: "Ultrathink:\nplease ultrathink about this problem",
263283 });
264284
apps/web/src/components/chat/ComposerCommandMenu.tsx+3-3
......@@ -1,6 +1,6 @@
11import {
22 type ProjectEntry,
3 type ProviderKind,
3 type ProviderDriverKind,
44 type ServerProviderSkill,
55 type ServerProviderSlashCommand,
66} from "@t3tools/contracts";
......@@ -39,7 +39,7 @@ export type ComposerCommandItem =
3939 | {
4040 id: string;
4141 type: "provider-slash-command";
42 provider: ProviderKind;
42 provider: ProviderDriverKind;
4343 command: ServerProviderSlashCommand;
4444 label: string;
4545 description: string;
......@@ -47,7 +47,7 @@ export type ComposerCommandItem =
4747 | {
4848 id: string;
4949 type: "skill";
50 provider: ProviderKind;
50 provider: ProviderDriverKind;
5151 skill: ServerProviderSkill;
5252 label: string;
5353 description: string;
apps/web/src/components/chat/ModelListRow.tsx+26-7
......@@ -1,9 +1,8 @@
1import { type ProviderKind } from "@t3tools/contracts";
1import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
22import { memo } from "react";
33import { StarIcon } from "lucide-react";
44import {
55 getDisplayModelName,
6 getProviderLabel,
76 getTriggerDisplayModelLabel,
87 type ModelEsque,
98 PROVIDER_ICON_BY_PROVIDER,
......@@ -16,7 +15,17 @@ import { cn } from "~/lib/utils";
1615export const ModelListRow = memo(function ModelListRow(props: {
1716 index: number;
1817 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;
2029 isFavorite: boolean;
2130 showProvider: boolean;
2231 preferShortName?: boolean;
......@@ -25,13 +34,16 @@ export const ModelListRow = memo(function ModelListRow(props: {
2534 jumpLabel?: string | null;
2635 onToggleFavorite: () => void;
2736}) {
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
3042 return (
3143 <ComboboxItem
3244 hideIndicator
3345 index={props.index}
34 value={`${props.provider}:${props.model.slug}`}
46 value={`${props.instanceId}:${props.model.slug}`}
3547 contentClassName="flex w-full items-start gap-2"
3648 className={cn(
3749 "w-full cursor-pointer rounded px-3 py-2 transition-colors group",
......@@ -92,9 +104,16 @@ export const ModelListRow = memo(function ModelListRow(props: {
92104 </div>
93105 {props.showProvider && (
94106 <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}
96115 <span className="text-xs font-normal leading-snug text-muted-foreground/70 truncate">
97 {getProviderLabel(props.provider, props.model)}
116 {providerLabel}
98117 </span>
99118 </div>
100119 )}
apps/web/src/components/chat/ModelPickerContent.tsx+249-119
......@@ -1,8 +1,7 @@
11import {
2 type ProviderKind,
3 PROVIDER_DISPLAY_NAMES,
2 type ProviderInstanceId,
3 type ProviderDriverKind,
44 type ResolvedKeybindingsConfig,
5 type ServerProvider,
65} from "@t3tools/contracts";
76import { resolveSelectableModel } from "@t3tools/shared/model";
87import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
......@@ -22,40 +21,89 @@ import {
2221import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
2322import { cn } from "~/lib/utils";
2423import { TooltipProvider } from "../ui/tooltip";
24import type { ProviderInstanceEntry } from "../../providerInstances";
25import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";
2526
2627type ModelPickerItem = {
2728 slug: string;
2829 name: string;
2930 shortName?: string;
3031 subProvider?: string;
31 provider: ProviderKind;
32 instanceId: ProviderInstanceId;
33 driverKind: ProviderDriverKind;
34 instanceDisplayName: string;
35 instanceAccentColor?: string | undefined;
36 continuationGroupKey?: string | undefined;
3237};
3338
3439const 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
3655export const ModelPickerContent = memo(function ModelPickerContent(props: {
37 provider: ProviderKind;
56 /** The instance currently selected in the composer (combobox "value"). */
57 activeInstanceId: ProviderInstanceId;
3858 model: string;
39 lockedProvider: ProviderKind | null;
40 providers?: ReadonlyArray<ServerProvider>;
59 /**
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>;
4174 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>>;
4382 terminalOpen: boolean;
4483 onRequestClose?: () => void;
45 onProviderModelChange: (provider: ProviderKind, model: string) => void;
84 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
4685}) {
47 const { keybindings: providedKeybindings, modelOptionsByProvider, onProviderModelChange } = props;
86 const {
87 keybindings: providedKeybindings,
88 modelOptionsByInstance,
89 instanceEntries,
90 onInstanceModelChange,
91 } = props;
4892 const [searchQuery, setSearchQuery] = useState("");
4993 const searchInputRef = useRef<HTMLInputElement>(null);
5094 const listRegionRef = useRef<HTMLDivElement>(null);
5195 const highlightedModelKeyRef = useRef<string | null>(null);
5296 const favorites = useSettings((s) => s.favorites ?? []);
53 const [selectedProvider, setSelectedProvider] = useState<ProviderKind | "favorites">(() => {
54 if (props.lockedProvider !== null) {
55 return props.lockedProvider;
56 }
57 return favorites.length > 0 ? "favorites" : props.provider;
58 });
97 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
98 () => {
99 if (props.lockedProvider !== null) {
100 // When locked, prime the sidebar to the currently-active instance
101 // so jumping into the picker keeps the focused instance visible.
102 return props.activeInstanceId;
103 }
104 return favorites.length > 0 ? "favorites" : props.activeInstanceId;
105 },
106 );
59107 const keybindings = useMemo<ResolvedKeybindingsConfig>(
60108 () => providedKeybindings ?? [],
61109 [providedKeybindings],
......@@ -66,9 +114,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
66114 searchInputRef.current?.focus({ preventScroll: true });
67115 }, []);
68116
69 const handleSelectProvider = useCallback(
70 (provider: ProviderKind | "favorites") => {
71 setSelectedProvider(provider);
117 const handleSelectInstance = useCallback(
118 (instanceId: ProviderInstanceId | "favorites") => {
119 setSelectedInstanceId(instanceId);
72120 window.requestAnimationFrame(() => {
73121 focusSearchInput();
74122 });
......@@ -90,44 +138,97 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
90138 };
91139 }, [focusSearchInput]);
92140
93 // Create a Set for efficient lookup
141 // Create a Set for efficient lookup. Favorites are keyed by
142 // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind
143 // to ProviderInstanceId so pre-migration favorites keyed by driver slugs
144 // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals
145 // the driver slug.
94146 const favoritesSet = useMemo(() => {
95 return new Set(favorites.map((fav) => `${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 );
147 return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model)));
101148 }, [favorites]);
102149
103 const readyProviderSet = useMemo(() => {
104 if (!props.providers || props.providers.length === 0) {
105 return null;
150 /**
151 * Lookup table keyed by `instanceId`. Used for display name + driver
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 }
106175 }
107 return new Set(
108 props.providers
109 .filter((provider) => provider.status === "ready")
110 .map((provider) => provider.provider),
111 );
112 }, [props.providers]);
113
114 // Flatten models into a searchable array
176 return ready;
177 }, [instanceEntries]);
178
179 // Flatten models into a searchable array. One pass over the
180 // instance-keyed map; each model carries its instance id + driver kind
181 // so the list row can render the right icon and display name without
182 // another lookup.
115183 const flatModels = useMemo(() => {
116 return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => {
117 if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) {
118 return [];
184 const out: ModelPickerItem[] = [];
185 for (const [instanceId, models] of modelOptionsByInstance) {
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;
119191 }
120 return models.map((m) => ({
121 slug: m.slug,
122 name: m.name,
123 ...(m.shortName ? { shortName: m.shortName } : {}),
124 ...(m.subProvider ? { subProvider: m.subProvider } : {}),
125 provider: providerKind as ProviderKind,
126 })) satisfies Array<ModelPickerItem>;
127 });
128 }, [props.modelOptionsByProvider, readyProviderSet]);
192 if (!readyInstanceSet.has(instanceId)) {
193 continue;
194 }
195 for (const model of models) {
196 out.push({
197 slug: model.slug,
198 name: model.name,
199 ...(model.shortName ? { shortName: model.shortName } : {}),
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 provider
231 // Filter models based on search query and selected instance
131232 const filteredModels = useMemo(() => {
132233 let result = flatModels;
133234
......@@ -138,13 +239,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
138239 model,
139240 score: scoreModelPickerSearch(
140241 {
141 ...model,
142 isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`),
242 name: model.name,
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)),
143248 },
144249 searchQuery,
145250 ),
146 isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`),
147 tieBreaker: buildModelPickerSearchText(model),
251 isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)),
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 }),
148259 }))
149260 .filter(
150261 (
......@@ -157,10 +268,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
157268 } => rankedModel.score !== null,
158269 );
159270
160 // When searching, we only respect locked provider, ignoring sidebar selection
271 // When searching, we only respect locked provider (by driver kind),
272 // ignoring sidebar selection so account-scoped searches can find a
273 // model before the user chooses a specific instance rail item.
161274 if (props.lockedProvider !== null) {
162275 return rankedMatches
163 .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider)
276 .filter((rankedModel) => matchesLockedProvider(rankedModel.model))
164277 .toSorted((a, b) => {
165278 const scoreDelta = a.score - b.score;
166279 if (scoreDelta !== 0) {
......@@ -188,72 +301,87 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
188301 .map((rankedModel) => rankedModel.model);
189302 }
190303
191 // Locked provider mode always shows that provider's models, with favorites first.
192304 if (props.lockedProvider !== null) {
193 result = result.filter((m) => m.provider === props.lockedProvider);
194 } else if (selectedProvider === "favorites") {
195 result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`));
305 result = result.filter((m) => matchesLockedProvider(m));
306 if (showLockedInstanceSidebar) {
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)));
196311 } else {
197 result = result.filter((m) => m.provider === selectedProvider);
312 result = result.filter((m) => m.instanceId === selectedInstanceId);
198313 }
199314
200 return result.toSorted((a, b) => {
201 const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`);
202 const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`);
203
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;
315 return sortProviderModelItems(result, {
316 favoriteModelKeys: favoritesSet,
317 groupFavorites: selectedInstanceId !== "favorites",
318 instanceOrder: selectedInstanceId === "favorites" ? instanceOrder : [],
214319 });
215320 }, [
216 favoriteOrder,
217321 favoritesSet,
218322 flatModels,
323 instanceOrder,
324 matchesLockedProvider,
219325 props.lockedProvider,
220326 searchQuery,
221 selectedProvider,
327 showLockedInstanceSidebar,
328 selectedInstanceId,
222329 ]);
223330
224331 const handleModelSelect = useCallback(
225 (modelSlug: string, provider: ProviderKind) => {
226 const resolvedModel = resolveSelectableModel(
227 provider,
228 modelSlug,
229 modelOptionsByProvider[provider],
230 );
332 (modelSlug: string, instanceId: ProviderInstanceId) => {
333 const options = modelOptionsByInstance.get(instanceId);
334 if (!options) {
335 return;
336 }
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);
231345 if (resolvedModel) {
232 onProviderModelChange(provider, resolvedModel);
346 onInstanceModelChange(instanceId, resolvedModel);
233347 }
234348 },
235 [modelOptionsByProvider, onProviderModelChange],
349 [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange],
236350 );
237351
238352 const toggleFavorite = useCallback(
239 (provider: ProviderKind, model: string) => {
353 (instanceId: ProviderInstanceId, model: string) => {
240354 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);
242356 if (index >= 0) {
243357 newFavorites.splice(index, 1);
244358 } else {
245 newFavorites.push({ provider, model });
359 newFavorites.push({ provider: instanceId, model });
246360 }
247361 updateSettings({ favorites: newFavorites });
248362 },
249363 [favorites, updateSettings],
250364 );
251365
252 const isLocked = props.lockedProvider !== null;
253 const isSearching = searchQuery.trim().length > 0;
254 const showSidebar = !isLocked && !isSearching;
255366 const LockedProviderIcon =
256367 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 ]);
257385 const modelJumpCommandByKey = useMemo(() => {
258386 const mapping = new Map<
259387 string,
......@@ -264,7 +392,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
264392 if (!jumpCommand) {
265393 return mapping;
266394 }
267 mapping.set(`${model.provider}:${model.slug}`, jumpCommand);
395 mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand);
268396 }
269397 return mapping;
270398 }, [filteredModels]);
......@@ -273,16 +401,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
273401 [modelJumpCommandByKey],
274402 );
275403 const allModelKeys = useMemo(
276 (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`),
404 (): string[] => flatModels.map((model) => `${model.instanceId}:${model.slug}`),
277405 [flatModels],
278406 );
279407 const filteredModelKeys = useMemo(
280 (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`),
408 (): string[] => filteredModels.map((model) => `${model.instanceId}:${model.slug}`),
281409 [filteredModels],
282410 );
283411 const filteredModelByKey = useMemo(
284412 (): 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)),
286414 [filteredModels],
287415 );
288416 const modelJumpShortcutContext = useMemo(
......@@ -331,10 +459,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
331459 if (!targetModelKey) {
332460 return;
333461 }
334 const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string];
462 const { instanceId, slug } = splitInstanceModelKey(targetModelKey);
335463 event.preventDefault();
336464 event.stopPropagation();
337 handleModelSelect(slug, provider);
465 handleModelSelect(slug, instanceId);
338466 };
339467
340468 window.addEventListener("keydown", onWindowKeyDown, true);
......@@ -392,25 +520,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
392520 <div
393521 className={cn(
394522 "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",
396524 )}
397525 >
398526 {/* Locked provider header (only shown in locked mode) */}
399 {isLocked && LockedProviderIcon && props.lockedProvider && (
527 {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && (
400528 <div className="flex items-center gap-2 px-4 py-3 border-b">
401529 <LockedProviderIcon className="size-5 shrink-0" />
402 <span className="font-medium text-sm">
403 {PROVIDER_DISPLAY_NAMES[props.lockedProvider]}
404 </span>
530 <span className="font-medium text-sm">{lockedHeaderLabel}</span>
405531 </div>
406532 )}
407533
408534 {/* Sidebar (only in unlocked mode) */}
409535 {showSidebar && (
410536 <ModelPickerSidebar
411 selectedProvider={selectedProvider}
412 onSelectProvider={handleSelectProvider}
413 {...(props.providers && { providers: props.providers })}
537 selectedInstanceId={selectedInstanceId}
538 onSelectInstance={handleSelectInstance}
539 instanceEntries={sidebarInstanceEntries}
540 showFavorites={!isLocked}
541 showComingSoon={!isLocked}
414542 />
415543 )}
416544
......@@ -422,7 +550,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
422550 filter={null}
423551 autoHighlight
424552 open
425 value={`${props.provider}:${props.model}`}
553 value={`${props.activeInstanceId}:${props.model}`}
426554 onItemHighlighted={(modelKey) => {
427555 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
428556 }}
......@@ -430,14 +558,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
430558 if (typeof modelKey !== "string") {
431559 return;
432560 }
433 const [provider, slug] = modelKey.split(":") as [ProviderKind, string];
434 handleModelSelect(slug, provider);
561 const { instanceId, slug } = splitInstanceModelKey(modelKey);
562 handleModelSelect(slug, instanceId);
435563 }}
436564 >
437565 <div
438566 className={cn(
439567 "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",
441569 )}
442570 >
443571 {/* Search bar */}
......@@ -464,11 +592,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
464592 ).preventBaseUIHandler?.();
465593 e.preventDefault();
466594 e.stopPropagation();
467 const [provider, slug] = highlightedModelKeyRef.current.split(":") as [
468 ProviderKind,
469 string,
470 ];
471 handleModelSelect(slug, provider);
595 const { instanceId, slug } = splitInstanceModelKey(
596 highlightedModelKeyRef.current,
597 );
598 handleModelSelect(slug, instanceId);
472599 return;
473600 }
474601 e.stopPropagation();
......@@ -495,14 +622,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
495622 key={modelKey}
496623 index={index}
497624 model={model}
498 provider={model.provider}
625 instanceId={model.instanceId}
626 driverKind={model.driverKind}
627 providerDisplayName={model.instanceDisplayName}
628 providerAccentColor={model.instanceAccentColor}
499629 isFavorite={favoritesSet.has(modelKey)}
500 showProvider={!isLocked}
630 showProvider={!isLocked || showLockedInstanceSidebar}
501631 preferShortName={!isLocked}
502 useTriggerLabel={isLocked}
503 showNewBadge={isModelPickerNewModel(model.provider, model.slug)}
632 useTriggerLabel={isLocked && !showLockedInstanceSidebar}
633 showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
504634 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
505 onToggleFavorite={() => toggleFavorite(model.provider, model.slug)}
635 onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
506636 />
507637 );
508638 })}
apps/web/src/components/chat/ModelPickerSidebar.tsx+213-160
......@@ -1,28 +1,32 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";
1import { type ProviderInstanceId } from "@t3tools/contracts";
2import { memo, useMemo } from "react";
33import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";
44import { Gemini, GithubCopilotIcon } from "../Icons";
5import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
5import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
6import { ScrollArea } from "../ui/scroll-area";
67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
78import { cn } from "~/lib/utils";
8import { getProviderSnapshot } from "../../providerModels";
9import type { ProviderInstanceEntry } from "../../providerInstances";
910
10function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string {
11 if (!live) {
12 return `${label} — waiting for provider status…`;
13 }
14 if (live.status === "ready") {
11/**
12 * Build the hover tooltip for an instance button. Mirrors the old
13 * kind-based copy but uses the entry's configured `displayName` so custom
14 * instances get their user-authored name (e.g. "Codex Personal — Unavailable.").
15 */
16function describeUnavailableInstance(entry: ProviderInstanceEntry): string {
17 const label = entry.displayName;
18 if (entry.status === "ready") {
1519 return label;
1620 }
1721 const kind =
18 live.status === "error"
22 entry.status === "error"
1923 ? "Unavailable"
20 : live.status === "warning"
24 : entry.status === "warning"
2125 ? "Limited"
22 : live.status === "disabled"
26 : entry.status === "disabled"
2327 ? "Disabled in settings"
2428 : "Not ready";
25 const msg = live.message?.trim();
29 const msg = entry.snapshot.message?.trim();
2630 return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`;
2731}
2832
......@@ -39,172 +43,221 @@ const PICKER_TOOLTIP_SIDE = "left" as const;
3943const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
4044
4145export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
42 selectedProvider: ProviderKind | "favorites";
43 onSelectProvider: (provider: ProviderKind | "favorites") => void;
44 providers?: ReadonlyArray<ServerProvider>;
46 selectedInstanceId: ProviderInstanceId | "favorites";
47 onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void;
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>;
4565}) {
46 const handleProviderClick = (provider: ProviderKind | "favorites") => {
47 props.onSelectProvider(provider);
66 const handleSelect = (instanceId: ProviderInstanceId | "favorites") => {
67 props.onSelectInstance(instanceId);
4868 };
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
5079 return (
51 <div className="flex flex-col w-12 border-r bg-muted/30 p-1 overflow-y-auto gap-1">
52 {/* Favorites section */}
53 <div className="pb-1 mb-1 border-b">
54 <div className="relative w-full">
55 {props.selectedProvider === "favorites" && <div className={SELECTED_INDICATOR_CLASS} />}
56 <Tooltip>
57 <TooltipTrigger
58 render={
59 <button
60 className={cn(
61 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted",
62 props.selectedProvider === "favorites" && SELECTED_BUTTON_CLASS,
63 )}
64 onClick={() => handleProviderClick("favorites")}
65 type="button"
66 data-model-picker-provider="favorites"
67 aria-label="Favorites"
80 <ScrollArea
81 hideScrollbars
82 scrollFade
83 className="w-12 shrink-0 border-r bg-muted/30"
84 data-model-picker-sidebar="true"
85 >
86 <div className="flex min-h-full flex-col gap-1 p-1">
87 {/* Favorites section */}
88 {showFavorites ? (
89 <div className="pb-1 mb-1 border-b">
90 <div className="relative w-full">
91 {props.selectedInstanceId === "favorites" && (
92 <div className={SELECTED_INDICATOR_CLASS} />
93 )}
94 <Tooltip>
95 <TooltipTrigger
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}
68115 >
69 <StarIcon className="size-5 fill-current shrink-0" aria-hidden />
70 </button>
71 }
72 />
73 <TooltipPopup
74 side={PICKER_TOOLTIP_SIDE}
75 align="center"
76 className={PICKER_TOOLTIP_CLASS}
77 >
78 Favorites
79 </TooltipPopup>
80 </Tooltip>
81 </div>
82 </div>
116 Favorites
117 </TooltipPopup>
118 </Tooltip>
119 </div>
120 </div>
121 ) : null}
83122
84 {/* Provider buttons */}
85 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
86 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
87 const liveProvider = props.providers
88 ? getProviderSnapshot(props.providers, option.value)
89 : undefined;
123 {/* Instance buttons (one per configured instance — built-in + custom) */}
124 {props.instanceEntries.map((entry) => {
125 const isDisabled = !entry.isAvailable || entry.status !== "ready";
126 const isSelected = props.selectedInstanceId === entry.instanceId;
127 const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false;
128 const showInstanceBadge =
129 Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1;
90130
91 const isDisabled = !liveProvider || liveProvider.status !== "ready";
92 const isSelected = props.selectedProvider === option.value;
93 const badge = option.pickerSidebarBadge;
131 const tooltip = isDisabled
132 ? describeUnavailableInstance(entry)
133 : showNewBadge
134 ? `${entry.displayName} — New`
135 : entry.displayName;
94136
95 const providerTooltip = isDisabled
96 ? describeUnavailableProvider(option.label, liveProvider)
97 : badge === "new"
98 ? `${option.label} — New`
99 : option.label;
137 const button = (
138 <button
139 data-model-picker-provider={entry.instanceId}
140 className={cn(
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 = (
102 <button
103 data-model-picker-provider={option.value}
104 className={cn(
105 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted",
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 );
173 const trigger = isDisabled ? (
174 <span className="relative block w-full">{button}</span>
175 ) : (
176 button
177 );
132178
133 const trigger = isDisabled ? (
134 <span className="relative block w-full">{button}</span>
135 ) : (
136 button
137 );
179 return (
180 <div key={entry.instanceId} className="relative w-full">
181 {isSelected && <div className={SELECTED_INDICATOR_CLASS} />}
182 <Tooltip>
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 (
140 <div key={option.value} className="relative w-full">
141 {isSelected && <div className={SELECTED_INDICATOR_CLASS} />}
196 {showComingSoon ? (
197 <>
198 {/* Gemini button (coming soon) */}
142199 <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 />
144220 <TooltipPopup
145221 side={PICKER_TOOLTIP_SIDE}
146222 align="center"
147223 className={PICKER_TOOLTIP_CLASS}
148224 >
149 {providerTooltip}
225 Gemini — Coming soon
150226 </TooltipPopup>
151227 </Tooltip>
152 </div>
153 );
154 })}
155
156 {/* Gemini button (coming soon) */}
157 <Tooltip>
158 <TooltipTrigger
159 render={
160 <span className="relative block w-full">
161 <button
162 className={cn(
163 "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent",
164 )}
165 disabled
166 type="button"
167 data-model-picker-provider="gemini-coming-soon"
168 aria-label="Gemini — coming soon"
169 >
170 <Gemini className="size-5 text-muted-foreground/85" aria-hidden />
171 <span className={SOON_BADGE_CLASS} aria-hidden>
172 <Clock3Icon className="size-2" />
173 </span>
174 </button>
175 </span>
176 }
177 />
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"
228 {/* Github Copilot button (coming soon) */}
229 <Tooltip>
230 <TooltipTrigger
231 render={
232 <span className="relative block w-full">
233 <button
234 className={cn(
235 "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent",
236 )}
237 disabled
238 type="button"
239 data-model-picker-provider="github-copilot-coming-soon"
240 aria-label="Github Copilot — coming soon"
241 >
242 <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden />
243 <span className={SOON_BADGE_CLASS} aria-hidden>
244 <Clock3Icon className="size-2" />
245 </span>
246 </button>
247 </span>
248 }
249 />
250 <TooltipPopup
251 side={PICKER_TOOLTIP_SIDE}
252 align="center"
253 className={PICKER_TOOLTIP_CLASS}
195254 >
196 <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden />
197 <span className={SOON_BADGE_CLASS} aria-hidden>
198 <Clock3Icon className="size-2" />
199 </span>
200 </button>
201 </span>
202 }
203 />
204 <TooltipPopup side={PICKER_TOOLTIP_SIDE} align="center" className={PICKER_TOOLTIP_CLASS}>
205 Github Copilot — Coming soon
206 </TooltipPopup>
207 </Tooltip>
208 </div>
255 Github Copilot — Coming soon
256 </TooltipPopup>
257 </Tooltip>
258 </>
259 ) : null}
260 </div>
261 </ScrollArea>
209262 );
210263});
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 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22import { EnvironmentId } from "@t3tools/contracts";
33import { createModelCapabilities } from "@t3tools/shared/model";
44import { page, userEvent } from "vitest/browser";
......@@ -6,8 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66import { render } from "vitest-browser-react";
77
88import { ProviderModelPicker } from "./ProviderModelPicker";
9import { getCustomModelOptionsByProvider } from "../../modelSelection";
10import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
9import { getCustomModelOptionsByInstance } from "../../modelSelection";
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";
1120import { __resetLocalApiForTests } from "../../localApi";
1221
1322// Mock the environments/runtime module to provide a mock primary environment connection
......@@ -93,7 +102,8 @@ function booleanDescriptor(id: string, label: string) {
93102
94103const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
95104 {
96 provider: "codex",
105 driver: ProviderDriverKind.make("codex"),
106 instanceId: ProviderInstanceId.make("codex"),
97107 displayName: "Codex",
98108 enabled: true,
99109 installed: true,
......@@ -137,7 +147,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
137147 ],
138148 },
139149 {
140 provider: "claudeAgent",
150 driver: ProviderDriverKind.make("claudeAgent"),
151 instanceId: ProviderInstanceId.make("claudeAgent"),
141152 displayName: "Claude",
142153 enabled: true,
143154 installed: true,
......@@ -199,9 +210,14 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
199210 },
200211];
201212
213const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex");
214const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent");
215const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
216
202217function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {
203218 return {
204 provider: "codex",
219 driver: ProviderDriverKind.make("codex"),
220 instanceId: ProviderInstanceId.make("codex"),
205221 displayName: "Codex",
206222 enabled: true,
207223 installed: true,
......@@ -217,7 +233,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {
217233
218234function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider {
219235 return {
220 provider: "opencode",
236 driver: ProviderDriverKind.make("opencode"),
237 instanceId: ProviderInstanceId.make("opencode"),
221238 enabled: true,
222239 installed: true,
223240 version: "1.0.0",
......@@ -231,37 +248,47 @@ function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider
231248}
232249
233250async function mountPicker(props: {
234 provider: ProviderKind;
251 activeInstanceId?: ProviderInstanceId;
235252 model: string;
236 lockedProvider: ProviderKind | null;
253 lockedProvider: ProviderDriverKind | null;
254 lockedContinuationGroupKey?: string | null;
237255 providers?: ReadonlyArray<ServerProvider>;
256 settings?: UnifiedSettings;
238257 triggerVariant?: "ghost" | "outline";
239258}) {
240259 const host = document.createElement("div");
241260 document.body.append(host);
242 const onProviderModelChange = vi.fn();
261 const onInstanceModelChange = vi.fn();
243262 const providers = props.providers ?? TEST_PROVIDERS;
244 const modelOptionsByProvider = getCustomModelOptionsByProvider(
245 DEFAULT_UNIFIED_SETTINGS,
263 const instanceEntries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers));
264 const activeInstanceId = props.activeInstanceId ?? CODEX_INSTANCE_ID;
265 const modelOptionsByInstance = getCustomModelOptionsByInstance(
266 props.settings ?? DEFAULT_UNIFIED_SETTINGS,
246267 providers,
247 props.provider,
268 activeInstanceId,
248269 props.model,
249270 );
250271 const screen = await render(
251272 <ProviderModelPicker
252 provider={props.provider}
273 activeInstanceId={activeInstanceId}
253274 model={props.model}
254275 lockedProvider={props.lockedProvider}
255 providers={providers}
256 modelOptionsByProvider={modelOptionsByProvider}
276 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}
277 instanceEntries={instanceEntries}
278 modelOptionsByInstance={modelOptionsByInstance}
257279 triggerVariant={props.triggerVariant}
258 onProviderModelChange={onProviderModelChange}
280 onInstanceModelChange={onInstanceModelChange}
259281 />,
260282 { container: host },
261283 );
262284
263285 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 },
265292 cleanup: async () => {
266293 await screen.unmount();
267294 host.remove();
......@@ -304,7 +331,7 @@ describe("ProviderModelPicker", () => {
304331
305332 it("shows provider sidebar in unlocked mode", async () => {
306333 const mounted = await mountPicker({
307 provider: "claudeAgent",
334 activeInstanceId: CLAUDE_INSTANCE_ID,
308335 model: "claude-opus-4-6",
309336 lockedProvider: null,
310337 });
......@@ -325,7 +352,7 @@ describe("ProviderModelPicker", () => {
325352
326353 it("shows favorites first in the provider sidebar", async () => {
327354 const mounted = await mountPicker({
328 provider: "claudeAgent",
355 activeInstanceId: CLAUDE_INSTANCE_ID,
329356 model: "claude-opus-4-6",
330357 lockedProvider: null,
331358 });
......@@ -347,7 +374,7 @@ describe("ProviderModelPicker", () => {
347374
348375 it("filters models by selected provider in sidebar", async () => {
349376 const mounted = await mountPicker({
350 provider: "claudeAgent",
377 activeInstanceId: CLAUDE_INSTANCE_ID,
351378 model: "claude-opus-4-6",
352379 lockedProvider: null,
353380 });
......@@ -379,9 +406,37 @@ describe("ProviderModelPicker", () => {
379406 }
380407 });
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
382437 it("focuses the search input after selecting a sidebar provider", async () => {
383438 const mounted = await mountPicker({
384 provider: "claudeAgent",
439 activeInstanceId: CLAUDE_INSTANCE_ID,
385440 model: "claude-opus-4-6",
386441 lockedProvider: null,
387442 });
......@@ -419,9 +474,9 @@ describe("ProviderModelPicker", () => {
419474 );
420475
421476 const mounted = await mountPicker({
422 provider: "claudeAgent",
477 activeInstanceId: CLAUDE_INSTANCE_ID,
423478 model: "claude-opus-4-6",
424 lockedProvider: "claudeAgent",
479 lockedProvider: ProviderDriverKind.make("claudeAgent"),
425480 });
426481
427482 try {
......@@ -443,27 +498,115 @@ describe("ProviderModelPicker", () => {
443498 }
444499 });
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
446583 it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => {
447584 const host = document.createElement("div");
448585 document.body.append(host);
449 const onProviderModelChange = vi.fn();
450 const modelOptionsByProvider = {
451 claudeAgent: [
452 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
453 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
586 const onInstanceModelChange = vi.fn();
587 const modelOptionsByInstance = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>([
588 [
589 "claudeAgent" as ProviderInstanceId,
590 [
591 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
592 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
593 ],
454594 ],
455 codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }],
456 cursor: [],
457 opencode: [],
458 } as const;
595 ["codex" as ProviderInstanceId, [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }]],
596 ["cursor" as ProviderInstanceId, []],
597 ["opencode" as ProviderInstanceId, []],
598 ]);
599 const instanceEntries = sortProviderInstanceEntries(
600 deriveProviderInstanceEntries(TEST_PROVIDERS),
601 );
459602 const screen = await render(
460603 <ProviderModelPicker
461 provider="claudeAgent"
604 activeInstanceId={"claudeAgent" as ProviderInstanceId}
462605 model="gpt-5-codex"
463606 lockedProvider={null}
464 providers={TEST_PROVIDERS}
465 modelOptionsByProvider={modelOptionsByProvider}
466 onProviderModelChange={onProviderModelChange}
607 instanceEntries={instanceEntries}
608 modelOptionsByInstance={modelOptionsByInstance}
609 onInstanceModelChange={onInstanceModelChange}
467610 />,
468611 { container: host },
469612 );
......@@ -504,9 +647,9 @@ describe("ProviderModelPicker", () => {
504647 ]),
505648 ];
506649 const mounted = await mountPicker({
507 provider: "opencode",
650 activeInstanceId: OPENCODE_INSTANCE_ID,
508651 model: "github-copilot/claude-opus-4.5",
509 lockedProvider: "opencode",
652 lockedProvider: ProviderDriverKind.make("opencode"),
510653 providers,
511654 });
512655
......@@ -531,7 +674,7 @@ describe("ProviderModelPicker", () => {
531674
532675 it("searches models by name in flat list", async () => {
533676 const mounted = await mountPicker({
534 provider: "claudeAgent",
677 activeInstanceId: CLAUDE_INSTANCE_ID,
535678 model: "claude-opus-4-6",
536679 lockedProvider: null,
537680 });
......@@ -561,9 +704,9 @@ describe("ProviderModelPicker", () => {
561704
562705 it("supports arrow-key navigation in the model picker", async () => {
563706 const mounted = await mountPicker({
564 provider: "claudeAgent",
707 activeInstanceId: CLAUDE_INSTANCE_ID,
565708 model: "claude-opus-4-6",
566 lockedProvider: "claudeAgent",
709 lockedProvider: ProviderDriverKind.make("claudeAgent"),
567710 });
568711
569712 try {
......@@ -600,7 +743,7 @@ describe("ProviderModelPicker", () => {
600743
601744 it("hides the provider sidebar while searching", async () => {
602745 const mounted = await mountPicker({
603 provider: "claudeAgent",
746 activeInstanceId: CLAUDE_INSTANCE_ID,
604747 model: "claude-opus-4-6",
605748 lockedProvider: null,
606749 });
......@@ -624,7 +767,7 @@ describe("ProviderModelPicker", () => {
624767
625768 it("closes the picker when escape is pressed in search", async () => {
626769 const mounted = await mountPicker({
627 provider: "claudeAgent",
770 activeInstanceId: CLAUDE_INSTANCE_ID,
628771 model: "claude-opus-4-6",
629772 lockedProvider: null,
630773 });
......@@ -652,7 +795,7 @@ describe("ProviderModelPicker", () => {
652795
653796 it("searches models by provider name", async () => {
654797 const mounted = await mountPicker({
655 provider: "claudeAgent",
798 activeInstanceId: CLAUDE_INSTANCE_ID,
656799 model: "claude-opus-4-6",
657800 lockedProvider: null,
658801 });
......@@ -718,7 +861,7 @@ describe("ProviderModelPicker", () => {
718861 ]),
719862 ];
720863 const mounted = await mountPicker({
721 provider: "opencode",
864 activeInstanceId: OPENCODE_INSTANCE_ID,
722865 model: "github-copilot/claude-opus-4.7",
723866 lockedProvider: null,
724867 providers,
......@@ -780,7 +923,7 @@ describe("ProviderModelPicker", () => {
780923 },
781924 ];
782925 const mounted = await mountPicker({
783 provider: "opencode",
926 activeInstanceId: OPENCODE_INSTANCE_ID,
784927 model: "github-copilot/claude-opus-4.7",
785928 lockedProvider: null,
786929 providers,
......@@ -805,7 +948,7 @@ describe("ProviderModelPicker", () => {
805948 localStorage.removeItem("t3code:client-settings:v1");
806949
807950 const mounted = await mountPicker({
808 provider: "claudeAgent",
951 activeInstanceId: CLAUDE_INSTANCE_ID,
809952 model: "claude-opus-4-6",
810953 lockedProvider: null,
811954 });
......@@ -850,7 +993,7 @@ describe("ProviderModelPicker", () => {
850993 localStorage.removeItem("t3code:client-settings:v1");
851994
852995 const mounted = await mountPicker({
853 provider: "claudeAgent",
996 activeInstanceId: CLAUDE_INSTANCE_ID,
854997 model: "claude-opus-4-6",
855998 lockedProvider: null,
856999 });
......@@ -890,7 +1033,6 @@ describe("ProviderModelPicker", () => {
8901033 );
8911034
8921035 const mounted = await mountPicker({
893 provider: "codex",
8941036 model: "gpt-5-codex",
8951037 lockedProvider: null,
8961038 });
......@@ -910,9 +1052,9 @@ describe("ProviderModelPicker", () => {
9101052
9111053 it("dispatches callback with correct provider and model when selected", async () => {
9121054 const mounted = await mountPicker({
913 provider: "claudeAgent",
1055 activeInstanceId: CLAUDE_INSTANCE_ID,
9141056 model: "claude-opus-4-6",
915 lockedProvider: "claudeAgent",
1057 lockedProvider: ProviderDriverKind.make("claudeAgent"),
9161058 });
9171059
9181060 try {
......@@ -995,7 +1137,6 @@ describe("ProviderModelPicker", () => {
9951137 ];
9961138
9971139 const hidden = await mountPicker({
998 provider: "codex",
9991140 model: "gpt-5.3-codex",
10001141 lockedProvider: null,
10011142 providers: providersWithoutSpark,
......@@ -1014,7 +1155,6 @@ describe("ProviderModelPicker", () => {
10141155 }
10151156
10161157 const visible = await mountPicker({
1017 provider: "codex",
10181158 model: "gpt-5.3-codex",
10191159 lockedProvider: null,
10201160 providers: providersWithSpark,
......@@ -1034,7 +1174,7 @@ describe("ProviderModelPicker", () => {
10341174 it("shows disabled providers grayed out in sidebar", async () => {
10351175 const disabledProviders = TEST_PROVIDERS.slice();
10361176 const claudeIndex = disabledProviders.findIndex(
1037 (provider) => provider.provider === "claudeAgent",
1177 (provider) => provider.instanceId === ProviderInstanceId.make("claudeAgent"),
10381178 );
10391179 if (claudeIndex >= 0) {
10401180 const claudeProvider = disabledProviders[claudeIndex]!;
......@@ -1046,7 +1186,6 @@ describe("ProviderModelPicker", () => {
10461186 }
10471187
10481188 const mounted = await mountPicker({
1049 provider: "codex",
10501189 model: "gpt-5-codex",
10511190 lockedProvider: null,
10521191 providers: disabledProviders,
......@@ -1068,7 +1207,6 @@ describe("ProviderModelPicker", () => {
10681207
10691208 it("accepts outline trigger styling", async () => {
10701209 const mounted = await mountPicker({
1071 provider: "codex",
10721210 model: "gpt-5-codex",
10731211 lockedProvider: null,
10741212 triggerVariant: "outline",
apps/web/src/components/chat/ProviderModelPicker.tsx+56-27
......@@ -1,9 +1,9 @@
11import {
2 type ProviderKind,
2 type ProviderInstanceId,
3 type ProviderDriverKind,
34 type ResolvedKeybindingsConfig,
4 type ServerProvider,
55} from "@t3tools/contracts";
6import { memo, useEffect, useState } from "react";
6import { memo, useEffect, useMemo, useState } from "react";
77import type { VariantProps } from "class-variance-authority";
88import { ChevronDownIcon } from "lucide-react";
99import { Button, buttonVariants } from "../ui/button";
......@@ -11,21 +11,28 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1111import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1212import { cn } from "~/lib/utils";
1313import { ModelPickerContent } from "./ModelPickerContent";
14import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
1415import {
1516 ModelEsque,
16 PROVIDER_ICON_BY_PROVIDER,
1717 getTriggerDisplayModelLabel,
1818 getTriggerDisplayModelName,
1919} from "./providerIconUtils";
2020import { setModelPickerOpen } from "../../modelPickerOpenState";
21import type { ProviderInstanceEntry } from "../../providerInstances";
2122
2223export 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;
2429 model: string;
25 lockedProvider: ProviderKind | null;
26 providers?: ReadonlyArray<ServerProvider>;
30 lockedProvider: ProviderDriverKind | null;
31 lockedContinuationGroupKey?: string | null;
32 /** Instance entries rendered in the sidebar + used to resolve display name. */
33 instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
2734 keybindings?: ResolvedKeybindingsConfig;
28 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<ModelEsque>>;
35 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
2936 activeProviderIconClassName?: string;
3037 compact?: boolean;
3138 disabled?: boolean;
......@@ -34,22 +41,36 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3441 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
3542 triggerClassName?: string;
3643 onOpenChange?: (open: boolean) => void;
37 onProviderModelChange: (provider: ProviderKind, model: string) => void;
44 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
3845}) {
3946 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
40 const activeProvider = props.lockedProvider ?? props.provider;
4147 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
42 const selectedProviderOptions = props.modelOptionsByProvider[activeProvider];
43 // If the current slug belongs to a different provider (for example after a provider
44 // switch or disable), prefer the active provider's first option so the trigger icon
45 // and label stay in sync instead of showing a stale foreign slug.
48
49 // Resolve the active instance entry by exact routing key. The composer
50 // resolves fallbacks before rendering this component; if the selected
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.
4664 const selectedModel =
47 selectedProviderOptions.find((option) => option.slug === props.model) ??
48 selectedProviderOptions[0];
49 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider];
65 selectedInstanceOptions.find((option) => option.slug === props.model) ??
66 selectedInstanceOptions[0];
5067 const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model;
5168 const triggerSubtitle = selectedModel?.subProvider;
5269 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
5475 const setIsMenuOpen = (open: boolean) => {
5576 props.onOpenChange?.(open);
......@@ -65,9 +86,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
6586 };
6687 }, [isMenuOpen]);
6788
68 const handleProviderModelChange = (provider: ProviderKind, model: string) => {
89 const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => {
6990 if (props.disabled) return;
70 props.onProviderModelChange(provider, model);
91 props.onInstanceModelChange(instanceId, model);
7192 setIsMenuOpen(false);
7293 };
7394
......@@ -103,10 +124,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
103124 props.compact ? "max-w-36 sm:pl-1" : undefined,
104125 )}
105126 >
106 <ProviderIcon
107 aria-hidden="true"
108 className={cn("size-4 shrink-0", props.activeProviderIconClassName)}
109 />
127 {activeEntry ? (
128 <ProviderInstanceIcon
129 driverKind={activeEntry.driverKind}
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}
110138 <Tooltip>
111139 <TooltipTrigger
112140 render={
......@@ -142,15 +170,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
142170 className="border-0 bg-transparent p-0 shadow-none before:hidden [--viewport-inline-padding:0] *:data-[slot=popover-viewport]:p-0"
143171 >
144172 <ModelPickerContent
145 provider={props.provider}
173 activeInstanceId={activeInstanceId}
146174 model={props.model}
147175 lockedProvider={props.lockedProvider}
148 {...(props.providers && { providers: props.providers })}
176 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}
177 instanceEntries={props.instanceEntries}
149178 {...(props.keybindings ? { keybindings: props.keybindings } : {})}
150 modelOptionsByProvider={props.modelOptionsByProvider}
179 modelOptionsByInstance={props.modelOptionsByInstance}
151180 terminalOpen={props.terminalOpen ?? false}
152181 onRequestClose={() => setIsMenuOpen(false)}
153 onProviderModelChange={handleProviderModelChange}
182 onInstanceModelChange={handleInstanceModelChange}
154183 />
155184 </PopoverPopup>
156185 </Popover>
apps/web/src/components/chat/ProviderStatusBanner.tsx+2-2
......@@ -2,7 +2,7 @@ import { type ServerProvider } from "@t3tools/contracts";
22import { memo } from "react";
33import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
44import { CircleAlertIcon } from "lucide-react";
5import { formatProviderKindLabel } from "../../providerModels";
5import { formatProviderDriverKindLabel } from "../../providerModels";
66
77export const ProviderStatusBanner = memo(function ProviderStatusBanner({
88 status,
......@@ -13,7 +13,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
1313 return null;
1414 }
1515
16 const providerLabel = status.displayName?.trim() || formatProviderKindLabel(status.provider);
16 const providerLabel = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
1717 const defaultMessage =
1818 status.status === "error"
1919 ? `${providerLabel} provider is unavailable.`
apps/web/src/components/chat/TraitsPicker.tsx+5-5
......@@ -1,5 +1,5 @@
11import {
2 type ProviderKind,
2 type ProviderDriverKind,
33 type ProviderOptionDescriptor,
44 type ProviderOptionSelection,
55 type ScopedThreadRef,
......@@ -76,7 +76,7 @@ function getDescriptorStringValue(
7676}
7777
7878function getSelectedTraits(
79 provider: ProviderKind,
79 provider: ProviderDriverKind,
8080 models: ReadonlyArray<ServerProviderModel>,
8181 model: string | null | undefined,
8282 prompt: string,
......@@ -150,7 +150,7 @@ function getSelectedTraits(
150150}
151151
152152function getTraitsSectionVisibility(input: {
153 provider: ProviderKind;
153 provider: ProviderDriverKind;
154154 models: ReadonlyArray<ServerProviderModel>;
155155 model: string | null | undefined;
156156 prompt: string;
......@@ -184,7 +184,7 @@ function getTraitsSectionVisibility(input: {
184184}
185185
186186export function shouldRenderTraitsControls(input: {
187 provider: ProviderKind;
187 provider: ProviderDriverKind;
188188 models: ReadonlyArray<ServerProviderModel>;
189189 model: string | null | undefined;
190190 prompt: string;
......@@ -195,7 +195,7 @@ export function shouldRenderTraitsControls(input: {
195195}
196196
197197export interface TraitsMenuContentProps {
198 provider: ProviderKind;
198 provider: ProviderDriverKind;
199199 models: ReadonlyArray<ServerProviderModel>;
200200 model: string | null | undefined;
201201 prompt: string;
apps/web/src/components/chat/composerProviderState.test.tsx+6-6
......@@ -1,9 +1,9 @@
11import { describe, expect, it } from "vitest";
2import type {
3 ProviderKind,
4 ProviderOptionDescriptor,
5 ProviderOptionSelection,
6 ServerProviderModel,
2import {
3 ProviderDriverKind,
4 type ProviderOptionDescriptor,
5 type ProviderOptionSelection,
6 type ServerProviderModel,
77} from "@t3tools/contracts";
88import {
99 getComposerProviderState,
......@@ -15,7 +15,7 @@ import {
1515// optionDescriptors, so these tests use a single synthetic provider/model and
1616// vary only the descriptor shape per scenario.
1717
18const PROVIDER: ProviderKind = "codex";
18const PROVIDER: ProviderDriverKind = ProviderDriverKind.make("codex");
1919const MODEL = "test-model";
2020
2121function selectDescriptor(
apps/web/src/components/chat/composerProviderState.tsx+4-4
......@@ -1,5 +1,5 @@
11import {
2 type ProviderKind,
2 type ProviderDriverKind,
33 type ProviderOptionSelection,
44 type ScopedThreadRef,
55 type ServerProviderModel,
......@@ -17,7 +17,7 @@ import { getProviderModelCapabilities } from "../../providerModels";
1717import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1818
1919export type ComposerProviderStateInput = {
20 provider: ProviderKind;
20 provider: ProviderDriverKind;
2121 model: string;
2222 models: ReadonlyArray<ServerProviderModel>;
2323 prompt: string;
......@@ -25,7 +25,7 @@ export type ComposerProviderStateInput = {
2525};
2626
2727export type ComposerProviderState = {
28 provider: ProviderKind;
28 provider: ProviderDriverKind;
2929 promptEffort: string | null;
3030 modelOptionsForDispatch: ReadonlyArray<ProviderOptionSelection> | undefined;
3131 composerFrameClassName?: string;
......@@ -34,7 +34,7 @@ export type ComposerProviderState = {
3434};
3535
3636type TraitsRenderInput = {
37 provider: ProviderKind;
37 provider: ProviderDriverKind;
3838 threadRef?: ScopedThreadRef;
3939 draftId?: DraftId;
4040 model: string;
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+7-4
......@@ -1,9 +1,12 @@
11import { describe, expect, it } from "vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
23
34import type { ComposerCommandItem } from "./ComposerCommandMenu";
45import { searchSlashCommandItems } from "./composerSlashCommandSearch";
56
67describe("searchSlashCommandItems", () => {
8 const claudeDriver = ProviderDriverKind.make("claudeAgent");
9
710 it("moves exact provider command matches ahead of broader description matches", () => {
811 const items = [
912 {
......@@ -16,7 +19,7 @@ describe("searchSlashCommandItems", () => {
1619 {
1720 id: "provider-slash-command:claudeAgent:ui",
1821 type: "provider-slash-command",
19 provider: "claudeAgent",
22 provider: claudeDriver,
2023 command: { name: "ui" },
2124 label: "/ui",
2225 description: "Explore, build, and refine UI.",
......@@ -24,7 +27,7 @@ describe("searchSlashCommandItems", () => {
2427 {
2528 id: "provider-slash-command:claudeAgent:frontend-design",
2629 type: "provider-slash-command",
27 provider: "claudeAgent",
30 provider: claudeDriver,
2831 command: { name: "frontend-design" },
2932 label: "/frontend-design",
3033 description: "Create distinctive, production-grade frontend interfaces",
......@@ -44,7 +47,7 @@ describe("searchSlashCommandItems", () => {
4447 {
4548 id: "provider-slash-command:claudeAgent:gh-fix-ci",
4649 type: "provider-slash-command",
47 provider: "claudeAgent",
50 provider: claudeDriver,
4851 command: { name: "gh-fix-ci" },
4952 label: "/gh-fix-ci",
5053 description: "Fix failing GitHub Actions",
......@@ -52,7 +55,7 @@ describe("searchSlashCommandItems", () => {
5255 {
5356 id: "provider-slash-command:claudeAgent:github",
5457 type: "provider-slash-command",
55 provider: "claudeAgent",
58 provider: claudeDriver,
5659 command: { name: "github" },
5760 label: "/github",
5861 description: "General GitHub help",
apps/web/src/components/chat/modelPickerModelHighlights.ts+2-2
......@@ -1,4 +1,4 @@
1import type { ProviderKind } from "@t3tools/contracts";
1import type { ProviderDriverKind } from "@t3tools/contracts";
22
33/**
44 * Model slugs that show a gold "NEW" chip in the model picker list.
......@@ -8,6 +8,6 @@ const NEW_MODEL_KEYS = new Set<string>([
88 // Example: "claudeAgent:claude-opus-4-7",
99]);
1010
11export function isModelPickerNewModel(provider: ProviderKind, slug: string): boolean {
11export function isModelPickerNewModel(provider: ProviderDriverKind, slug: string): boolean {
1212 return NEW_MODEL_KEYS.has(`${provider}:${slug}`);
1313}
apps/web/src/components/chat/modelPickerSearch.test.ts+31-9
......@@ -6,7 +6,8 @@ describe("buildModelPickerSearchText", () => {
66 it("builds provider-agnostic search text from generic fields", () => {
77 expect(
88 buildModelPickerSearchText({
9 provider: "opencode",
9 driverKind: "opencode",
10 providerDisplayName: "opencode",
1011 name: "Claude Opus 4.7",
1112 subProvider: "GitHub Copilot",
1213 }),
......@@ -19,7 +20,8 @@ describe("scoreModelPickerSearch", () => {
1920 expect(
2021 scoreModelPickerSearch(
2122 {
22 provider: "opencode",
23 driverKind: "opencode",
24 providerDisplayName: "opencode",
2325 name: "Claude Opus 4.7",
2426 subProvider: "GitHub Copilot",
2527 },
......@@ -32,7 +34,8 @@ describe("scoreModelPickerSearch", () => {
3234 expect(
3335 scoreModelPickerSearch(
3436 {
35 provider: "codex",
37 driverKind: "codex",
38 providerDisplayName: "codex",
3639 name: "GPT-5 Codex",
3740 },
3841 "coplt op",
......@@ -43,7 +46,8 @@ describe("scoreModelPickerSearch", () => {
4346 it("ranks exact token matches ahead of fuzzier matches", () => {
4447 const exactScore = scoreModelPickerSearch(
4548 {
46 provider: "opencode",
49 driverKind: "opencode",
50 providerDisplayName: "opencode",
4751 name: "Claude Opus 4.7",
4852 subProvider: "GitHub Copilot",
4953 },
......@@ -51,7 +55,8 @@ describe("scoreModelPickerSearch", () => {
5155 );
5256 const fuzzyScore = scoreModelPickerSearch(
5357 {
54 provider: "opencode",
58 driverKind: "opencode",
59 providerDisplayName: "opencode",
5560 name: "Claude Opus 4.7",
5661 subProvider: "GitHub Copilot",
5762 },
......@@ -66,7 +71,8 @@ describe("scoreModelPickerSearch", () => {
6671 it("gives favorite models a strong enough ranking boost for partial queries", () => {
6772 const favoriteScore = scoreModelPickerSearch(
6873 {
69 provider: "claudeAgent",
74 driverKind: "claudeAgent",
75 providerDisplayName: "Claude",
7076 name: "Claude Opus 4.7",
7177 isFavorite: true,
7278 },
......@@ -74,7 +80,8 @@ describe("scoreModelPickerSearch", () => {
7480 );
7581 const nonFavoriteScore = scoreModelPickerSearch(
7682 {
77 provider: "cursor",
83 driverKind: "cursor",
84 providerDisplayName: "Cursor",
7885 name: "Opus 4.5",
7986 },
8087 "opu",
......@@ -88,7 +95,8 @@ describe("scoreModelPickerSearch", () => {
8895 it("does not let the favorite boost outrank clearly better textual matches", () => {
8996 const favoriteScore = scoreModelPickerSearch(
9097 {
91 provider: "claudeAgent",
98 driverKind: "claudeAgent",
99 providerDisplayName: "Claude",
92100 name: "Claude Opus 4.7",
93101 isFavorite: true,
94102 },
......@@ -96,7 +104,8 @@ describe("scoreModelPickerSearch", () => {
96104 );
97105 const nonFavoriteExactScore = scoreModelPickerSearch(
98106 {
99 provider: "cursor",
107 driverKind: "cursor",
108 providerDisplayName: "Cursor",
100109 name: "Opus 4.7",
101110 },
102111 "opus 4.7",
......@@ -106,4 +115,17 @@ describe("scoreModelPickerSearch", () => {
106115 expect(nonFavoriteExactScore).not.toBeNull();
107116 expect(nonFavoriteExactScore!).toBeLessThan(favoriteScore!);
108117 });
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 });
109131});
apps/web/src/components/chat/modelPickerSearch.ts+11-11
......@@ -1,8 +1,14 @@
1import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
21import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
32
43type 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;
612 name: string;
713 shortName?: string;
814 subProvider?: string;
......@@ -16,8 +22,8 @@ function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[]
1622 normalizeSearchQuery(model.name),
1723 ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []),
1824 ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []),
19 normalizeSearchQuery(model.provider),
20 normalizeSearchQuery(PROVIDER_DISPLAY_NAMES[model.provider]),
25 normalizeSearchQuery(model.driverKind),
26 normalizeSearchQuery(model.providerDisplayName),
2127 buildModelPickerSearchText(model),
2228 ];
2329}
......@@ -40,13 +46,7 @@ function scoreModelPickerSearchToken(
4046
4147export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string {
4248 return normalizeSearchQuery(
43 [
44 model.name,
45 model.shortName,
46 model.subProvider,
47 model.provider,
48 PROVIDER_DISPLAY_NAMES[model.provider],
49 ]
49 [model.name, model.shortName, model.subProvider, model.driverKind, model.providerDisplayName]
5050 .filter((value): value is string => typeof value === "string" && value.length > 0)
5151 .join(" "),
5252 );
apps/web/src/components/chat/providerIconUtils.ts+7-12
......@@ -1,16 +1,16 @@
1import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
1import { ProviderDriverKind } from "@t3tools/contracts";
22import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
33import { PROVIDER_OPTIONS } from "../../session-logic";
44
5export const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
6 codex: OpenAI,
7 claudeAgent: ClaudeAI,
8 opencode: OpenCodeIcon,
9 cursor: CursorIcon,
5export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
6 [ProviderDriverKind.make("codex")]: OpenAI,
7 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
8 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
9 [ProviderDriverKind.make("cursor")]: CursorIcon,
1010};
1111
1212function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
13 value: ProviderKind;
13 value: ProviderDriverKind;
1414 label: string;
1515 available: true;
1616 pickerSidebarBadge?: "new" | "soon";
......@@ -27,11 +27,6 @@ export type ModelEsque = {
2727 subProvider?: string | undefined;
2828};
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
3530export function getDisplayModelName(
3631 model: ModelEsque,
3732 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", () => {
713713
714714 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();
717721 await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument();
718 await expect.element(page.getByText("OpenCode server password")).toBeInTheDocument();
719 await expect.element(page.getByPlaceholder("Server password")).toBeInTheDocument();
722 await expect.element(page.getByText("Server password")).toBeInTheDocument();
723 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
720724 });
721725});
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 @@
1import {
2 ArchiveIcon,
3 ArchiveX,
4 ChevronDownIcon,
5 InfoIcon,
6 LoaderIcon,
7 PlusIcon,
8 RefreshCwIcon,
9 XIcon,
10} from "lucide-react";
1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
112import { useQueryClient } from "@tanstack/react-query";
123import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
134import {
5 defaultInstanceIdForDriver,
146 type DesktopUpdateChannel,
7 ProviderDriverKind,
8 type ProviderInstanceConfig,
9 type ProviderInstanceId,
1510 type ScopedThreadRef,
16 type ProviderKind,
17 type ServerProvider,
18 type ServerProviderModel,
1911} from "@t3tools/contracts";
2012import { scopeThreadRef } from "@t3tools/client-runtime";
2113import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
22import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
14import { createModelSelection } from "@t3tools/shared/model";
2315import { Equal } from "effect";
2416import { APP_VERSION } from "../../branding";
2517import {
......@@ -41,10 +33,13 @@ import {
4133 useDesktopUpdateState,
4234} from "../../lib/desktopUpdateReactQuery";
4335import {
44 MAX_CUSTOM_MODEL_LENGTH,
45 getCustomModelOptionsByProvider,
36 getCustomModelOptionsByInstance,
4637 resolveAppModelSelectionState,
4738} from "../../modelSelection";
39import {
40 deriveProviderInstanceEntries,
41 sortProviderInstanceEntries,
42} from "../../providerInstances";
4843import { ensureLocalApi, readLocalApi } from "../../localApi";
4944import { useShallow } from "zustand/react/shallow";
5045import {
......@@ -53,16 +48,17 @@ import {
5348 useStore,
5449} from "../../store";
5550import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
56import { cn } from "../../lib/utils";
57import { Badge } from "../ui/badge";
5851import { Button } from "../ui/button";
59import { Collapsible, CollapsibleContent } from "../ui/collapsible";
6052import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
61import { Input } from "../ui/input";
53import { DraftInput } from "../ui/draft-input";
6254import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
6355import { Switch } from "../ui/switch";
6456import { stackedThreadToast, toastManager } from "../ui/toast";
6557import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
58import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
59import { ProviderInstanceCard } from "./ProviderInstanceCard";
60import { getDriverOption } from "./providerDriverMeta";
61import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
6662import {
6763 SettingResetButton,
6864 SettingsPageContainer,
......@@ -77,7 +73,6 @@ import {
7773 useServerObservability,
7874 useServerProviders,
7975} from "../../rpc/serverState";
80import { formatProviderKindLabel } from "../../providerModels";
8176
8277const THEME_OPTIONS = [
8378 {
......@@ -100,8 +95,26 @@ const TIMESTAMP_FORMAT_LABELS = {
10095 "24-hour": "24-hour",
10196} 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
103116type InstallProviderSettings = {
104 provider: ProviderKind;
117 provider: ProviderDriverKind;
105118 title: string;
106119 badgeLabel?: string;
107120 binaryPlaceholder: string;
......@@ -117,7 +130,7 @@ type InstallProviderSettings = {
117130
118131const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
119132 {
120 provider: "codex",
133 provider: ProviderDriverKind.make("codex"),
121134 title: "Codex",
122135 binaryPlaceholder: "Codex binary path",
123136 binaryDescription: "Path to the Codex binary",
......@@ -126,20 +139,20 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
126139 homeDescription: "Optional custom Codex home and config directory.",
127140 },
128141 {
129 provider: "claudeAgent",
142 provider: ProviderDriverKind.make("claudeAgent"),
130143 title: "Claude",
131144 binaryPlaceholder: "Claude binary path",
132145 binaryDescription: "Path to the Claude binary",
133146 },
134147 {
135 provider: "cursor",
148 provider: ProviderDriverKind.make("cursor"),
136149 title: "Cursor",
137150 badgeLabel: "Early Access",
138151 binaryPlaceholder: "Cursor agent binary path",
139152 binaryDescription: "Path to the Cursor agent binary",
140153 },
141154 {
142 provider: "opencode",
155 provider: ProviderDriverKind.make("opencode"),
143156 title: "OpenCode",
144157 binaryPlaceholder: "OpenCode binary path",
145158 binaryDescription: "Path to the OpenCode binary",
......@@ -151,78 +164,6 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
151164 },
152165] 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
226167function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
227168 useRelativeTimeTick();
228169 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
......@@ -457,11 +398,31 @@ export function useSettingsRestore(onRestored?: () => void) {
457398 settings.textGenerationModelSelection ?? null,
458399 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
459400 );
460 const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => {
461 const currentSettings = settings.providers[providerSettings.provider];
462 const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
463 return !Equal.equals(currentSettings, defaultSettings);
464 });
401 // A provider surface is "dirty" if either the legacy per-kind
402 // `settings.providers[kind]` struct differs from defaults (for users
403 // on pre-migration data) or the new `settings.providerInstances` map
404 // has any entries (every edit to a default slot promotes it into an
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
466427 const changedSettingLabels = useMemo(
467428 () => [
......@@ -540,47 +501,17 @@ export function GeneralSettingsPanel() {
540501 const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState<
541502 Partial<Record<"keybindings" | "logsDirectory", string | null>>
542503 >({});
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 >({});
581504 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>>({});
582514 const refreshingRef = useRef(false);
583 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});
584515 const refreshProviders = useCallback(() => {
585516 if (refreshingRef.current) return;
586517 refreshingRef.current = true;
......@@ -603,9 +534,11 @@ export function GeneralSettingsPanel() {
603534 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
604535 (providerSettings) =>
605536 providerSettings.provider !== "cursor" ||
606 serverProviders.some((provider) => provider.provider === "cursor"),
537 serverProviders.some(
538 (provider) =>
539 provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
540 ),
607541 );
608 const codexHomePath = settings.providers.codex.homePath;
609542 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
610543 const diagnosticsDescription = (() => {
611544 const exports: string[] = [];
......@@ -620,13 +553,21 @@ export function GeneralSettingsPanel() {
620553 })();
621554
622555 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
623 const textGenProvider = textGenerationModelSelection.provider;
556 const textGenInstanceId = textGenerationModelSelection.instanceId;
624557 const textGenModel = textGenerationModelSelection.model;
625558 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(
627568 settings,
628569 serverProviders,
629 textGenProvider,
570 textGenInstanceId,
630571 textGenModel,
631572 );
632573 const isGitWritingModelDirty = !Equal.equals(
......@@ -678,140 +619,6 @@ export function GeneralSettingsPanel() {
678619 const isOpeningKeybindings = openingPathByTarget.keybindings;
679620 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
815622 const lastCheckedAt =
816623 serverProviders.length > 0
817624 ? serverProviders.reduce(
......@@ -820,6 +627,213 @@ export function GeneralSettingsPanel() {
820627 )
821628 : 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
823837 return (
824838 <SettingsPageContainer>
825839 <SettingsSection title="General">
......@@ -1033,10 +1047,10 @@ export function GeneralSettingsPanel() {
10331047 ) : null
10341048 }
10351049 control={
1036 <Input
1050 <DraftInput
10371051 className="w-full sm:w-72"
10381052 value={settings.addProjectBaseDirectory}
1039 onChange={(event) => updateSettings({ addProjectBaseDirectory: event.target.value })}
1053 onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })}
10401054 placeholder="~/"
10411055 spellCheck={false}
10421056 aria-label="Add project base directory"
......@@ -1115,19 +1129,19 @@ export function GeneralSettingsPanel() {
11151129 control={
11161130 <div className="flex flex-wrap items-center justify-end gap-1.5">
11171131 <ProviderModelPicker
1118 provider={textGenProvider}
1132 activeInstanceId={textGenInstanceId}
11191133 model={textGenModel}
11201134 lockedProvider={null}
1121 providers={serverProviders}
1122 modelOptionsByProvider={gitModelOptionsByProvider}
1135 instanceEntries={gitModelInstanceEntries}
1136 modelOptionsByInstance={gitModelOptionsByInstance}
11231137 triggerVariant="outline"
11241138 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
1125 onProviderModelChange={(provider, model) => {
1139 onInstanceModelChange={(instanceId, model) => {
11261140 updateSettings({
11271141 textGenerationModelSelection: resolveAppModelSelectionState(
11281142 {
11291143 ...settings,
1130 textGenerationModelSelection: createModelSelection(provider, model),
1144 textGenerationModelSelection: createModelSelection(instanceId, model),
11311145 },
11321146 serverProviders,
11331147 ),
......@@ -1137,8 +1151,11 @@ export function GeneralSettingsPanel() {
11371151 <TraitsPicker
11381152 provider={textGenProvider}
11391153 models={
1140 serverProviders.find((provider) => provider.provider === textGenProvider)
1141 ?.models ?? []
1154 // Use the exact instance's models (rather than the
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 ?? []
11421159 }
11431160 model={textGenModel}
11441161 prompt=""
......@@ -1153,7 +1170,7 @@ export function GeneralSettingsPanel() {
11531170 {
11541171 ...settings,
11551172 textGenerationModelSelection: createModelSelection(
1156 textGenProvider,
1173 textGenInstanceId,
11571174 textGenModel,
11581175 nextOptions,
11591176 ),
......@@ -1173,6 +1190,22 @@ export function GeneralSettingsPanel() {
11731190 headerAction={
11741191 <div className="flex items-center gap-1.5">
11751192 <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>
11761209 <Tooltip>
11771210 <TooltipTrigger
11781211 render={
......@@ -1197,447 +1230,90 @@ export function GeneralSettingsPanel() {
11971230 </div>
11981231 }
11991232 >
1200 {providerCards.map((providerCard) => {
1201 const customModelInput = customModelInputByProvider[providerCard.provider];
1202 const customModelError = customModelErrorByProvider[providerCard.provider] ?? null;
1203 const providerDisplayName =
1204 providerCard.liveProvider?.displayName?.trim() ||
1205 providerCard.title ||
1206 formatProviderKindLabel(providerCard.provider);
1207
1233 {rows.map((row) => {
1234 const driverOption = getDriverOption(row.driver);
1235 const liveProvider = serverProviders.find(
1236 (candidate) => candidate.instanceId === row.instanceId,
1237 );
1238 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
1239 hiddenModels: [],
1240 modelOrder: [],
1241 };
1242 const favoriteModels = (settings.favorites ?? [])
1243 .filter((favorite) => favorite.provider === row.instanceId)
1244 .map((favorite) => favorite.model);
1245 const resetLabel = driverOption?.label ?? String(row.driver);
1246 const headerAction =
1247 row.isDefault && row.isDirty ? (
1248 <SettingResetButton
1249 label={`${resetLabel} provider settings`}
1250 onClick={() => resetDefaultInstance(row.driver)}
1251 />
1252 ) : null;
12081253 return (
1209 <div key={providerCard.provider} className="border-t border-border first:border-t-0">
1210 <div className="px-4 py-4 sm:px-5">
1211 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1212 <div className="min-w-0 flex-1 space-y-1">
1213 <div className="flex min-h-5 items-center gap-1.5">
1214 <span
1215 className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)}
1216 />
1217 <h3 className="text-sm font-medium text-foreground">{providerDisplayName}</h3>
1218 {providerCard.badgeLabel ? (
1219 <Badge variant="warning" size="sm" className="shrink-0">
1220 {providerCard.badgeLabel}
1221 </Badge>
1222 ) : null}
1223 {providerCard.versionLabel ? (
1224 <code className="text-xs text-muted-foreground">
1225 {providerCard.versionLabel}
1226 </code>
1227 ) : null}
1228 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
1229 {providerCard.isDirty ? (
1230 <SettingResetButton
1231 label={`${providerDisplayName} provider settings`}
1232 onClick={() => {
1233 updateSettings({
1234 providers: {
1235 ...settings.providers,
1236 [providerCard.provider]:
1237 DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider],
1238 },
1239 });
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 }))
1254 <ProviderInstanceCard
1255 key={row.instanceId}
1256 instanceId={row.instanceId}
1257 instance={row.instance}
1258 driverOption={driverOption}
1259 liveProvider={liveProvider}
1260 isExpanded={openInstanceDetails[row.instanceId] ?? false}
1261 onExpandedChange={(open) =>
1262 setOpenInstanceDetails((existing) => ({
1263 ...existing,
1264 [row.instanceId]: open,
1265 }))
1266 }
1267 onUpdate={(next) => {
1268 // When the user disables the exact instance the text-gen
1269 // selection points at, fall back to the global default so we
1270 // don't leave the selection dangling on a disabled instance.
1271 // Prior kind-level behavior cleared on any kind-matching
1272 // disable; instance-level addressing makes this narrower and
1273 // more accurate (other instances of the same kind stay
1274 // untouched).
1275 const wasEnabled = row.instance.enabled ?? true;
1276 const isDisabling = next.enabled === false && wasEnabled;
1277 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
1278 if (shouldClearTextGen) {
1279 updateProviderInstance(row, next, {
1280 textGenerationModelSelection:
1281 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
1282 });
1283 } else {
1284 updateProviderInstance(row, next);
13091285 }
1310 >
1311 <CollapsibleContent>
1312 <div className="space-y-0">
1313 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1314 <label
1315 htmlFor={`provider-install-${providerCard.provider}-binary-path`}
1316 className="block"
1317 >
1318 <span className="text-xs font-medium text-foreground">
1319 {providerDisplayName} binary path
1320 </span>
1321 <Input
1322 id={`provider-install-${providerCard.provider}-binary-path`}
1323 className="mt-1.5"
1324 value={providerCard.binaryPathValue}
1325 onChange={(event) =>
1326 updateSettings({
1327 providers: {
1328 ...settings.providers,
1329 [providerCard.provider]: {
1330 ...settings.providers[providerCard.provider],
1331 binaryPath: event.target.value,
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>
1286 }}
1287 onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)}
1288 headerAction={headerAction}
1289 hiddenModels={modelPreferences.hiddenModels}
1290 favoriteModels={favoriteModels}
1291 modelOrder={modelPreferences.modelOrder}
1292 onHiddenModelsChange={(hiddenModels) =>
1293 updateProviderModelPreferences(row.instanceId, {
1294 ...modelPreferences,
1295 hiddenModels,
1296 })
1297 }
1298 onFavoriteModelsChange={(favoriteModels) =>
1299 updateProviderFavoriteModels(row.instanceId, favoriteModels)
1300 }
1301 onModelOrderChange={(modelOrder) =>
1302 updateProviderModelPreferences(row.instanceId, {
1303 ...modelPreferences,
1304 modelOrder,
1305 })
1306 }
1307 />
16371308 );
16381309 })}
16391310 </SettingsSection>
16401311
1312 <AddProviderInstanceDialog
1313 open={isAddInstanceDialogOpen}
1314 onOpenChange={setIsAddInstanceDialogOpen}
1315 />
1316
16411317 <SettingsSection title="Advanced">
16421318 <SettingsRow
16431319 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 {
66} from "@t3tools/client-runtime";
77import * as Schema from "effect/Schema";
88import {
9 defaultInstanceIdForDriver,
910 EnvironmentId,
1011 ProjectId,
12 ProviderDriverKind,
13 ProviderInstanceId,
1114 ThreadId,
1215 type ModelSelection,
13 type ProviderKind,
1416 type ProviderOptionSelection,
1517} from "@t3tools/contracts";
1618import { 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
1830type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>;
19type ProviderOptionSelectionsByProvider = Partial<Record<ProviderKind, ProviderOptionSelectionBag>>;
31type ProviderOptionSelectionsByProvider = Partial<Record<string, ProviderOptionSelectionBag>>;
2032
2133function toSelections(
2234 options: Record<string, string | boolean | undefined> | undefined,
......@@ -32,11 +44,11 @@ function toSelections(
3244}
3345
3446function selectionsByProvider(
35 options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>,
47 options: Partial<Record<ProviderDriverKind, Record<string, string | boolean | undefined>>>,
3648): ProviderOptionSelectionsByProvider {
3749 const result: ProviderOptionSelectionsByProvider = {};
3850 for (const [provider, bag] of Object.entries(options) as Array<
39 [ProviderKind, Record<string, string | boolean | undefined>]
51 [ProviderDriverKind, Record<string, string | boolean | undefined>]
4052 >) {
4153 result[provider] = toSelections(bag);
4254 }
......@@ -121,15 +133,15 @@ function resetComposerDraftStore() {
121133}
122134
123135function modelSelection(
124 provider: "codex" | "claudeAgent" | "cursor",
136 provider: ProviderDriverKind,
125137 model: string,
126138 options?: Record<string, string | boolean | undefined>,
127139): ModelSelection {
128 return createModelSelection(provider, model, toSelections(options));
140 return createModelSelection(defaultInstanceIdForDriver(provider), model, toSelections(options));
129141}
130142
131143function providerModelOptions(
132 options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>,
144 options: Partial<Record<string, Record<string, string | boolean | undefined>>>,
133145): ProviderOptionSelectionsByProvider {
134146 return selectionsByProvider(options);
135147}
......@@ -929,14 +941,16 @@ describe("composerDraftStore modelSelection", () => {
929941 const store = useComposerDraftStore.getState();
930942 store.setModelSelection(
931943 threadRef,
932 modelSelection("codex", "gpt-5.3-codex", {
944 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
933945 reasoningEffort: "xhigh",
934946 fastMode: true,
935947 }),
936948 );
937949
938 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
939 modelSelection("codex", "gpt-5.3-codex", {
950 expect(
951 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
952 ).toEqual(
953 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
940954 reasoningEffort: "xhigh",
941955 fastMode: true,
942956 }),
......@@ -945,11 +959,11 @@ describe("composerDraftStore modelSelection", () => {
945959
946960 it("keeps default-only model selections on the draft", () => {
947961 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(
951 modelSelection("codex", "gpt-5.4"),
952 );
964 expect(
965 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
966 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));
953967 });
954968
955969 it("replaces only the targeted provider options on the current model selection", () => {
......@@ -957,29 +971,38 @@ describe("composerDraftStore modelSelection", () => {
957971
958972 store.setModelSelection(
959973 threadRef,
960 modelSelection("claudeAgent", "claude-opus-4-6", {
974 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
961975 effort: "max",
962976 fastMode: true,
963977 }),
964978 );
965979 store.setStickyModelSelection(
966 modelSelection("claudeAgent", "claude-opus-4-6", {
980 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
967981 effort: "max",
968982 fastMode: true,
969983 }),
970984 );
971985
972 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), {
973 persistSticky: true,
974 });
986 store.setProviderModelOptions(
987 threadRef,
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(
977 modelSelection("claudeAgent", "claude-opus-4-6", {
995 expect(
996 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
997 ).toEqual(
998 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
978999 thinking: false,
9791000 }),
9801001 );
981 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
982 modelSelection("claudeAgent", "claude-opus-4-6", {
1002 expect(
1003 useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
1004 ).toEqual(
1005 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
9831006 thinking: false,
9841007 }),
9851008 );
......@@ -990,15 +1013,17 @@ describe("composerDraftStore modelSelection", () => {
9901013
9911014 store.setModelSelection(
9921015 threadRef,
993 modelSelection("claudeAgent", "claude-opus-4-6", {
1016 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
9941017 effort: "max",
9951018 }),
9961019 );
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(
1001 modelSelection("claudeAgent", "claude-opus-4-6", {
1023 expect(
1024 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
1025 ).toEqual(
1026 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
10021027 thinking: true,
10031028 }),
10041029 );
......@@ -1008,16 +1033,18 @@ describe("composerDraftStore modelSelection", () => {
10081033 it("keeps explicit off/default codex overrides on the selection", () => {
10091034 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
10131038 store.setProviderModelOptions(
10141039 threadRef,
1015 "codex",
1040 CODEX_DRIVER,
10161041 toSelections({ reasoningEffort: "high", fastMode: false }),
10171042 );
10181043
1019 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
1020 modelSelection("codex", "gpt-5.4", {
1044 expect(
1045 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
1046 ).toEqual(
1047 modelSelection(CODEX_DRIVER, "gpt-5.4", {
10211048 reasoningEffort: "high",
10221049 fastMode: false,
10231050 }),
......@@ -1029,7 +1056,7 @@ describe("composerDraftStore modelSelection", () => {
10291056
10301057 store.setModelSelection(
10311058 threadRef,
1032 modelSelection("cursor", "claude-opus-4-6", {
1059 modelSelection(CURSOR_DRIVER, "claude-opus-4-6", {
10331060 reasoning: "xhigh",
10341061 fastMode: true,
10351062 thinking: false,
......@@ -1038,12 +1065,14 @@ describe("composerDraftStore modelSelection", () => {
10381065
10391066 store.setProviderModelOptions(
10401067 threadRef,
1041 "cursor",
1068 CURSOR_DRIVER,
10421069 toSelections({ reasoning: "medium", fastMode: false, thinking: true }),
10431070 );
10441071
1045 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual(
1046 modelSelection("cursor", "claude-opus-4-6", {
1072 expect(
1073 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CURSOR_INSTANCE],
1074 ).toEqual(
1075 modelSelection(CURSOR_DRIVER, "claude-opus-4-6", {
10471076 reasoning: "medium",
10481077 fastMode: false,
10491078 thinking: true,
......@@ -1054,18 +1083,22 @@ describe("composerDraftStore modelSelection", () => {
10541083 it("preserves the selected Cursor model when only traits change", () => {
10551084 const store = useComposerDraftStore.getState();
10561085
1057 store.setProviderModelOptions(threadRef, "cursor", toSelections({ reasoning: "high" }), {
1086 store.setProviderModelOptions(threadRef, CURSOR_DRIVER, toSelections({ reasoning: "high" }), {
10581087 model: "gpt-5.4",
10591088 persistSticky: true,
10601089 });
10611090
1062 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual(
1063 modelSelection("cursor", "gpt-5.4", {
1091 expect(
1092 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CURSOR_INSTANCE],
1093 ).toEqual(
1094 modelSelection(CURSOR_DRIVER, "gpt-5.4", {
10641095 reasoning: "high",
10651096 }),
10661097 );
1067 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual(
1068 modelSelection("cursor", "gpt-5.4", {
1098 expect(
1099 useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
1100 ).toEqual(
1101 modelSelection(CURSOR_DRIVER, "gpt-5.4", {
10691102 reasoning: "high",
10701103 }),
10711104 );
......@@ -1075,23 +1108,29 @@ describe("composerDraftStore modelSelection", () => {
10751108 const store = useComposerDraftStore.getState();
10761109
10771110 store.setStickyModelSelection(
1078 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1111 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
10791112 );
10801113 store.setModelSelection(
10811114 threadRef,
1082 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1115 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
10831116 );
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(
1088 modelSelection("claudeAgent", "claude-opus-4-6", {
1124 expect(
1125 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
1126 ).toEqual(
1127 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
10891128 thinking: false,
10901129 }),
10911130 );
1092 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
1093 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1094 );
1131 expect(
1132 useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
1133 ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }));
10951134 });
10961135
10971136 it("does not clear other provider options when setting options for a single provider", () => {
......@@ -1110,13 +1149,17 @@ describe("composerDraftStore modelSelection", () => {
11101149 store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
11111150
11121151 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
1113 expect(draft?.modelSelectionByProvider.codex?.options).toEqual(
1114 createModelSelection("codex", "gpt-5.4", toSelections({ reasoningEffort: "xhigh" })).options,
1115 );
1116 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual(
1117 createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" }))
1152 expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]?.options).toEqual(
1153 createModelSelection(CODEX_INSTANCE, "gpt-5.4", toSelections({ reasoningEffort: "xhigh" }))
11181154 .options,
11191155 );
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 );
11201163 });
11211164
11221165 it("preserves other provider options when switching the active model selection", () => {
......@@ -1130,14 +1173,14 @@ describe("composerDraftStore modelSelection", () => {
11301173 }),
11311174 );
11321175
1133 store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6"));
1176 store.setModelSelection(threadRef, modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"));
11341177
11351178 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
1136 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(
1137 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1179 expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual(
1180 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
11381181 );
1139 expect(draft?.modelSelectionByProvider.codex?.options).toEqual(
1140 createModelSelection("codex", "gpt-5.4", toSelections({ fastMode: true })).options,
1182 expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]?.options).toEqual(
1183 createModelSelection(CODEX_INSTANCE, "gpt-5.4", toSelections({ fastMode: true })).options,
11411184 );
11421185 expect(draft?.activeProvider).toBe("claudeAgent");
11431186 });
......@@ -1145,14 +1188,14 @@ describe("composerDraftStore modelSelection", () => {
11451188 it("creates the first sticky snapshot from provider option changes", () => {
11461189 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 }), {
11511194 persistSticky: true,
11521195 });
11531196
1154 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
1155 modelSelection("codex", "gpt-5.4", {
1197 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
1198 modelSelection(CODEX_DRIVER, "gpt-5.4", {
11561199 fastMode: true,
11571200 }),
11581201 );
......@@ -1162,25 +1205,32 @@ describe("composerDraftStore modelSelection", () => {
11621205 const store = useComposerDraftStore.getState();
11631206
11641207 store.setStickyModelSelection(
1165 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1208 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
11661209 );
11671210 store.setModelSelection(
11681211 threadRef,
1169 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1212 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
11701213 );
11711214
1172 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), {
1173 persistSticky: false,
1174 });
1215 store.setProviderModelOptions(
1216 threadRef,
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(
1177 modelSelection("claudeAgent", "claude-opus-4-6", {
1224 expect(
1225 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
1226 ).toEqual(
1227 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
11781228 thinking: false,
11791229 }),
11801230 );
1181 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
1182 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
1183 );
1231 expect(
1232 useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
1233 ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }));
11841234 });
11851235});
11861236
......@@ -1195,11 +1245,11 @@ describe("composerDraftStore setModelSelection", () => {
11951245 it("keeps explicit model overrides instead of coercing to null", () => {
11961246 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(
1201 modelSelection("codex", "gpt-5.3-codex"),
1202 );
1250 expect(
1251 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
1252 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.3-codex"));
12031253 });
12041254});
12051255
......@@ -1212,14 +1262,14 @@ describe("composerDraftStore sticky composer settings", () => {
12121262 const store = useComposerDraftStore.getState();
12131263
12141264 store.setStickyModelSelection(
1215 modelSelection("codex", "gpt-5.3-codex", {
1265 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
12161266 reasoningEffort: "medium",
12171267 fastMode: true,
12181268 }),
12191269 );
12201270
1221 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
1222 modelSelection("codex", "gpt-5.3-codex", {
1271 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
1272 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
12231273 reasoningEffort: "medium",
12241274 fastMode: true,
12251275 }),
......@@ -1230,10 +1280,10 @@ describe("composerDraftStore sticky composer settings", () => {
12301280 it("normalizes empty sticky model options by dropping selection options", () => {
12311281 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(
1236 modelSelection("codex", "gpt-5.4"),
1285 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
1286 modelSelection(CODEX_DRIVER, "gpt-5.4"),
12371287 );
12381288 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
12391289 });
......@@ -1242,7 +1292,7 @@ describe("composerDraftStore sticky composer settings", () => {
12421292 const store = useComposerDraftStore.getState();
12431293
12441294 store.setStickyModelSelection(
1245 modelSelection("cursor", "gpt-5.4", {
1295 modelSelection(CURSOR_DRIVER, "gpt-5.4", {
12461296 reasoning: undefined,
12471297 fastMode: undefined,
12481298 thinking: undefined,
......@@ -1250,9 +1300,9 @@ describe("composerDraftStore sticky composer settings", () => {
12501300 }),
12511301 );
12521302
1253 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual(
1254 modelSelection("cursor", "gpt-5.4"),
1255 );
1303 expect(
1304 useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
1305 ).toEqual(modelSelection(CURSOR_DRIVER, "gpt-5.4"));
12561306 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");
12571307 });
12581308
......@@ -1261,12 +1311,12 @@ describe("composerDraftStore sticky composer settings", () => {
12611311 const threadId = ThreadId.make("thread-sticky-active-provider");
12621312 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"));
12651315 store.applyStickyState(threadRef);
12661316
12671317 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({
12681318 modelSelectionByProvider: {
1269 claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"),
1319 claudeAgent: modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"),
12701320 },
12711321 activeProvider: "claudeAgent",
12721322 });
......@@ -1285,18 +1335,21 @@ describe("composerDraftStore provider-scoped option updates", () => {
12851335 const store = useComposerDraftStore.getState();
12861336 store.setModelSelection(
12871337 threadRef,
1288 modelSelection("codex", "gpt-5.3-codex", {
1338 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
12891339 reasoningEffort: "medium",
12901340 }),
12911341 );
1292 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ effort: "max" }));
1342 store.setProviderModelOptions(threadRef, CLAUDE_AGENT_DRIVER, toSelections({ effort: "max" }));
12931343 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
1294 expect(draft?.modelSelectionByProvider.codex).toEqual(
1295 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),
1344 expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual(
1345 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "medium" }),
12961346 );
1297 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual(
1298 createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" }))
1299 .options,
1347 expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]?.options).toEqual(
1348 createModelSelection(
1349 CLAUDE_AGENT_INSTANCE,
1350 "claude-opus-4-6",
1351 toSelections({ effort: "max" }),
1352 ).options,
13001353 );
13011354 expect(draft?.activeProvider).toBe("codex");
13021355 });
apps/web/src/composerDraftStore.ts+263-119
......@@ -1,10 +1,13 @@
11import {
2 DEFAULT_MODEL,
23 DEFAULT_MODEL_BY_PROVIDER,
4 defaultInstanceIdForDriver,
35 type EnvironmentId,
46 ModelSelection,
57 ProjectId,
8 ProviderInstanceId,
69 ProviderInteractionMode,
7 ProviderKind,
10 ProviderDriverKind,
811 ProviderOptionSelection,
912 RuntimeMode,
1013 type ServerProvider,
......@@ -26,7 +29,7 @@ import { DeepMutable } from "effect/Types";
2629import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2730import { useMemo } from "react";
2831import { getLocalStorageItem } from "./hooks/useLocalStorage";
29import { resolveAppModelSelection } from "./modelSelection";
32import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
3033import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
3134import {
3235 type TerminalContextDraft,
......@@ -92,10 +95,19 @@ const PersistedComposerThreadDraftState = Schema.Struct({
9295 prompt: Schema.String,
9396 attachments: Schema.Array(PersistedComposerImageAttachment),
9497 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
95 modelSelectionByProvider: Schema.optionalKey(
96 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
97 ),
98 activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),
98 // Keyed by `ProviderInstanceId` (open branded slug) so custom provider
99 // instances (e.g. `codex_personal`) round-trip alongside the built-in
100 // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind`
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)),
99111 runtimeMode: Schema.optionalKey(RuntimeMode),
100112 interactionMode: Schema.optionalKey(ProviderInteractionMode),
101113});
......@@ -107,7 +119,7 @@ type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftStat
107119 * deriving per-provider option bundles for downstream consumers.
108120 */
109121type ProviderOptionSelectionsByProvider = Partial<
110 Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>
122 Record<string, ReadonlyArray<ProviderOptionSelection>>
111123>;
112124
113125type LegacyCodexFields = {
......@@ -182,9 +194,9 @@ const PersistedComposerDraftStoreState = Schema.Struct({
182194 draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState),
183195 logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String),
184196 stickyModelSelectionByProvider: Schema.optionalKey(
185 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
197 Schema.Record(ProviderInstanceId, ModelSelection),
186198 ),
187 stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),
199 stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)),
188200});
189201type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
190202
......@@ -203,8 +215,17 @@ export interface ComposerThreadDraftState {
203215 nonPersistedImageIds: string[];
204216 persistedAttachments: PersistedComposerImageAttachment[];
205217 terminalContexts: TerminalContextDraft[];
206 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
207 activeProvider: ProviderKind | null;
218 /**
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;
208229 runtimeMode: RuntimeMode | null;
209230 interactionMode: ProviderInteractionMode | null;
210231}
......@@ -259,8 +280,8 @@ interface ComposerDraftStoreState {
259280 draftsByThreadKey: Record<string, ComposerThreadDraftState>;
260281 draftThreadsByThreadKey: Record<string, DraftThreadState>;
261282 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
262 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
263 stickyActiveProvider: ProviderKind | null;
283 stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
284 stickyActiveProvider: ProviderInstanceId | null;
264285 /** Returns the editable composer content for a draft session or server thread. */
265286 getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null;
266287 /** Looks up the active draft session for a logical project identity. */
......@@ -339,14 +360,14 @@ interface ComposerDraftStoreState {
339360 setModelOptions: (
340361 threadRef: ComposerThreadTarget,
341362 modelOptions:
342 | Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>>
363 | Partial<Record<string, ReadonlyArray<ProviderOptionSelection>>>
343364 | null
344365 | undefined,
345366 ) => void;
346367 applyStickyState: (threadRef: ComposerThreadTarget) => void;
347368 setProviderModelOptions: (
348369 threadRef: ComposerThreadTarget,
349 provider: ProviderKind,
370 provider: ProviderDriverKind,
350371 nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
351372 options?: {
352373 model?: string | null | undefined;
......@@ -388,8 +409,8 @@ export interface EffectiveComposerModelState {
388409}
389410
390411interface ComposerDraftModelState {
391 activeProvider: ProviderKind | null;
392 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
412 activeProvider: ProviderInstanceId | null;
413 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
393414}
394415
395416function providerSelectionsFromModelSelection(
......@@ -402,17 +423,17 @@ function providerSelectionsFromModelSelection(
402423 if (!options || options.length === 0) {
403424 return null;
404425 }
405 return { [modelSelection.provider]: options } as ProviderOptionSelectionsByProvider;
426 return { [modelSelection.instanceId]: options };
406427}
407428
408429function modelSelectionByProviderToOptions(
409 map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined,
430 map: Partial<Record<string, ModelSelection>> | null | undefined,
410431): ProviderOptionSelectionsByProvider | null {
411432 if (!map) return null;
412 const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {};
433 const result: ProviderOptionSelectionsByProvider = {};
413434 for (const [provider, selection] of Object.entries(map)) {
414435 if (selection?.options && selection.options.length > 0) {
415 result[provider as ProviderKind] = selection.options;
436 result[provider] = selection.options;
416437 }
417438 }
418439 return Object.keys(result).length > 0 ? result : null;
......@@ -425,15 +446,14 @@ function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelect
425446 } as DeepMutable<ModelSelection>;
426447}
427448
428function cloneModelSelectionByProvider(
429 selections: Partial<Record<ProviderKind, ModelSelection>>,
430): DeepMutable<Partial<Record<ProviderKind, ModelSelection>>> {
449function compactModelSelectionByProvider(
450 selections: Partial<Record<ProviderInstanceId, ModelSelection>>,
451): DeepMutable<Record<ProviderInstanceId, ModelSelection>> {
431452 return Object.fromEntries(
432 Object.entries(selections).map(([provider, selection]) => [
433 provider,
434 selection ? cloneModelSelection(selection) : selection,
435 ]),
436 ) as DeepMutable<Partial<Record<ProviderKind, ModelSelection>>>;
453 Object.entries(selections)
454 .filter((entry): entry is [string, ModelSelection] => entry[1] !== undefined)
455 .map(([provider, selection]) => [provider, cloneModelSelection(selection)]),
456 ) as DeepMutable<Record<ProviderInstanceId, ModelSelection>>;
437457}
438458
439459const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
......@@ -451,7 +471,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
451471Object.freeze(EMPTY_IMAGES);
452472Object.freeze(EMPTY_IDS);
453473Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
454const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> =
474const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderDriverKind, ModelSelection>> =
455475 Object.freeze({});
456476const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({
457477 activeProvider: null,
......@@ -554,10 +574,28 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
554574 );
555575}
556576
557function normalizeProviderKind(value: unknown): ProviderKind | null {
558 return value === "codex" || value === "claudeAgent" || value === "cursor" || value === "opencode"
559 ? value
560 : null;
577function normalizeProviderDriverKind(value: unknown): ProviderDriverKind | null {
578 return Schema.is(ProviderDriverKind)(value) ? value : null;
579}
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;
561599}
562600
563601/**
......@@ -610,11 +648,11 @@ function coerceProviderOptionSelections(
610648 */
611649function normalizeProviderModelOptions(
612650 value: unknown,
613 provider?: ProviderKind | null,
651 provider?: ProviderDriverKind | null,
614652 legacy?: LegacyCodexFields,
615653): ProviderOptionSelectionsByProvider | null {
616654 const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null;
617 const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {};
655 const result: ProviderOptionSelectionsByProvider = {};
618656 for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
619657 const selections = coerceProviderOptionSelections(candidate?.[providerKey]);
620658 if (selections) {
......@@ -648,6 +686,13 @@ function normalizeProviderModelOptions(
648686 return Object.keys(result).length > 0 ? result : null;
649687}
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.
651696function normalizeModelSelection(
652697 value: unknown,
653698 legacy?: {
......@@ -656,63 +701,98 @@ function normalizeModelSelection(
656701 modelOptions?: unknown;
657702 legacyCodex?: LegacyCodexFields;
658703 },
659): ModelSelection | null {
704): NormalizedModelSelection | null {
660705 const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null;
661 const provider = normalizeProviderKind(candidate?.provider ?? legacy?.provider);
662 if (provider === null) {
706 // Post-migration ModelSelection carries `instanceId`; pre-migration (v2
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) {
663713 return null;
664714 }
665715 const rawModel = candidate?.model ?? legacy?.model;
666716 if (typeof rawModel !== "string") {
667717 return null;
668718 }
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);
670726 if (!model) {
671727 return null;
672728 }
673729 if (Array.isArray(candidate?.options)) {
674730 const selections = coerceProviderOptionSelections(candidate.options);
675 return createModelSelection(provider, model, selections);
731 return createModelSelection(instanceId, model, selections) as NormalizedModelSelection;
676732 }
677 const modelOptions = normalizeProviderModelOptions(
678 candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions,
679 provider,
680 provider === "codex" ? legacy?.legacyCodex : undefined,
681 );
682 const options = modelOptions?.[provider];
683 return createModelSelection(provider, model, options);
733 // Per-kind options were a pre-migration concern; only recover them for a
734 // built-in-kind instance. Custom instances don't have a legacy options
735 // store to thread through here.
736 const kindForLegacyOptions = normalizeProviderDriverKind(instanceId);
737 const modelOptions = kindForLegacyOptions
738 ? normalizeProviderModelOptions(
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;
684746}
685747
748type NormalizedModelSelection = Omit<ModelSelection, "instanceId"> & {
749 readonly instanceId: ProviderInstanceId;
750};
751
686752// ── 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
688759function legacySyncModelSelectionOptions(
689 modelSelection: ModelSelection | null,
760 modelSelection: NormalizedModelSelection | null,
690761 modelOptions: ProviderOptionSelectionsByProvider | null | undefined,
691): ModelSelection | null {
762): NormalizedModelSelection | null {
692763 if (modelSelection === null) {
693764 return null;
694765 }
695 const options = modelOptions?.[modelSelection.provider];
696 return createModelSelection(modelSelection.provider, modelSelection.model, options);
766 const kind = normalizeProviderDriverKind(modelSelection.instanceId);
767 const options = kind ? modelOptions?.[kind] : undefined;
768 return createModelSelection(
769 modelSelection.instanceId,
770 modelSelection.model,
771 options,
772 ) as NormalizedModelSelection;
697773}
698774
699775function legacyMergeModelSelectionIntoProviderModelOptions(
700 modelSelection: ModelSelection | null,
776 modelSelection: NormalizedModelSelection | null,
701777 currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined,
702778): ProviderOptionSelectionsByProvider | null {
703779 if (!modelSelection?.options || modelSelection.options.length === 0) {
704780 return normalizeProviderModelOptions(currentModelOptions);
705781 }
782 const kind = normalizeProviderDriverKind(modelSelection.instanceId);
783 if (!kind) {
784 return normalizeProviderModelOptions(currentModelOptions);
785 }
706786 return legacyReplaceProviderModelOptions(
707787 normalizeProviderModelOptions(currentModelOptions),
708 modelSelection.provider,
788 kind,
709789 modelSelection.options,
710790 );
711791}
712792
713793function legacyReplaceProviderModelOptions(
714794 currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined,
715 provider: ProviderKind,
795 provider: ProviderDriverKind,
716796 nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
717797): ProviderOptionSelectionsByProvider | null {
718798 const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } =
......@@ -727,26 +807,28 @@ function legacyReplaceProviderModelOptions(
727807// ── New helpers for the consolidated representation ────────────────────
728808
729809function legacyToModelSelectionByProvider(
730 modelSelection: ModelSelection | null,
810 modelSelection: NormalizedModelSelection | null,
731811 modelOptions: ProviderOptionSelectionsByProvider | null | undefined,
732): Partial<Record<ProviderKind, ModelSelection>> {
733 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
812): Partial<Record<ProviderInstanceId, ModelSelection>> {
813 const result: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
734814 if (modelOptions) {
735815 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
736816 const options = modelOptions[provider];
737817 if (options && options.length > 0) {
738 result[provider] = createModelSelection(
739 provider,
740 modelSelection?.provider === provider
818 const driverKind = ProviderDriverKind.make(provider);
819 const instanceKey = defaultInstanceIdForDriver(driverKind);
820 result[instanceKey] = createModelSelection(
821 instanceKey,
822 modelSelection?.instanceId === instanceKey
741823 ? modelSelection.model
742 : DEFAULT_MODEL_BY_PROVIDER[provider],
824 : (DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL),
743825 options,
744826 );
745827 }
746828 }
747829 }
748830 if (modelSelection) {
749 result[modelSelection.provider] = modelSelection;
831 result[modelSelection.instanceId] = modelSelection as ModelSelection;
750832 }
751833 return result;
752834}
......@@ -757,24 +839,64 @@ export function deriveEffectiveComposerModelState(input: {
757839 | null
758840 | undefined;
759841 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;
761851 threadModelSelection: ModelSelection | null | undefined;
762852 projectModelSelection: ModelSelection | null | undefined;
763853 settings: UnifiedSettings;
764854}): EffectiveComposerModelState {
855 const baseModelCandidate =
856 input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null;
765857 const baseModel =
766 normalizeModelSlug(
767 input.threadModelSelection?.model ?? input.projectModelSelection?.model,
858 (input.selectedInstanceId
859 ? resolveAppModelSelectionForInstance(
860 input.selectedInstanceId,
861 input.settings,
862 input.providers,
863 baseModelCandidate,
864 )
865 : null) ??
866 resolveAppModelSelection(
768867 input.selectedProvider,
769 ) ?? getDefaultServerModel(input.providers, input.selectedProvider);
770 const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider];
868 input.settings,
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);
771887 const selectedModel = activeSelection?.model
772 ? resolveAppModelSelection(
888 ? (resolveAppModelSelectionForInstance(
889 activeSelectionInstanceId,
890 input.settings,
891 input.providers,
892 activeSelection.model,
893 ) ??
894 resolveAppModelSelection(
773895 input.selectedProvider,
774896 input.settings,
775897 input.providers,
776898 activeSelection.model,
777 )
899 ))
778900 : baseModel;
779901 const modelOptions =
780902 modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ??
......@@ -1365,8 +1487,8 @@ function normalizePersistedDraftsByThreadId(
13651487 );
13661488 // If the draft already has the v3 shape, use it directly
13671489 const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState;
1368 let modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {};
1369 let activeProvider: ProviderKind | null = null;
1490 let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
1491 let activeProvider: ProviderInstanceId | null = null;
13701492
13711493 if (
13721494 draftCandidate.modelSelectionByProvider &&
......@@ -1374,9 +1496,9 @@ function normalizePersistedDraftsByThreadId(
13741496 ) {
13751497 // v3 format
13761498 modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial<
1377 Record<ProviderKind, ModelSelection>
1499 Record<ProviderInstanceId, ModelSelection>
13781500 >;
1379 activeProvider = normalizeProviderKind(draftCandidate.activeProvider);
1501 activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider);
13801502 } else {
13811503 // v2 or legacy format: migrate
13821504 const normalizedModelOptions =
......@@ -1406,7 +1528,7 @@ function normalizePersistedDraftsByThreadId(
14061528 modelSelection,
14071529 mergedModelOptions,
14081530 );
1409 activeProvider = modelSelection?.provider ?? null;
1531 activeProvider = modelSelection?.instanceId ?? null;
14101532 }
14111533
14121534 const hasModelData =
......@@ -1439,7 +1561,7 @@ function normalizePersistedDraftsByThreadId(
14391561 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
14401562 ...(hasModelData
14411563 ? {
1442 modelSelectionByProvider: cloneModelSelectionByProvider(modelSelectionByProvider),
1564 modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),
14431565 activeProvider,
14441566 }
14451567 : {}),
......@@ -1486,7 +1608,7 @@ function migratePersistedComposerDraftStoreState(
14861608 stickyModelSelection,
14871609 nextStickyModelOptions,
14881610 );
1489 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
1611 const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null;
14901612
14911613 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
14921614 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey);
......@@ -1498,7 +1620,7 @@ function migratePersistedComposerDraftStoreState(
14981620 draftsByThreadKey,
14991621 draftThreadsByThreadKey,
15001622 logicalProjectDraftThreadKeyByLogicalProjectKey,
1501 stickyModelSelectionByProvider,
1623 stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider),
15021624 stickyActiveProvider,
15031625 };
15041626}
......@@ -1543,7 +1665,9 @@ function partializeComposerDraftStoreState(
15431665 : {}),
15441666 ...(hasModelData
15451667 ? {
1546 modelSelectionByProvider: cloneModelSelectionByProvider(draft.modelSelectionByProvider),
1668 modelSelectionByProvider: compactModelSelectionByProvider(
1669 draft.modelSelectionByProvider,
1670 ),
15471671 activeProvider: draft.activeProvider,
15481672 }
15491673 : {}),
......@@ -1557,7 +1681,9 @@ function partializeComposerDraftStoreState(
15571681 draftThreadsByThreadKey: state.draftThreadsByThreadKey,
15581682 logicalProjectDraftThreadKeyByLogicalProjectKey:
15591683 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
1560 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,
1684 stickyModelSelectionByProvider: compactModelSelectionByProvider(
1685 state.stickyModelSelectionByProvider,
1686 ),
15611687 stickyActiveProvider: state.stickyActiveProvider,
15621688 };
15631689}
......@@ -1580,17 +1706,19 @@ function normalizeCurrentPersistedComposerDraftStoreState(
15801706 );
15811707
15821708 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
1583 let stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {};
1584 let stickyActiveProvider: ProviderKind | null = null;
1709 let stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
1710 let stickyActiveProvider: ProviderInstanceId | null = null;
15851711 if (
15861712 normalizedPersistedState.stickyModelSelectionByProvider &&
15871713 typeof normalizedPersistedState.stickyModelSelectionByProvider === "object"
15881714 ) {
15891715 stickyModelSelectionByProvider =
15901716 normalizedPersistedState.stickyModelSelectionByProvider as Partial<
1591 Record<ProviderKind, ModelSelection>
1717 Record<ProviderInstanceId, ModelSelection>
15921718 >;
1593 stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyActiveProvider);
1719 stickyActiveProvider = normalizeProviderInstanceId(
1720 normalizedPersistedState.stickyActiveProvider,
1721 );
15941722 } else {
15951723 // Legacy migration path
15961724 const stickyModelOptions =
......@@ -1615,7 +1743,7 @@ function normalizeCurrentPersistedComposerDraftStoreState(
16151743 stickyModelSelection,
16161744 nextStickyModelOptions,
16171745 );
1618 stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider);
1746 stickyActiveProvider = normalizeProviderInstanceId(normalizedPersistedState.stickyProvider);
16191747 }
16201748
16211749 return {
......@@ -1625,7 +1753,7 @@ function normalizeCurrentPersistedComposerDraftStoreState(
16251753 ),
16261754 draftThreadsByThreadKey,
16271755 logicalProjectDraftThreadKeyByLogicalProjectKey,
1628 stickyModelSelectionByProvider,
1756 stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider),
16291757 stickyActiveProvider,
16301758 };
16311759}
......@@ -1754,9 +1882,9 @@ function toHydratedThreadDraft(
17541882 persistedDraft: PersistedComposerThreadDraftState,
17551883): ComposerThreadDraftState {
17561884 // 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>> =
17581886 persistedDraft.modelSelectionByProvider ?? {};
1759 const activeProvider = normalizeProviderKind(persistedDraft.activeProvider) ?? null;
1887 const activeProvider = normalizeProviderInstanceId(persistedDraft.activeProvider) ?? null;
17601888
17611889 return {
17621890 prompt: persistedDraft.prompt,
......@@ -2117,18 +2245,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
21172245 if (!normalized) {
21182246 return state;
21192247 }
2120 const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {
2248 const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = {
21212249 ...state.stickyModelSelectionByProvider,
2122 [normalized.provider]: normalized,
2250 [normalized.instanceId]: normalized,
21232251 };
21242252 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
2125 return state.stickyActiveProvider === normalized.provider
2253 return state.stickyActiveProvider === normalized.instanceId
21262254 ? state
2127 : { stickyActiveProvider: normalized.provider };
2255 : { stickyActiveProvider: normalized.instanceId };
21282256 }
21292257 return {
21302258 stickyModelSelectionByProvider: nextMap,
2131 stickyActiveProvider: normalized.provider,
2259 stickyActiveProvider: normalized.instanceId,
21322260 };
21332261 });
21342262 },
......@@ -2148,8 +2276,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
21482276 const nextMap = { ...base.modelSelectionByProvider };
21492277 for (const [provider, selection] of Object.entries(stickyMap)) {
21502278 if (selection) {
2151 const current = nextMap[provider as ProviderKind];
2152 nextMap[provider as ProviderKind] = {
2279 // Iteration key comes from the instance-keyed sticky map,
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] = {
21532285 ...selection,
21542286 model: current?.model ?? selection.model,
21552287 };
......@@ -2235,20 +2367,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
22352367 const base = existing ?? createEmptyThreadDraft();
22362368 const nextMap = { ...base.modelSelectionByProvider };
22372369 if (normalized) {
2238 const current = nextMap[normalized.provider];
2370 const current = nextMap[normalized.instanceId];
22392371 if (normalized.options !== undefined) {
22402372 // Explicit options provided → use them
2241 nextMap[normalized.provider] = normalized;
2373 nextMap[normalized.instanceId] = normalized as ModelSelection;
22422374 } else {
22432375 // No options in selection → preserve existing options, update provider+model
2244 nextMap[normalized.provider] = createModelSelection(
2245 normalized.provider,
2376 nextMap[normalized.instanceId] = createModelSelection(
2377 normalized.instanceId,
22462378 normalized.model,
22472379 current?.options,
22482380 );
22492381 }
22502382 }
2251 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
2383 const nextActiveProvider = normalized?.instanceId ?? base.activeProvider;
22522384 if (
22532385 Equal.equals(base.modelSelectionByProvider, nextMap) &&
22542386 base.activeProvider === nextActiveProvider
......@@ -2284,16 +2416,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
22842416 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
22852417 if (!modelOptions || !(provider in modelOptions)) continue;
22862418 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];
22882422 if (opts && opts.length > 0) {
2289 nextMap[provider] = createModelSelection(
2290 provider,
2291 current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
2423 nextMap[instanceKey] = createModelSelection(
2424 instanceKey,
2425 current?.model ?? DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL,
22922426 opts,
22932427 );
22942428 } else if (current?.options) {
22952429 const { options: _, ...rest } = current;
2296 nextMap[provider] = rest as ModelSelection;
2430 nextMap[instanceKey] = rest as ModelSelection;
22972431 }
22982432 }
22992433 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
......@@ -2317,13 +2451,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23172451 if (threadKey.length === 0) {
23182452 return;
23192453 }
2320 const normalizedProvider = normalizeProviderKind(provider);
2454 const normalizedProvider = normalizeProviderDriverKind(provider);
23212455 if (normalizedProvider === null) {
23222456 return;
23232457 }
2458 const instanceKey = defaultInstanceIdForDriver(normalizedProvider);
23242459 const fallbackModel =
23252460 normalizeModelSlug(options?.model, normalizedProvider) ??
2326 DEFAULT_MODEL_BY_PROVIDER[normalizedProvider];
2461 DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ??
2462 DEFAULT_MODEL;
23272463 const providerOpts =
23282464 nextProviderOptions && nextProviderOptions.length > 0 ? nextProviderOptions : undefined;
23292465
......@@ -2333,16 +2469,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23332469
23342470 // Update the map entry for this provider
23352471 const nextMap = { ...base.modelSelectionByProvider };
2336 const currentForProvider = nextMap[normalizedProvider];
2472 const currentForProvider = nextMap[instanceKey];
23372473 if (providerOpts) {
2338 nextMap[normalizedProvider] = createModelSelection(
2339 normalizedProvider,
2474 nextMap[instanceKey] = createModelSelection(
2475 instanceKey,
23402476 currentForProvider?.model ?? fallbackModel,
23412477 providerOpts,
23422478 );
23432479 } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) {
23442480 const { options: _, ...rest } = currentForProvider;
2345 nextMap[normalizedProvider] = rest as ModelSelection;
2481 nextMap[instanceKey] = rest as ModelSelection;
23462482 }
23472483
23482484 // Handle sticky persistence
......@@ -2351,20 +2487,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23512487 if (options?.persistSticky === true) {
23522488 nextStickyMap = { ...state.stickyModelSelectionByProvider };
23532489 const stickyBase =
2354 nextStickyMap[normalizedProvider] ??
2355 base.modelSelectionByProvider[normalizedProvider] ??
2356 createModelSelection(normalizedProvider, fallbackModel);
2490 nextStickyMap[instanceKey] ??
2491 base.modelSelectionByProvider[instanceKey] ??
2492 createModelSelection(instanceKey, fallbackModel);
23572493 if (providerOpts) {
2358 nextStickyMap[normalizedProvider] = createModelSelection(
2359 normalizedProvider,
2494 nextStickyMap[instanceKey] = createModelSelection(
2495 instanceKey,
23602496 stickyBase.model,
23612497 providerOpts,
23622498 );
23632499 } else if ((stickyBase.options?.length ?? 0) > 0) {
23642500 const { options: _, ...rest } = stickyBase;
2365 nextStickyMap[normalizedProvider] = rest as ModelSelection;
2501 nextStickyMap[instanceKey] = rest as ModelSelection;
23662502 }
2367 nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
2503 nextStickyActiveProvider = base.activeProvider ?? instanceKey;
23682504 }
23692505
23702506 if (
......@@ -2815,7 +2951,13 @@ export function useEffectiveComposerModelState(input: {
28152951 threadRef?: ComposerThreadTarget;
28162952 draftId?: DraftId;
28172953 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;
28192961 threadModelSelection: ModelSelection | null | undefined;
28202962 projectModelSelection: ModelSelection | null | undefined;
28212963 settings: UnifiedSettings;
......@@ -2828,6 +2970,7 @@ export function useEffectiveComposerModelState(input: {
28282970 draft,
28292971 providers: input.providers,
28302972 selectedProvider: input.selectedProvider,
2973 selectedInstanceId: input.selectedInstanceId,
28312974 threadModelSelection: input.threadModelSelection,
28322975 projectModelSelection: input.projectModelSelection,
28332976 settings: input.settings,
......@@ -2837,6 +2980,7 @@ export function useEffectiveComposerModelState(input: {
28372980 input.providers,
28382981 input.settings,
28392982 input.projectModelSelection,
2983 input.selectedInstanceId,
28402984 input.selectedProvider,
28412985 input.threadModelSelection,
28422986 ],
apps/web/src/environmentGrouping.test.ts+2-2
......@@ -1,4 +1,4 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
22import { scopeProjectRef } from "@t3tools/client-runtime";
33import { describe, expect, it } from "vitest";
44
......@@ -48,7 +48,7 @@ function makeProject(
4848): Project {
4949 return {
5050 cwd: `/tmp/${overrides.name}`,
51 defaultModelSelection: { provider: "codex" as const, model: "gpt-5-codex" },
51 defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
5252 createdAt: "2026-01-01T00:00:00.000Z",
5353 updatedAt: "2026-01-01T00:00:00.000Z",
5454 scripts: [],
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+2-1
......@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
22import {
33 EnvironmentId,
44 ProjectId,
5 ProviderInstanceId,
56 ThreadId,
67 TurnId,
78 type OrchestrationShellSnapshot,
......@@ -98,7 +99,7 @@ function makeThreadShellSnapshot(params: {
9899 projectId,
99100 title: "Thread",
100101 modelSelection: {
101 provider: "codex",
102 instanceId: ProviderInstanceId.make("codex"),
102103 model: "gpt-5-codex",
103104 },
104105 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 @@
11import { 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";
39import type { Thread } from "../types";
410import { getLatestThreadForProject, sortThreads } from "./threadSort";
511
......@@ -13,7 +19,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1319 codexThreadId: null,
1420 projectId: PROJECT_ID,
1521 title: "Thread",
16 modelSelection: { provider: "codex", model: "gpt-5.4" },
22 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
1723 runtimeMode: DEFAULT_RUNTIME_MODE,
1824 interactionMode: "default",
1925 session: null,
apps/web/src/localApi.test.ts+8-3
......@@ -6,6 +6,8 @@ import {
66 type GitStatusResult,
77 ProjectId,
88 type OrchestrationShellStreamItem,
9 ProviderDriverKind,
10 ProviderInstanceId,
911 type ServerConfig,
1012 type ServerProvider,
1113 type TerminalEvent,
......@@ -207,7 +209,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
207209
208210const defaultProviders: ReadonlyArray<ServerProvider> = [
209211 {
210 provider: "codex",
212 instanceId: ProviderInstanceId.make("codex"),
213 driver: ProviderDriverKind.make("codex"),
211214 enabled: true,
212215 installed: true,
213216 version: "0.116.0",
......@@ -328,7 +331,7 @@ describe("wsApi", () => {
328331 title: "Project",
329332 workspaceRoot: "/tmp/workspace",
330333 defaultModelSelection: {
331 provider: "codex",
334 instanceId: ProviderInstanceId.make("codex"),
332335 model: "gpt-5-codex",
333336 },
334337 scripts: [],
......@@ -394,7 +397,7 @@ describe("wsApi", () => {
394397 title: "Project",
395398 workspaceRoot: "/tmp/project",
396399 defaultModelSelection: {
397 provider: "codex",
400 instanceId: ProviderInstanceId.make("codex"),
398401 model: "gpt-5-codex",
399402 },
400403 createdAt: "2026-02-24T00:00:00.000Z",
......@@ -534,6 +537,7 @@ describe("wsApi", () => {
534537 confirmThreadDelete: false,
535538 diffWordWrap: true,
536539 favorites: [],
540 providerModelPreferences: {},
537541 sidebarProjectGroupingMode: "repository_path" as const,
538542 sidebarProjectGroupingOverrides: {
539543 "environment-local:/tmp/project": "separate" as const,
......@@ -593,6 +597,7 @@ describe("wsApi", () => {
593597 confirmThreadDelete: false,
594598 diffWordWrap: true,
595599 favorites: [],
600 providerModelPreferences: {},
596601 sidebarProjectGroupingMode: "repository_path" as const,
597602 sidebarProjectGroupingOverrides: {
598603 "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 @@
11import {
2 DEFAULT_GIT_TEXT_GENERATION_MODEL,
23 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
4 defaultInstanceIdForDriver,
35 type ModelSelection,
4 type ProviderKind,
6 ProviderDriverKind,
7 ProviderInstanceId,
58 type ServerProvider,
69} from "@t3tools/contracts";
710import {
......@@ -17,9 +20,52 @@ import {
1720 resolveSelectableProvider,
1821} from "./providerModels";
1922import { ModelEsque } from "./components/chat/providerIconUtils";
23import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances";
24import { sortModelsForProviderInstance } from "./modelOrdering";
2025
2126const MAX_CUSTOM_MODEL_COUNT = 32;
2227export 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
2470export interface AppModelOption {
2571 slug: string;
......@@ -29,10 +75,47 @@ export interface AppModelOption {
2975 isCustom: boolean;
3076}
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
32115export function normalizeCustomModelSlugs(
33116 models: Iterable<string | null | undefined>,
34117 builtInModelSlugs: ReadonlySet<string>,
35 provider: ProviderKind = "codex",
118 provider: ProviderDriverKind = ProviderDriverKind.make("codex"),
36119): string[] {
37120 const normalizedModels: string[] = [];
38121 const seen = new Set<string>();
......@@ -61,27 +144,23 @@ export function normalizeCustomModelSlugs(
61144export function getAppModelOptions(
62145 settings: UnifiedSettings,
63146 providers: ReadonlyArray<ServerProvider>,
64 provider: ProviderKind,
65 selectedModel?: string | null,
147 provider: ProviderDriverKind,
148 _selectedModel?: string | null,
66149): AppModelOption[] {
67 const options: AppModelOption[] = getProviderModels(providers, provider).map(
68 ({ slug, name, shortName, subProvider, isCustom }) => ({
69 slug,
70 name,
71 ...(shortName ? { shortName } : {}),
72 ...(subProvider ? { subProvider } : {}),
73 isCustom,
74 }),
75 );
150 const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption);
76151 const seen = new Set(options.map((option) => option.slug));
77 const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
78152 const builtInModelSlugs = new Set(
79153 getProviderModels(providers, provider)
80154 .filter((model) => !model.isCustom)
81155 .map((model) => model.slug),
82156 );
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);
85164 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) {
86165 if (seen.has(slug)) {
87166 continue;
......@@ -95,27 +174,52 @@ export function getAppModelOptions(
95174 });
96175 }
97176
98 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
99 const selectedModelMatchesExistingName =
100 typeof trimmedSelectedModel === "string" &&
101 options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
102 if (
103 normalizedSelectedModel &&
104 !seen.has(normalizedSelectedModel) &&
105 !selectedModelMatchesExistingName
106 ) {
107 options.push({
108 slug: normalizedSelectedModel,
109 name: normalizedSelectedModel,
110 isCustom: true,
111 });
177 return applyInstanceModelPreferences(
178 options,
179 readInstanceModelPreferences(settings, defaultInstanceId),
180 );
181}
182
183/**
184 * Instance-scoped variant of {@link getAppModelOptions}. Built-in models
185 * come from the instance's own `entry.models` snapshot (rather than the
186 * first-matching-kind fallback in `getProviderModels`), so each custom
187 * instance gets the precise model list its driver reported. Custom model
188 * slugs come from the instance's own `providerInstances[id].config.customModels`
189 * when present, falling back to the legacy per-kind
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 });
112213 }
113214
114 return options;
215 return applyInstanceModelPreferences(
216 options,
217 readInstanceModelPreferences(settings, entry.instanceId),
218 );
115219}
116220
117221export function resolveAppModelSelection(
118 provider: ProviderKind,
222 provider: ProviderDriverKind,
119223 settings: UnifiedSettings,
120224 providers: ReadonlyArray<ServerProvider>,
121225 selectedModel: string | null | undefined,
......@@ -128,38 +232,41 @@ export function resolveAppModelSelection(
128232 );
129233}
130234
131export function getCustomModelOptionsByProvider(
235export function resolveAppModelSelectionForInstance(
236 instanceId: ProviderInstanceId,
132237 settings: UnifiedSettings,
133238 providers: ReadonlyArray<ServerProvider>,
134 selectedProvider?: ProviderKind | null,
135 selectedModel?: string | null,
136): Record<ProviderKind, ReadonlyArray<ModelEsque>> {
137 return {
138 codex: getAppModelOptions(
139 settings,
140 providers,
141 "codex",
142 selectedProvider === "codex" ? selectedModel : undefined,
143 ),
144 claudeAgent: getAppModelOptions(
145 settings,
146 providers,
147 "claudeAgent",
148 selectedProvider === "claudeAgent" ? selectedModel : undefined,
149 ),
150 cursor: getAppModelOptions(
151 settings,
152 providers,
153 "cursor",
154 selectedProvider === "cursor" ? selectedModel : undefined,
155 ),
156 opencode: getAppModelOptions(
157 settings,
158 providers,
159 "opencode",
160 selectedProvider === "opencode" ? selectedModel : undefined,
161 ),
162 };
239 selectedModel: string | null | undefined,
240): string | null {
241 const entry = deriveProviderInstanceEntries(providers).find(
242 (candidate) => candidate.instanceId === instanceId,
243 );
244 if (!entry) return null;
245 const options = getAppModelOptionsForInstance(settings, entry);
246 return (
247 resolveSelectableModel(entry.driverKind, selectedModel, options) ??
248 options[0]?.slug ??
249 entry.models[0]?.slug ??
250 null
251 );
252}
253
254/**
255 * Instance-keyed model options map. Each configured instance gets its own
256 * option list so the model picker can show the same driver's built-in and
257 * custom instances side by side without collapsing them.
258 */
259export function getCustomModelOptionsByInstance(
260 settings: UnifiedSettings,
261 providers: ReadonlyArray<ServerProvider>,
262 _selectedInstanceId?: ProviderInstanceId | null,
263 _selectedModel?: string | null,
264): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> {
265 const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>();
266 for (const entry of deriveProviderInstanceEntries(providers)) {
267 out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
268 }
269 return out;
163270}
164271
165272export function resolveAppModelSelectionState(
......@@ -167,22 +274,52 @@ export function resolveAppModelSelectionState(
167274 providers: ReadonlyArray<ServerProvider>,
168275): ModelSelection {
169276 const selection = settings.textGenerationModelSelection ?? {
170 provider: "codex" as const,
171 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
277 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
278 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
172279 };
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
175312 // When the provider changed due to fallback (e.g. selected provider was disabled),
176313 // 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;
178315 const model = resolveAppModelSelection(provider, settings, providers, selectedModel);
179316 const { modelOptionsForDispatch } = getComposerProviderState({
180317 provider,
181318 model,
182319 models: getProviderModels(providers, provider),
183320 prompt: "",
184 modelOptions: provider === selection.provider ? selection.options : undefined,
321 modelOptions: keptSelectedProvider ? selection.options : undefined,
185322 });
186323
187 return createModelSelection(provider, model, modelOptionsForDispatch);
324 return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);
188325}
apps/web/src/orchestrationEventEffects.test.ts+3-2
......@@ -3,6 +3,7 @@ import {
33 EventId,
44 MessageId,
55 ProjectId,
6 ProviderInstanceId,
67 ThreadId,
78 TurnId,
89 type OrchestrationEvent,
......@@ -49,7 +50,7 @@ describe("deriveOrchestrationBatchEffects", () => {
4950 threadId: createdThreadId,
5051 projectId: ProjectId.make("project-1"),
5152 title: "Created thread",
52 modelSelection: { provider: "codex", model: "gpt-5-codex" },
53 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
5354 runtimeMode: "full-access",
5455 interactionMode: "default",
5556 branch: null,
......@@ -86,7 +87,7 @@ describe("deriveOrchestrationBatchEffects", () => {
8687 threadId,
8788 projectId: ProjectId.make("project-1"),
8889 title: "Recreated thread",
89 modelSelection: { provider: "codex", model: "gpt-5-codex" },
90 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
9091 runtimeMode: "full-access",
9192 interactionMode: "default",
9293 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 @@
11import {
2 DEFAULT_MODEL,
23 DEFAULT_MODEL_BY_PROVIDER,
4 defaultInstanceIdForDriver,
5 ProviderDriverKind,
36 type ModelCapabilities,
4 type ProviderKind,
7 type ProviderInstanceId,
58 type ServerProvider,
69 type ServerProviderModel,
710} from "@t3tools/contracts";
......@@ -10,8 +13,9 @@ import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/mod
1013const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
1114 optionDescriptors: [],
1215});
16const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
1317
14export function formatProviderKindLabel(provider: ProviderKind): string {
18export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string {
1519 return provider
1620 .replace(/([a-z])([A-Z])/g, "$1 $2")
1721 .replace(/[_-]+/g, " ")
......@@ -21,36 +25,37 @@ export function formatProviderKindLabel(provider: ProviderKind): string {
2125
2226export function getProviderModels(
2327 providers: ReadonlyArray<ServerProvider>,
24 provider: ProviderKind,
28 provider: ProviderDriverKind,
2529): ReadonlyArray<ServerProviderModel> {
26 return providers.find((candidate) => candidate.provider === provider)?.models ?? [];
30 return getProviderSnapshot(providers, provider)?.models ?? [];
2731}
2832
2933export function getProviderSnapshot(
3034 providers: ReadonlyArray<ServerProvider>,
31 provider: ProviderKind,
35 provider: ProviderDriverKind,
3236): ServerProvider | undefined {
33 return providers.find((candidate) => candidate.provider === provider);
37 const defaultInstanceId = defaultInstanceIdForDriver(provider);
38 return providers.find((candidate) => candidate.instanceId === defaultInstanceId);
3439}
3540
3641export function getProviderDisplayName(
3742 providers: ReadonlyArray<ServerProvider>,
38 provider: ProviderKind,
43 provider: ProviderDriverKind,
3944): string {
4045 const snapshot = getProviderSnapshot(providers, provider);
41 return snapshot?.displayName?.trim() || formatProviderKindLabel(provider);
46 return snapshot?.displayName?.trim() || formatProviderDriverKindLabel(provider);
4247}
4348
4449export function getProviderInteractionModeToggle(
4550 providers: ReadonlyArray<ServerProvider>,
46 provider: ProviderKind,
51 provider: ProviderDriverKind,
4752): boolean {
4853 return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true;
4954}
5055
5156export function isProviderEnabled(
5257 providers: ReadonlyArray<ServerProvider>,
53 provider: ProviderKind,
58 provider: ProviderDriverKind,
5459): boolean {
5560 if (providers.length === 0) {
5661 return true;
......@@ -58,21 +63,24 @@ export function isProviderEnabled(
5863 return getProviderSnapshot(providers, provider)?.enabled ?? false;
5964}
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.
6169export function resolveSelectableProvider(
6270 providers: ReadonlyArray<ServerProvider>,
63 provider: ProviderKind | null | undefined,
64): ProviderKind {
65 const requested = provider ?? "codex";
66 if (isProviderEnabled(providers, requested)) {
67 return requested;
71 provider: ProviderDriverKind | ProviderInstanceId | null | undefined,
72): ProviderDriverKind {
73 const requestedEntry = providers.find((candidate) => candidate.instanceId === provider);
74 if (requestedEntry?.enabled) {
75 return requestedEntry.driver;
6876 }
69 return providers.find((candidate) => candidate.enabled)?.provider ?? requested;
77 return providers.find((candidate) => candidate.enabled)?.driver ?? DEFAULT_DRIVER_KIND;
7078}
7179
7280export function getProviderModelCapabilities(
7381 models: ReadonlyArray<ServerProviderModel>,
7482 model: string | null | undefined,
75 provider: ProviderKind,
83 provider: ProviderDriverKind,
7684): ModelCapabilities {
7785 const slug = normalizeModelSlug(model, provider);
7886 return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES;
......@@ -80,12 +88,13 @@ export function getProviderModelCapabilities(
8088
8189export function getDefaultServerModel(
8290 providers: ReadonlyArray<ServerProvider>,
83 provider: ProviderKind,
91 provider: ProviderDriverKind,
8492): string {
8593 const models = getProviderModels(providers, provider);
8694 return (
8795 models.find((model) => !model.isCustom)?.slug ??
8896 models[0]?.slug ??
89 DEFAULT_MODEL_BY_PROVIDER[provider]
97 DEFAULT_MODEL_BY_PROVIDER[provider] ??
98 DEFAULT_MODEL
9099 );
91100}
apps/web/src/rpc/serverState.test.ts+4-1
......@@ -1,6 +1,8 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
33 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
46 ProjectId,
57 ThreadId,
68 type ServerConfig,
......@@ -40,7 +42,8 @@ const configListeners = new Set<(event: ServerConfigStreamEvent) => void>();
4042
4143const defaultProviders: ReadonlyArray<ServerProvider> = [
4244 {
43 provider: "codex",
45 instanceId: ProviderInstanceId.make("codex"),
46 driver: ProviderDriverKind.make("codex"),
4447 enabled: true,
4548 installed: true,
4649 version: "0.116.0",
apps/web/src/rpc/wsRpcClient.ts+9-3
......@@ -102,7 +102,13 @@ export interface WsRpcClient {
102102 };
103103 readonly server: {
104104 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>>;
106112 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
107113 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
108114 readonly updateSettings: (
......@@ -206,8 +212,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
206212 },
207213 server: {
208214 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
209 refreshProviders: () =>
210 transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})),
215 refreshProviders: (input) =>
216 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
211217 upsertKeybinding: (input) =>
212218 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
213219 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
apps/web/src/session-logic.ts+16-6
......@@ -6,7 +6,7 @@ import {
66 type OrchestrationLatestTurn,
77 type OrchestrationThreadActivity,
88 type OrchestrationProposedPlanId,
9 type ProviderKind,
9 ProviderDriverKind,
1010 type ToolLifecycleItemType,
1111 type UserInputQuestion,
1212 type ThreadId,
......@@ -22,7 +22,7 @@ import type {
2222 TurnDiffSummary,
2323} from "./types";
2424
25export type ProviderPickerKind = ProviderKind;
25export type ProviderPickerKind = ProviderDriverKind;
2626
2727export const PROVIDER_OPTIONS: Array<{
2828 value: ProviderPickerKind;
......@@ -31,10 +31,20 @@ export const PROVIDER_OPTIONS: Array<{
3131 /** Shown on the model picker sidebar when relevant */
3232 pickerSidebarBadge?: "new" | "soon";
3333}> = [
34 { value: "codex", label: "Codex", available: true },
35 { value: "claudeAgent", label: "Claude", available: true },
36 { value: "opencode", label: "OpenCode", available: true, pickerSidebarBadge: "new" },
37 { value: "cursor", label: "Cursor", available: true, pickerSidebarBadge: "new" },
34 { value: ProviderDriverKind.make("codex"), label: "Codex", available: true },
35 { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true },
36 {
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 },
3848];
3949
4050export interface WorkLogEntry {
apps/web/src/store.test.ts+14-13
......@@ -1,11 +1,12 @@
11import { scopeThreadRef } from "@t3tools/client-runtime";
22import {
33 CheckpointRef,
4 DEFAULT_MODEL_BY_PROVIDER,
4 DEFAULT_MODEL,
55 EnvironmentId,
66 EventId,
77 MessageId,
88 ProjectId,
9 ProviderInstanceId,
910 ThreadId,
1011 TurnId,
1112 type OrchestrationEvent,
......@@ -65,7 +66,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
6566 projectId: ProjectId.make("project-1"),
6667 title: "Thread",
6768 modelSelection: {
68 provider: "codex",
69 instanceId: ProviderInstanceId.make("codex"),
6970 model: "gpt-5-codex",
7071 },
7172 runtimeMode: DEFAULT_RUNTIME_MODE,
......@@ -93,7 +94,7 @@ function makeState(thread: Thread): AppState {
9394 name: "Project",
9495 cwd: "/tmp/project",
9596 defaultModelSelection: {
96 provider: "codex" as const,
97 instanceId: ProviderInstanceId.make("codex"),
9798 model: "gpt-5-codex",
9899 },
99100 createdAt: "2026-02-13T00:00:00.000Z",
......@@ -463,8 +464,8 @@ describe("incremental orchestration updates", () => {
463464 name: "Project",
464465 cwd: "/tmp/project",
465466 defaultModelSelection: {
466 provider: "codex",
467 model: DEFAULT_MODEL_BY_PROVIDER.codex,
467 instanceId: ProviderInstanceId.make("codex"),
468 model: DEFAULT_MODEL,
468469 },
469470 createdAt: "2026-02-27T00:00:00.000Z",
470471 updatedAt: "2026-02-27T00:00:00.000Z",
......@@ -480,8 +481,8 @@ describe("incremental orchestration updates", () => {
480481 title: "Project Recreated",
481482 workspaceRoot: "/tmp/project",
482483 defaultModelSelection: {
483 provider: "codex",
484 model: DEFAULT_MODEL_BY_PROVIDER.codex,
484 instanceId: ProviderInstanceId.make("codex"),
485 model: DEFAULT_MODEL,
485486 },
486487 scripts: [],
487488 createdAt: "2026-02-27T00:00:01.000Z",
......@@ -518,8 +519,8 @@ describe("incremental orchestration updates", () => {
518519 name: "Project 1",
519520 cwd: "/tmp/project-1",
520521 defaultModelSelection: {
521 provider: "codex",
522 model: DEFAULT_MODEL_BY_PROVIDER.codex,
522 instanceId: ProviderInstanceId.make("codex"),
523 model: DEFAULT_MODEL,
523524 },
524525 createdAt: "2026-02-27T00:00:00.000Z",
525526 updatedAt: "2026-02-27T00:00:00.000Z",
......@@ -531,8 +532,8 @@ describe("incremental orchestration updates", () => {
531532 name: "Project 2",
532533 cwd: "/tmp/project-2",
533534 defaultModelSelection: {
534 provider: "codex",
535 model: DEFAULT_MODEL_BY_PROVIDER.codex,
535 instanceId: ProviderInstanceId.make("codex"),
536 model: DEFAULT_MODEL,
536537 },
537538 createdAt: "2026-02-27T00:00:00.000Z",
538539 updatedAt: "2026-02-27T00:00:00.000Z",
......@@ -548,8 +549,8 @@ describe("incremental orchestration updates", () => {
548549 projectId: recreatedProjectId,
549550 title: "Recovered thread",
550551 modelSelection: {
551 provider: "codex",
552 model: DEFAULT_MODEL_BY_PROVIDER.codex,
552 instanceId: ProviderInstanceId.make("codex"),
553 model: DEFAULT_MODEL,
553554 },
554555 runtimeMode: DEFAULT_RUNTIME_MODE,
555556 interactionMode: DEFAULT_INTERACTION_MODE,
apps/web/src/store.ts+13-8
......@@ -18,7 +18,7 @@ import type {
1818 ScopedProjectRef,
1919 ScopedThreadRef,
2020} from "@t3tools/contracts";
21import { ProviderKind } from "@t3tools/contracts";
21import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts";
2222import type { ThreadId, TurnId } from "@t3tools/contracts";
2323import { Schema } from "effect";
2424import { resolveModelSlugForProvider } from "@t3tools/shared/model";
......@@ -129,12 +129,16 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
129129 return left.length === right.length && left.every((value, index) => value === right[index]);
130130}
131131
132function normalizeModelSelection<T extends { provider: ProviderKind; model: string }>(
133 selection: T,
134): T {
132// Accepts the open `instanceId` string carried on `ModelSelection`; malformed
133// values pass through unchanged, while valid slugs use any registered alias
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 }
135139 return {
136140 ...selection,
137 model: resolveModelSlugForProvider(selection.provider, selection.model),
141 model: resolveModelSlugForProvider(selection.instanceId, selection.model),
138142 };
139143}
140144
......@@ -145,6 +149,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>):
145149function mapSession(session: OrchestrationSession): ThreadSession {
146150 return {
147151 provider: toLegacyProvider(session.providerName),
152 providerInstanceId: session.providerInstanceId ?? undefined,
148153 status: toLegacySessionStatus(session.status),
149154 orchestrationStatus: session.status,
150155 activeTurnId: session.activeTurnId ?? undefined,
......@@ -1000,11 +1005,11 @@ function toLegacySessionStatus(
10001005 }
10011006}
10021007
1003function toLegacyProvider(providerName: string | null): ProviderKind {
1004 if (Schema.is(ProviderKind)(providerName)) {
1008function toLegacyProvider(providerName: string | null): ProviderDriverKind {
1009 if (Schema.is(ProviderDriverKind)(providerName)) {
10051010 return providerName;
10061011 }
1007 return "codex";
1012 return ProviderDriverKind.make("codex");
10081013}
10091014
10101015function attachmentPreviewRoutePath(attachmentId: string): string {
apps/web/src/types.ts+4-2
......@@ -11,7 +11,8 @@ import type {
1111 ProjectId,
1212 TurnId,
1313 MessageId,
14 ProviderKind,
14 ProviderDriverKind,
15 ProviderInstanceId,
1516 CheckpointRef,
1617 ProviderInteractionMode,
1718 RuntimeMode,
......@@ -158,7 +159,8 @@ export interface SidebarThreadSummary {
158159}
159160
160161export interface ThreadSession {
161 provider: ProviderKind;
162 provider: ProviderDriverKind;
163 providerInstanceId?: ProviderInstanceId | undefined;
162164 status: SessionPhase | "error" | "closed";
163165 activeTurnId?: TurnId | undefined;
164166 createdAt: string;
apps/web/src/worktreeCleanup.test.ts+2-2
......@@ -1,4 +1,4 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
......@@ -14,7 +14,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1414 projectId: ProjectId.make("project-1"),
1515 title: "Thread",
1616 modelSelection: {
17 provider: "codex",
17 instanceId: ProviderInstanceId.make("codex"),
1818 model: "gpt-5.3-codex",
1919 },
2020 runtimeMode: DEFAULT_RUNTIME_MODE,
apps/web/vite.config.ts+7-1
......@@ -56,7 +56,13 @@ export default defineConfig({
5656 tailwindcss(),
5757 ],
5858 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 ],
6066 },
6167 define: {
6268 "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";
44export * from "./ipc.ts";
55export * from "./terminal.ts";
66export * from "./provider.ts";
7export * from "./providerInstance.ts";
78export * from "./providerRuntime.ts";
89export * from "./model.ts";
910export * from "./keybindings.ts";
packages/contracts/src/ipc.ts+9-1
......@@ -25,6 +25,7 @@ import type {
2525 ProjectWriteFileInput,
2626 ProjectWriteFileResult,
2727} from "./project.ts";
28import type { ProviderInstanceId } from "./providerInstance.ts";
2829import type {
2930 ServerConfig,
3031 ServerProviderUpdatedPayload,
......@@ -215,7 +216,14 @@ export interface LocalApi {
215216 };
216217 server: {
217218 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>;
219227 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
220228 getSettings: () => Promise<ServerSettings>;
221229 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
packages/contracts/src/model.ts+33-23
......@@ -1,6 +1,6 @@
11import { Effect, Schema, SchemaTransformation } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import type { ProviderKind } from "./orchestration.ts";
3import { ProviderDriverKind } from "./providerInstance.ts";
44
55export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]);
66export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type;
......@@ -125,25 +125,35 @@ export const ModelCapabilities = Schema.Struct({
125125});
126126export type ModelCapabilities = typeof ModelCapabilities.Type;
127127
128export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
129 codex: "gpt-5.4",
130 claudeAgent: "claude-sonnet-4-6",
131 cursor: "auto",
132 opencode: "openai/gpt-5",
133};
128const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
129const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
130const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
131const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
132
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
137143/** Per-provider text generation model defaults. */
138export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
139 codex: "gpt-5.4-mini",
140 claudeAgent: "claude-haiku-4-5",
141 cursor: "composer-2",
142 opencode: "openai/gpt-5",
144export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
145 Record<ProviderDriverKind, string>
146> = {
147 [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL,
148 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",
149 [CURSOR_DRIVER_KIND]: "composer-2",
150 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
143151};
144152
145export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = {
146 codex: {
153export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
154 Record<ProviderDriverKind, Record<string, string>>
155> = {
156 [CODEX_DRIVER_KIND]: {
147157 "gpt-5-codex": "gpt-5.4",
148158 "5.4": "gpt-5.4",
149159 "5.3": "gpt-5.3-codex",
......@@ -151,7 +161,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
151161 "5.3-spark": "gpt-5.3-codex-spark",
152162 "gpt-5.3-spark": "gpt-5.3-codex-spark",
153163 },
154 claudeAgent: {
164 [CLAUDE_DRIVER_KIND]: {
155165 opus: "claude-opus-4-7",
156166 "opus-4.7": "claude-opus-4-7",
157167 "claude-opus-4.7": "claude-opus-4-7",
......@@ -167,7 +177,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
167177 "claude-haiku-4.5": "claude-haiku-4-5",
168178 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
169179 },
170 cursor: {
180 [CURSOR_DRIVER_KIND]: {
171181 composer: "composer-2",
172182 "composer-1.5": "composer-1.5",
173183 "composer-1": "composer-1.5",
......@@ -178,14 +188,14 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
178188 "opus-4.5-thinking": "claude-opus-4-5",
179189 "opus-4.5": "claude-opus-4-5",
180190 },
181 opencode: {},
191 [OPENCODE_DRIVER_KIND]: {},
182192};
183193
184194// ── Provider display names ────────────────────────────────────────────
185195
186export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {
187 codex: "Codex",
188 claudeAgent: "Claude",
189 cursor: "Cursor",
190 opencode: "OpenCode",
196export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> = {
197 [CODEX_DRIVER_KIND]: "Codex",
198 [CLAUDE_DRIVER_KIND]: "Claude",
199 [CURSOR_DRIVER_KIND]: "Cursor",
200 [OPENCODE_DRIVER_KIND]: "OpenCode",
191201};
packages/contracts/src/orchestration.test.ts+103-8
......@@ -5,6 +5,7 @@ import { Effect, Schema } from "effect";
55import {
66 DEFAULT_PROVIDER_INTERACTION_MODE,
77 DEFAULT_RUNTIME_MODE,
8 ModelSelection,
89 OrchestrationCommand,
910 OrchestrationEvent,
1011 OrchestrationGetTurnDiffInput,
......@@ -20,6 +21,7 @@ import {
2021 ThreadTurnDiff,
2122 ThreadTurnStartRequestedPayload,
2223} from "./orchestration.ts";
24import { ProviderInstanceId } from "./providerInstance.ts";
2325
2426const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
2527const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
......@@ -104,7 +106,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
104106 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");
105107 assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined);
106108 assert.deepStrictEqual(parsed.defaultModelSelection, {
107 provider: "codex",
109 instanceId: ProviderInstanceId.make("codex"),
108110 model: "gpt-5.2",
109111 });
110112 }),
......@@ -140,7 +142,7 @@ it.effect("decodes historical project.created payloads with a default provider",
140142 createdAt: "2026-01-01T00:00:00.000Z",
141143 updatedAt: "2026-01-01T00:00:00.000Z",
142144 });
143 assert.strictEqual(parsed.defaultModelSelection?.provider, "codex");
145 assert.strictEqual(parsed.defaultModelSelection?.instanceId, "codex");
144146 }),
145147);
146148
......@@ -154,7 +156,7 @@ it.effect("decodes project.meta-updated payloads with explicit default provider"
154156 },
155157 updatedAt: "2026-01-01T00:00:00.000Z",
156158 });
157 assert.strictEqual(parsed.defaultModelSelection?.provider, "claudeAgent");
159 assert.strictEqual(parsed.defaultModelSelection?.instanceId, "claudeAgent");
158160 }),
159161);
160162
......@@ -213,7 +215,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
213215 runtimeMode: "full-access",
214216 createdAt: "2026-01-01T00:00:00.000Z",
215217 });
216 assert.strictEqual(parsed.modelSelection?.provider, "codex");
218 assert.strictEqual(parsed.modelSelection?.instanceId, "codex");
217219 assert.strictEqual(parsed.runtimeMode, "full-access");
218220 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
219221 }),
......@@ -278,7 +280,7 @@ it.effect("decodes thread.created runtime mode for historical events", () =>
278280 });
279281
280282 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
281 assert.strictEqual(parsed.modelSelection.provider, "codex");
283 assert.strictEqual(parsed.modelSelection.instanceId, "codex");
282284 }),
283285);
284286
......@@ -292,7 +294,7 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
292294 },
293295 updatedAt: "2026-01-01T00:00:00.000Z",
294296 });
295 assert.strictEqual(parsed.modelSelection?.provider, "claudeAgent");
297 assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent");
296298 }),
297299);
298300
......@@ -378,7 +380,7 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
378380 },
379381 createdAt: "2026-01-01T00:00:00.000Z",
380382 });
381 assert.strictEqual(parsed.modelSelection?.provider, "codex");
383 assert.strictEqual(parsed.modelSelection?.instanceId, "codex");
382384 assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high");
383385 assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true);
384386 }),
......@@ -408,7 +410,7 @@ it.effect("normalizes legacy object-shaped modelSelection.options on decode", ()
408410 updatedAt: "2026-01-01T00:00:00.000Z",
409411 });
410412
411 assert.strictEqual(parsed.modelSelection.provider, "claudeAgent");
413 assert.strictEqual(parsed.modelSelection.instanceId, ProviderInstanceId.make("claudeAgent"));
412414 assert.deepStrictEqual(parsed.modelSelection.options, [
413415 { id: "effort", value: "max" },
414416 { id: "fastMode", value: true },
......@@ -618,3 +620,96 @@ it.effect("preserves proposed plan implementation metadata when present", () =>
618620 assert.strictEqual(parsed.implementationThreadId, "thread-2");
619621 }),
620622);
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 @@
1import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
1import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect";
22import { ProviderOptionSelections } from "./model.ts";
33import { RepositoryIdentity } from "./environment.ts";
44import {
......@@ -15,6 +15,7 @@ import {
1515 TrimmedNonEmptyString,
1616 TurnId,
1717} from "./baseSchemas.ts";
18import { ProviderInstanceId } from "./providerInstance.ts";
1819
1920export const ORCHESTRATION_WS_METHODS = {
2021 dispatchCommand: "orchestration.dispatchCommand",
......@@ -25,8 +26,6 @@ export const ORCHESTRATION_WS_METHODS = {
2526 subscribeThread: "orchestration.subscribeThread",
2627} as const;
2728
28export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]);
29export type ProviderKind = typeof ProviderKind.Type;
3029export const ProviderApprovalPolicy = Schema.Literals([
3130 "untrusted",
3231 "on-failure",
......@@ -41,41 +40,72 @@ export const ProviderSandboxMode = Schema.Literals([
4140]);
4241export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
4342
44export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
45
46export const CodexModelSelection = Schema.Struct({
47 provider: Schema.Literal("codex"),
48 model: TrimmedNonEmptyString,
49 options: Schema.optionalKey(ProviderOptionSelections),
50});
51export type CodexModelSelection = typeof CodexModelSelection.Type;
52
53export const ClaudeModelSelection = Schema.Struct({
54 provider: Schema.Literal("claudeAgent"),
43/**
44 * `ModelSelection` — selection of a model on a configured provider instance.
45 *
46 * The routing key is `instanceId` (a user-defined slug identifying one
47 * configured provider instance). Drivers, credentials, working-directory
48 * bindings, and any other per-instance state are recovered from the
49 * runtime registry via the instance id.
50 *
51 * Wire legacy: persisted selections produced before the driver/instance
52 * split carried a `provider: <driver-id>` field instead. The schema absorbs
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,
5560 model: TrimmedNonEmptyString,
5661 options: Schema.optionalKey(ProviderOptionSelections),
5762});
58export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5963
60export const CursorModelSelection = Schema.Struct({
61 provider: Schema.Literal("cursor"),
62 model: TrimmedNonEmptyString,
63 options: Schema.optionalKey(ProviderOptionSelections),
64});
65export type CursorModelSelection = typeof CursorModelSelection.Type;
66export const OpenCodeModelSelection = Schema.Struct({
67 provider: Schema.Literal("opencode"),
68 model: TrimmedNonEmptyString,
69 options: Schema.optionalKey(ProviderOptionSelections),
70});
71export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type;
72
73export const ModelSelection = Schema.Union([
74 CodexModelSelection,
75 ClaudeModelSelection,
76 CursorModelSelection,
77 OpenCodeModelSelection,
78]);
64// Source shape for persisted legacy payloads. Fields are typed as
65// `Schema.Unknown` so malformed drafts still make it into the transform and
66// fail validation through the target schema (with proper error messages)
67// rather than at the source-struct layer where the error is less actionable.
68const ModelSelectionSource = Schema.Struct({
69 provider: Schema.optional(Schema.Unknown),
70 instanceId: Schema.optional(Schema.Unknown),
71 model: Schema.Unknown,
72 options: Schema.optional(Schema.Unknown),
73});
74
75export const ModelSelection = ModelSelectionSource.pipe(
76 Schema.decodeTo(
77 ModelSelectionWire,
78 SchemaTransformation.transformOrFail({
79 decode: (raw) => {
80 // Resolve the routing key: prefer an explicit `instanceId`; fall
81 // back to promoting the legacy `provider` slug (the canonical
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);
79109export type ModelSelection = typeof ModelSelection.Type;
80110
81111export const RuntimeMode = Schema.Literals([
......@@ -225,6 +255,7 @@ export const OrchestrationSession = Schema.Struct({
225255 threadId: ThreadId,
226256 status: OrchestrationSessionStatus,
227257 providerName: Schema.NullOr(TrimmedNonEmptyString),
258 providerInstanceId: Schema.optional(ProviderInstanceId),
228259 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))),
229260 activeTurnId: Schema.NullOr(TurnId),
230261 lastError: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/provider.test.ts+115-30
......@@ -1,10 +1,17 @@
11import { describe, expect, it } from "vitest";
22import { Schema } from "effect";
33
4import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts";
4import {
5 ProviderEvent,
6 ProviderSendTurnInput,
7 ProviderSession,
8 ProviderSessionStartInput,
9} from "./provider.ts";
510
611const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
712const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
13const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession);
14const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent);
815
916function getOptionValue(
1017 options: ReadonlyArray<{ id: string; value: unknown }> | undefined,
......@@ -30,13 +37,10 @@ describe("ProviderSessionStartInput", () => {
3037 runtimeMode: "full-access",
3138 });
3239 expect(parsed.runtimeMode).toBe("full-access");
33 expect(parsed.modelSelection?.provider).toBe("codex");
40 expect(parsed.modelSelection?.instanceId).toBe("codex");
3441 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
35 if (parsed.modelSelection?.provider !== "codex") {
36 throw new Error("Expected codex modelSelection");
37 }
38 expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("high");
39 expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true);
42 expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("high");
43 expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true);
4044 });
4145
4246 it("rejects payloads without runtime mode", () => {
......@@ -65,14 +69,11 @@ describe("ProviderSessionStartInput", () => {
6569 runtimeMode: "full-access",
6670 });
6771 expect(parsed.provider).toBe("claudeAgent");
68 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
72 expect(parsed.modelSelection?.instanceId).toBe("claudeAgent");
6973 expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6");
70 if (parsed.modelSelection?.provider !== "claudeAgent") {
71 throw new Error("Expected claude modelSelection");
72 }
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);
74 expect(getOptionValue(parsed.modelSelection?.options, "thinking")).toBe(true);
75 expect(getOptionValue(parsed.modelSelection?.options, "effort")).toBe("max");
76 expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true);
7677 expect(parsed.runtimeMode).toBe("full-access");
7778 });
7879
......@@ -89,11 +90,27 @@ describe("ProviderSessionStartInput", () => {
8990 },
9091 });
9192 expect(parsed.provider).toBe("cursor");
92 expect(parsed.modelSelection?.provider).toBe("cursor");
93 expect(parsed.modelSelection?.instanceId).toBe("cursor");
9394 expect(parsed.modelSelection?.model).toBe("composer-2");
94 if (parsed.modelSelection?.provider === "cursor") {
95 expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true);
96 }
95 expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true);
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");
97114 });
98115});
99116
......@@ -111,13 +128,10 @@ describe("ProviderSendTurnInput", () => {
111128 },
112129 });
113130
114 expect(parsed.modelSelection?.provider).toBe("codex");
131 expect(parsed.modelSelection?.instanceId).toBe("codex");
115132 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
116 if (parsed.modelSelection?.provider !== "codex") {
117 throw new Error("Expected codex modelSelection");
118 }
119 expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("xhigh");
120 expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true);
133 expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("xhigh");
134 expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true);
121135 });
122136
123137 it("accepts claude modelSelection including ultrathink", () => {
......@@ -133,11 +147,82 @@ describe("ProviderSendTurnInput", () => {
133147 },
134148 });
135149
136 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
137 if (parsed.modelSelection?.provider !== "claudeAgent") {
138 throw new Error("Expected claude modelSelection");
139 }
140 expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("ultrathink");
141 expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true);
150 expect(parsed.modelSelection?.instanceId).toBe("claudeAgent");
151 expect(getOptionValue(parsed.modelSelection?.options, "effort")).toBe("ultrathink");
152 expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true);
153 });
154});
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();
142227 });
143228});
packages/contracts/src/provider.ts+12-4
......@@ -16,12 +16,12 @@ import {
1616 ProviderApprovalDecision,
1717 ProviderApprovalPolicy,
1818 ProviderInteractionMode,
19 ProviderKind,
2019 ProviderRequestKind,
2120 ProviderSandboxMode,
2221 ProviderUserInputAnswers,
2322 RuntimeMode,
2423} from "./orchestration.ts";
24import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
2525
2626const ProviderSessionStatus = Schema.Literals([
2727 "connecting",
......@@ -32,7 +32,11 @@ const ProviderSessionStatus = Schema.Literals([
3232]);
3333
3434export 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),
3640 status: ProviderSessionStatus,
3741 runtimeMode: RuntimeMode,
3842 cwd: Schema.optional(TrimmedNonEmptyString),
......@@ -48,7 +52,9 @@ export type ProviderSession = typeof ProviderSession.Type;
4852
4953export const ProviderSessionStartInput = Schema.Struct({
5054 threadId: ThreadId,
51 provider: Schema.optional(ProviderKind),
55 provider: Schema.optional(ProviderDriverKind),
56 // See ProviderSession for the migration story.
57 providerInstanceId: Schema.optional(ProviderInstanceId),
5258 cwd: Schema.optional(TrimmedNonEmptyString),
5359 modelSelection: Schema.optional(ModelSelection),
5460 resumeCursor: Schema.optional(Schema.Unknown),
......@@ -108,7 +114,9 @@ const ProviderEventKind = Schema.Literals(["session", "notification", "request",
108114export const ProviderEvent = Schema.Struct({
109115 id: EventId,
110116 kind: ProviderEventKind,
111 provider: ProviderKind,
117 provider: ProviderDriverKind,
118 // See ProviderSession for the migration story.
119 providerInstanceId: Schema.optional(ProviderInstanceId),
112120 threadId: ThreadId,
113121 createdAt: IsoDateTime,
114122 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";
66const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
77
88describe("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
926 it("decodes turn.plan.updated for plan rendering", () => {
1027 const parsed = decodeRuntimeEvent({
1128 type: "turn.plan.updated",
packages/contracts/src/providerRuntime.ts+6-2
......@@ -12,7 +12,7 @@ import {
1212 TrimmedNonEmptyString,
1313 TurnId,
1414} from "./baseSchemas.ts";
15import { ProviderKind } from "./orchestration.ts";
15import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
1616
1717const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
1818const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
......@@ -245,7 +245,11 @@ const RuntimeErrorType = Schema.Literal("runtime.error");
245245
246246const ProviderRuntimeEventBase = Schema.Struct({
247247 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),
249253 threadId: ThreadId,
250254 createdAt: IsoDateTime,
251255 turnId: Schema.optional(TurnId),
packages/contracts/src/rpc.ts+10-1
......@@ -48,6 +48,7 @@ import {
4848 OrchestrationReplayEventsInput,
4949 OrchestrationRpcSchemas,
5050} from "./orchestration.ts";
51import { ProviderInstanceId } from "./providerInstance.ts";
5152import {
5253 ProjectSearchEntriesError,
5354 ProjectSearchEntriesInput,
......@@ -140,7 +141,15 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
140141});
141142
142143export 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 }),
144153 success: ServerProviderUpdatedPayload,
145154});
146155
packages/contracts/src/server.test.ts+22-2
......@@ -6,9 +6,10 @@ import { ServerProvider } from "./server.ts";
66const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
77
88describe("ServerProvider", () => {
9 it("defaults capability arrays when decoding legacy snapshots", () => {
9 it("defaults capability arrays when decoding provider snapshots", () => {
1010 const parsed = decodeServerProvider({
11 provider: "codex",
11 instanceId: "codex",
12 driver: "codex",
1213 enabled: true,
1314 installed: true,
1415 version: "1.0.0",
......@@ -23,4 +24,23 @@ describe("ServerProvider", () => {
2324 expect(parsed.slashCommands).toEqual([]);
2425 expect(parsed.skills).toEqual([]);
2526 });
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 });
2646});
packages/contracts/src/server.ts+53-2
......@@ -11,7 +11,7 @@ import {
1111import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts";
1212import { EditorId } from "./editor.ts";
1313import { ModelCapabilities } from "./model.ts";
14import { ProviderKind } from "./orchestration.ts";
14import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
1515import { ServerSettings } from "./settings.ts";
1616
1717const KeybindingsMalformedConfigIssue = Schema.Struct({
......@@ -47,6 +47,7 @@ export const ServerProviderAuth = Schema.Struct({
4747 status: ServerProviderAuthStatus,
4848 type: Schema.optional(TrimmedNonEmptyString),
4949 label: Schema.optional(TrimmedNonEmptyString),
50 email: Schema.optional(TrimmedNonEmptyString),
5051});
5152export type ServerProviderAuth = typeof ServerProviderAuth.Type;
5253
......@@ -83,10 +84,42 @@ export const ServerProviderSkill = Schema.Struct({
8384});
8485export 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
86112export 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,
88119 displayName: Schema.optional(TrimmedNonEmptyString),
120 accentColor: Schema.optional(TrimmedNonEmptyString),
89121 badgeLabel: Schema.optional(TrimmedNonEmptyString),
122 continuation: Schema.optional(ServerProviderContinuation),
90123 showInteractionModeToggle: Schema.optional(Schema.Boolean),
91124 enabled: Schema.Boolean,
92125 installed: Schema.Boolean,
......@@ -95,6 +128,15 @@ export const ServerProvider = Schema.Struct({
95128 auth: ServerProviderAuth,
96129 checkedAt: IsoDateTime,
97130 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),
98140 models: Schema.Array(ServerProviderModel),
99141 slashCommands: Schema.Array(ServerProviderSlashCommand).pipe(
100142 Schema.withDecodingDefault(Effect.succeed([])),
......@@ -106,6 +148,15 @@ export type ServerProvider = typeof ServerProvider.Type;
106148export const ServerProviders = Schema.Array(ServerProvider);
107149export 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
109160export const ServerObservability = Schema.Struct({
110161 logsDirectoryPath: TrimmedNonEmptyString,
111162 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";
22import * as Schema from "effect/Schema";
33import * as SchemaTransformation from "effect/SchemaTransformation";
44import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
5import {
6 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
7 ProviderOptionSelections,
8} from "./model.ts";
9import { ModelSelection, ProviderKind } from "./orchestration.ts";
5import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";
6import { ModelSelection } from "./orchestration.ts";
7import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
108
119// ── Client Settings (local-only) ───────────────────────────────
1210
......@@ -35,12 +33,31 @@ export const ClientSettingsSchema = Schema.Struct({
3533 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3634 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3735 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.
3846 favorites: Schema.Array(
3947 Schema.Struct({
40 provider: ProviderKind,
48 provider: ProviderInstanceId,
4149 model: TrimmedNonEmptyString,
4250 }),
4351 ).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({}))),
4461 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
4562 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
4663 ),
......@@ -83,6 +100,7 @@ export const CodexSettings = Schema.Struct({
83100 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
84101 binaryPath: makeBinaryPathSetting("codex"),
85102 homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
103 shadowHomePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
86104 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
87105});
88106export type CodexSettings = typeof CodexSettings.Type;
......@@ -90,6 +108,7 @@ export type CodexSettings = typeof CodexSettings.Type;
90108export const ClaudeSettings = Schema.Struct({
91109 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
92110 binaryPath: makeBinaryPathSetting("claude"),
111 homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
93112 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
94113 launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
95114});
......@@ -126,19 +145,32 @@ export const ServerSettings = Schema.Struct({
126145 textGenerationModelSelection: ModelSelection.pipe(
127146 Schema.withDecodingDefault(
128147 Effect.succeed({
129 provider: "codex" as const,
130 model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
148 instanceId: ProviderInstanceId.make("codex"),
149 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
131150 }),
132151 ),
133152 ),
134153
135 // Provider specific settings
154 // Legacy single-instance-per-driver settings. Continues to be the source
155 // of truth until `providerInstances` (below) lands per-driver migration
156 // shims and the server starts hydrating instances from it. Driver-specific
157 // schemas live here for the duration of the migration; once each driver
158 // owns its config in its own package, this struct shrinks to nothing and
159 // is removed entirely.
136160 providers: Schema.Struct({
137161 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
138162 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
139163 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
140164 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
141165 }).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 ),
142174 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
143175});
144176export type ServerSettings = typeof ServerSettings.Type;
......@@ -168,39 +200,24 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = {
168200
169201// ── Server Settings Patch (replace with a Schema.deepPartial if available) ──────────────────────────────────────────
170202
171const ModelSelectionPatch = Schema.Union([
172 Schema.Struct({
173 provider: Schema.optionalKey(Schema.Literal("codex")),
174 model: Schema.optionalKey(TrimmedNonEmptyString),
175 options: Schema.optionalKey(ProviderOptionSelections),
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]);
203const ModelSelectionPatch = Schema.Struct({
204 instanceId: Schema.optionalKey(ProviderInstanceId),
205 model: Schema.optionalKey(TrimmedNonEmptyString),
206 options: Schema.optionalKey(ProviderOptionSelections),
207});
193208
194209const CodexSettingsPatch = Schema.Struct({
195210 enabled: Schema.optionalKey(Schema.Boolean),
196211 binaryPath: Schema.optionalKey(Schema.String),
197212 homePath: Schema.optionalKey(Schema.String),
213 shadowHomePath: Schema.optionalKey(Schema.String),
198214 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
199215});
200216
201217const ClaudeSettingsPatch = Schema.Struct({
202218 enabled: Schema.optionalKey(Schema.Boolean),
203219 binaryPath: Schema.optionalKey(Schema.String),
220 homePath: Schema.optionalKey(Schema.String),
204221 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
205222 launchArgs: Schema.optionalKey(Schema.String),
206223});
......@@ -240,6 +257,11 @@ export const ServerSettingsPatch = Schema.Struct({
240257 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
241258 }),
242259 ),
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)),
243265});
244266export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
245267
......@@ -251,11 +273,24 @@ export const ClientSettingsPatch = Schema.Struct({
251273 favorites: Schema.optionalKey(
252274 Schema.Array(
253275 Schema.Struct({
254 provider: ProviderKind,
276 provider: ProviderInstanceId,
255277 model: TrimmedNonEmptyString,
256278 }),
257279 ),
258280 ),
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 ),
259294 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
260295 sidebarProjectGroupingOverrides: Schema.optionalKey(
261296 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),
packages/shared/src/model.test.ts+28-14
......@@ -1,5 +1,10 @@
11import { 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
49import {
510 applyClaudePromptEffortPrefix,
......@@ -67,9 +72,10 @@ const claudeCaps: ModelCapabilities = createModelCapabilities({
6772
6873describe("normalizeModelSlug", () => {
6974 it("maps known aliases to canonical slugs", () => {
75 const claude = ProviderDriverKind.make("claudeAgent");
7076 expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4");
7177 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");
7379 });
7480
7581 it("returns null for empty or missing values", () => {
......@@ -82,16 +88,18 @@ describe("normalizeModelSlug", () => {
8288
8389describe("resolveModelSlugForProvider", () => {
8490 it("returns defaults when the model is missing", () => {
85 expect(resolveModelSlugForProvider("codex", undefined)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
86 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(
87 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
91 expect(resolveModelSlugForProvider(ProviderDriverKind.make("codex"), undefined)).toBe(
92 DEFAULT_MODEL,
93 );
94 expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe(
95 DEFAULT_MODEL,
8896 );
8997 });
9098
9199 it("preserves normalized unknown models", () => {
92 expect(resolveModelSlugForProvider("codex", "custom/internal-model")).toBe(
93 "custom/internal-model",
94 );
100 expect(
101 resolveModelSlugForProvider(ProviderDriverKind.make("codex"), "custom/internal-model"),
102 ).toBe("custom/internal-model");
95103 });
96104});
97105
......@@ -101,9 +109,15 @@ describe("resolveSelectableModel", () => {
101109 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
102110 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
103111 ];
104 expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex");
105 expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex");
106 expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6");
112 expect(resolveSelectableModel(ProviderDriverKind.make("codex"), "gpt-5.3-codex", options)).toBe(
113 "gpt-5.3-codex",
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 );
107121 });
108122});
109123
......@@ -182,12 +196,12 @@ describe("descriptor helpers", () => {
182196
183197 it("stores option selection arrays in model selections", () => {
184198 expect(
185 createModelSelection("codex", "gpt-5.4", [
199 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
186200 { id: "reasoningEffort", value: "high" },
187201 { id: "fastMode", value: true },
188202 ]),
189203 ).toEqual({
190 provider: "codex",
204 instanceId: "codex",
191205 model: "gpt-5.4",
192206 options: [
193207 { id: "reasoningEffort", value: "high" },
......@@ -197,7 +211,7 @@ describe("descriptor helpers", () => {
197211 });
198212
199213 it("reads typed option selection values", () => {
200 const selection = createModelSelection("codex", "gpt-5.4", [
214 const selection = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
201215 { id: "reasoningEffort", value: "high" },
202216 { id: "fastMode", value: true },
203217 ]);
packages/shared/src/model.ts+16-12
......@@ -1,13 +1,17 @@
11import {
2 DEFAULT_MODEL,
23 DEFAULT_MODEL_BY_PROVIDER,
34 MODEL_SLUG_ALIASES_BY_PROVIDER,
45 type ModelCapabilities,
56 type ModelSelection,
7 ProviderDriverKind,
8 ProviderInstanceId,
69 type ProviderOptionDescriptor,
710 type ProviderOptionSelection,
8 type ProviderKind,
911} from "@t3tools/contracts";
1012
13const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
14
1115export interface SelectableModelOption {
1216 slug: string;
1317 name: string;
......@@ -230,7 +234,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole
230234
231235export function normalizeModelSlug(
232236 model: string | null | undefined,
233 provider: ProviderKind = "codex",
237 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,
234238): string | null {
235239 if (typeof model !== "string") {
236240 return null;
......@@ -241,7 +245,7 @@ export function normalizeModelSlug(
241245 return null;
242246 }
243247
244 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, string>;
248 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] ?? {};
245249 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)
246250 ? aliases[trimmed]
247251 : undefined;
......@@ -249,7 +253,7 @@ export function normalizeModelSlug(
249253}
250254
251255export function resolveSelectableModel(
252 provider: ProviderKind,
256 provider: ProviderDriverKind,
253257 value: string | null | undefined,
254258 options: ReadonlyArray<SelectableModelOption>,
255259): string | null {
......@@ -281,16 +285,16 @@ export function resolveSelectableModel(
281285 return resolved ? resolved.slug : null;
282286}
283287
284function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string {
288function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string {
285289 const normalized = normalizeModelSlug(model, provider);
286290 if (!normalized) {
287 return DEFAULT_MODEL_BY_PROVIDER[provider];
291 return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
288292 }
289293 return normalized;
290294}
291295
292296export function resolveModelSlugForProvider(
293 provider: ProviderKind,
297 provider: ProviderDriverKind,
294298 model: string | null | undefined,
295299): string {
296300 return resolveModelSlug(model, provider);
......@@ -310,16 +314,16 @@ function cloneSelections(
310314}
311315
312316export function createModelSelection(
313 provider: ProviderKind,
317 instanceId: ProviderInstanceId,
314318 model: string,
315319 options?: ReadonlyArray<ProviderOptionSelection> | null,
316320): ModelSelection {
317321 const selections = options ? cloneSelections(options) : [];
318 return {
319 provider,
322 const base: ModelSelection = {
323 instanceId,
320324 model,
321 ...(selections.length > 0 ? { options: selections } : {}),
322 } as ModelSelection;
325 };
326 return selections.length > 0 ? { ...base, options: selections } : base;
323327}
324328
325329/**
packages/shared/src/serverSettings.test.ts+70-20
......@@ -1,4 +1,8 @@
1import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5} from "@t3tools/contracts";
26import { describe, expect, it } from "vitest";
37import { createModelSelection } from "./model.ts";
48import {
......@@ -57,21 +61,25 @@ describe("serverSettings helpers", () => {
5761 it("replaces text generation selection when provider/model are provided", () => {
5862 const current = {
5963 ...DEFAULT_SERVER_SETTINGS,
60 textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [
61 { id: "reasoningEffort", value: "high" },
62 { id: "fastMode", value: true },
63 ]),
64 textGenerationModelSelection: createModelSelection(
65 ProviderInstanceId.make("codex"),
66 "gpt-5.4-mini",
67 [
68 { id: "reasoningEffort", value: "high" },
69 { id: "fastMode", value: true },
70 ],
71 ),
6472 };
6573
6674 expect(
6775 applyServerSettingsPatch(current, {
6876 textGenerationModelSelection: {
69 provider: "codex",
77 instanceId: ProviderInstanceId.make("codex"),
7078 model: "gpt-5.4-mini",
7179 },
7280 }).textGenerationModelSelection,
7381 ).toEqual({
74 provider: "codex",
82 instanceId: "codex",
7583 model: "gpt-5.4-mini",
7684 });
7785 });
......@@ -79,10 +87,14 @@ describe("serverSettings helpers", () => {
7987 it("still deep merges text generation selection when only options are provided", () => {
8088 const current = {
8189 ...DEFAULT_SERVER_SETTINGS,
82 textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [
83 { id: "reasoningEffort", value: "high" },
84 { id: "fastMode", value: true },
85 ]),
90 textGenerationModelSelection: createModelSelection(
91 ProviderInstanceId.make("codex"),
92 "gpt-5.4-mini",
93 [
94 { id: "reasoningEffort", value: "high" },
95 { id: "fastMode", value: true },
96 ],
97 ),
8698 };
8799
88100 expect(
......@@ -92,7 +104,7 @@ describe("serverSettings helpers", () => {
92104 },
93105 }).textGenerationModelSelection,
94106 ).toEqual({
95 provider: "codex",
107 instanceId: "codex",
96108 model: "gpt-5.4-mini",
97109 options: [
98110 { id: "reasoningEffort", value: "high" },
......@@ -104,21 +116,25 @@ describe("serverSettings helpers", () => {
104116 it("replaces text generation selection across providers without leaking stale options", () => {
105117 const current = {
106118 ...DEFAULT_SERVER_SETTINGS,
107 textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [
108 { id: "reasoningEffort", value: "high" },
109 { id: "fastMode", value: true },
110 ]),
119 textGenerationModelSelection: createModelSelection(
120 ProviderInstanceId.make("codex"),
121 "gpt-5.4-mini",
122 [
123 { id: "reasoningEffort", value: "high" },
124 { id: "fastMode", value: true },
125 ],
126 ),
111127 };
112128
113129 expect(
114130 applyServerSettingsPatch(current, {
115131 textGenerationModelSelection: {
116 provider: "opencode",
132 instanceId: ProviderInstanceId.make("opencode"),
117133 model: "openai/gpt-5",
118134 },
119135 }).textGenerationModelSelection,
120136 ).toEqual({
121 provider: "opencode",
137 instanceId: "opencode",
122138 model: "openai/gpt-5",
123139 });
124140 });
......@@ -127,7 +143,7 @@ describe("serverSettings helpers", () => {
127143 expect(
128144 applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
129145 textGenerationModelSelection: {
130 provider: "opencode",
146 instanceId: ProviderInstanceId.make("opencode"),
131147 model: "openai/gpt-5",
132148 options: [
133149 { id: "variant", value: "prod" },
......@@ -136,7 +152,7 @@ describe("serverSettings helpers", () => {
136152 },
137153 }).textGenerationModelSelection,
138154 ).toEqual({
139 provider: "opencode",
155 instanceId: "opencode",
140156 model: "openai/gpt-5",
141157 options: [
142158 { id: "variant", value: "prod" },
......@@ -144,4 +160,38 @@ describe("serverSettings helpers", () => {
144160 ],
145161 });
146162 });
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 });
147197});
packages/shared/src/serverSettings.ts+12-5
......@@ -44,7 +44,7 @@ export function parsePersistedServerObservabilitySettings(
4444function shouldReplaceTextGenerationModelSelection(
4545 patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined,
4646): boolean {
47 return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined));
47 return Boolean(patch && (patch.instanceId !== undefined || patch.model !== undefined));
4848}
4949
5050function mergeModelSelectionOptionsById(input: {
......@@ -76,11 +76,18 @@ export function applyServerSettingsPatch(
7676): ServerSettings {
7777 const selectionPatch = patch.textGenerationModelSelection;
7878 const next = deepMerge(current, patch);
79 const nextWithReplacements =
80 patch.providerInstances !== undefined
81 ? {
82 ...next,
83 providerInstances: patch.providerInstances,
84 }
85 : next;
7986 if (!selectionPatch) {
80 return next;
87 return nextWithReplacements;
8188 }
8289
83 const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider;
90 const instanceId = selectionPatch.instanceId ?? current.textGenerationModelSelection.instanceId;
8491 const model = selectionPatch.model ?? current.textGenerationModelSelection.model;
8592 const options = shouldReplaceTextGenerationModelSelection(selectionPatch)
8693 ? selectionPatch.options
......@@ -90,7 +97,7 @@ export function applyServerSettingsPatch(
9097 });
9198
9299 return {
93 ...next,
94 textGenerationModelSelection: createModelSelection(provider, model, options),
100 ...nextWithReplacements,
101 textGenerationModelSelection: createModelSelection(instanceId, model, options),
95102 };
96103}