authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 19:17:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 19:17:27-07:00
log3d17862e58554460b0baae7bae0e03d36b8ce5ab
tree40306f5ac4508e3de2d230f7cfd95f07ab6d470b
parent64275ae39653379e63f49f87be021c190fd20e81
signature Signed by PGP key B5690EEEBB952194

refactor(server): one module per service instead of Services/ and Layers/ folders (#16295)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

246 files changed, 21426 insertions(+), 21466 deletions(-)

.cursor/rules/cursor-cloud.mdc+1-1
......@@ -69,4 +69,4 @@ Gradle auto-downloads any additional pinned SDK packages on demand.
6969## Known flaky tests (unrelated to setup)
7070
7171- `apps/server/src/git/GitManager.test.ts` (cross-repo PR metadata, can time out at 12s).
72- `apps/server/src/provider/Layers/ProviderRegistry.test.ts` (codex binaryPath re-probe ordering).
72- `apps/server/src/provider/ProviderRegistry.test.ts` (codex binaryPath re-probe ordering).
apps/server/integration/transferBudgetV2.integration.test.ts+2-2
......@@ -35,8 +35,8 @@ import * as HttpApi from "effect/http-api/HttpApi";
3535import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
3636import { HttpRouter, HttpServer } from "effect/http";
3737import { Rpc, RpcGroup, RpcServer, RpcSerialization } from "effect/rpc";
38import * as SqlitePersistence from "../src/persistence/Layers/Sqlite.ts";
39import * as OrchestrationEventStore from "../src/persistence/Layers/OrchestrationEventStore.ts";
38import * as SqlitePersistence from "../src/persistence/Sqlite.ts";
39import * as OrchestrationEventStore from "../src/persistence/OrchestrationEventStore.ts";
4040import * as EventStore from "../src/orchestration-v2/EventStore.ts";
4141import * as EventSink from "../src/orchestration-v2/EventSink.ts";
4242import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts";
apps/server/scripts/migrate-dev-db.ts+1-1
......@@ -476,7 +476,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
476476 yield* Console.log("Running migrations on the snapshot...");
477477 const executed = yield* Effect.gen(function* () {
478478 const sql = yield* SqlClient.SqlClient;
479 // Mirror server boot (persistence/Layers/Sqlite.ts).
479 // Mirror server boot (persistence/Sqlite.ts).
480480 yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared;
481481 return yield* runMigrations();
482482 }).pipe(
apps/server/scripts/record-codex-app-server-replay-fixture.ts+1-1
......@@ -2,7 +2,7 @@ import * as NodeOS from "node:os";
22
33import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts";
44import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts";
5import { buildCodexInitializeParams } from "../src/provider/Layers/CodexProvider.ts";
5import { buildCodexInitializeParams } from "../src/provider/CodexProvider.ts";
66import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
77import * as NodeServices from "@effect/platform-node/NodeServices";
88import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
apps/server/src/auth/EnvironmentAuth.test.ts+1-1
......@@ -10,7 +10,7 @@ import * as SqlClient from "effect/sql/SqlClient";
1010import * as ServerConfig from "../config.ts";
1111import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1212import * as PersistenceErrors from "../persistence/Errors.ts";
13import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
13import * as SqlitePersistence from "../persistence/Sqlite.ts";
1414import * as PairingGrantStore from "./PairingGrantStore.ts";
1515import * as EnvironmentAuth from "./EnvironmentAuth.ts";
1616
apps/server/src/auth/EnvironmentAuth.ts+1-1
......@@ -38,7 +38,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts";
3838import * as SessionStore from "./SessionStore.ts";
3939import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
4040import { verifyRequestDpopProof } from "./dpop.ts";
41import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
41import * as SqlitePersistence from "../persistence/Sqlite.ts";
4242
4343const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
4444export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+1-1
......@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
55
66import * as ServerConfig from "../config.ts";
77import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
8import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
8import * as SqlitePersistence from "../persistence/Sqlite.ts";
99import * as EnvironmentAuth from "./EnvironmentAuth.ts";
1010import * as ServerSecretStore from "./ServerSecretStore.ts";
1111import * as SessionStore from "./SessionStore.ts";
apps/server/src/auth/PairingGrantStore.test.ts+1-1
......@@ -10,7 +10,7 @@ import * as TestClock from "effect/testing/TestClock";
1010import * as ServerConfig from "../config.ts";
1111import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
1212import { PersistenceSqlError } from "../persistence/Errors.ts";
13import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
13import * as SqlitePersistence from "../persistence/Sqlite.ts";
1414import * as PairingGrantStore from "./PairingGrantStore.ts";
1515
1616const layerServerConfig = (
apps/server/src/auth/SessionStore.test.ts+1-1
......@@ -16,7 +16,7 @@ import * as ServerConfig from "../config.ts";
1616import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1717import { PersistenceSqlError } from "../persistence/Errors.ts";
1818
19import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
19import * as SqlitePersistence from "../persistence/Sqlite.ts";
2020import * as AuthSessions from "../persistence/AuthSessions.ts";
2121import * as SessionStore from "./SessionStore.ts";
2222import * as ServerSecretStore from "./ServerSecretStore.ts";
apps/server/src/auth/http.test.ts+1-1
......@@ -15,7 +15,7 @@ import * as HttpRouter from "effect/http/HttpRouter";
1515
1616import * as ServerConfig from "../config.ts";
1717import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
18import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
18import * as SqlitePersistence from "../persistence/Sqlite.ts";
1919import * as EnvironmentAuth from "./EnvironmentAuth.ts";
2020import * as ServerSecretStore from "./ServerSecretStore.ts";
2121import * as AuthHttp from "./http.ts";
apps/server/src/cli/project.test.ts+1-1
......@@ -31,7 +31,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts";
3131import * as EventStore from "../orchestration-v2/EventStore.ts";
3232import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
3333import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
34import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
34import * as SqlitePersistence from "../persistence/Sqlite.ts";
3535import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
3636import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
3737import * as ProjectService from "../project/ProjectService.ts";
apps/server/src/cli/project.ts+1-1
......@@ -24,7 +24,7 @@ import * as HttpApiClient from "effect/http-api/HttpApiClient";
2424import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2525
2626import * as ServerConfig from "../config.ts";
27import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
27import * as SqlitePersistence from "../persistence/Sqlite.ts";
2828import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
2929import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
3030import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
apps/server/src/git/GitManager.test.ts+2-2
......@@ -53,9 +53,9 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr
5353import * as ServerConfig from "../config.ts";
5454import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
5555import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
56import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
56import * as SqlitePersistence from "../persistence/Sqlite.ts";
5757import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
58import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
58import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
5959import * as ServerSettings from "../serverSettings.ts";
6060import * as GitManager from "./GitManager.ts";
6161
apps/server/src/git/GitManager.ts+1-1
......@@ -66,7 +66,7 @@ import {
6666import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
6767import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
6868import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
69import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
69import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
7070import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
7171import { detachStackFrame } from "./detachStackFrame.ts";
7272import * as ServerSettings from "../serverSettings.ts";
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+1-1
......@@ -17,7 +17,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1717import { expect, it } from "vite-plus/test";
1818
1919import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
20import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
20import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
2121import * as ProjectService from "../project/ProjectService.ts";
2222import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
2323import * as SecretRequests from "../secrets/SecretRequests.ts";
apps/server/src/mcp/OrchestratorMcpService.test.ts+1-1
......@@ -20,7 +20,7 @@ import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts
2020import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
2121import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
2222import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
23import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
23import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
2424import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
2525import * as ProjectService from "../project/ProjectService.ts";
2626import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
apps/server/src/mcp/OrchestratorMcpService.ts+1-1
......@@ -76,7 +76,7 @@ import {
7676} from "../orchestration-v2/SubagentProjection.ts";
7777import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
7878import * as ProjectService from "../project/ProjectService.ts";
79import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
79import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
8080import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
8181import {
8282 type McpInvocationScope,
apps/server/src/mcp/toolkits/core.test.ts+1-1
......@@ -22,7 +22,7 @@ import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator
2222import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
2323import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
2424import * as ProjectService from "../../project/ProjectService.ts";
25import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts";
25import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
2626import * as SecretRequests from "../../secrets/SecretRequests.ts";
2727import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
2828import * as McpHttpServer from "../McpHttpServer.ts";
apps/server/src/mcp/toolkits/worktree/registration.test.ts+1-1
......@@ -17,7 +17,7 @@ import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdap
1717import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
1818import * as ProjectService from "../../../project/ProjectService.ts";
1919import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
20import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts";
20import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
2121import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
2222import * as SecretRequests from "../../../secrets/SecretRequests.ts";
2323import * as ServerSettings from "../../../serverSettings.ts";
apps/server/src/observability/Layers/Observability.ts deleted-102
......@@ -1,102 +0,0 @@
1import * as HttpObservability from "@t3tools/shared/httpObservability";
2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
3import * as SharedObservability from "@t3tools/shared/observability";
4import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as References from "effect/References";
8import * as Tracer from "effect/Tracer";
9import * as OtlpExporter from "effect/observability/OtlpExporter";
10import * as OtlpMetrics from "effect/observability/OtlpMetrics";
11import * as OtlpTracer from "effect/observability/OtlpTracer";
12
13import * as ServerConfig from "../../config.ts";
14import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
15import * as ServerLogger from "../../serverLogger.ts";
16import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
17
18export const layer = Layer.unwrap(
19 Effect.gen(function* () {
20 const config = yield* ServerConfig.ServerConfig;
21
22 const traces = config.otlpTracesExport;
23 const metrics = config.otlpMetricsExport;
24 // The trace serializer stays in the returned context because the browser
25 // trace forwarder exports on the same signal.
26 const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol);
27 const resource = ServerConfig.otlpResource(config);
28 const attribution = yield* ResourceAttribution.ResourceAttribution;
29
30 const layerTraceReferences = Layer.mergeAll(
31 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
32 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
33 HttpObservability.layer,
34 );
35
36 const layerTracer = Layer.unwrap(
37 Effect.gen(function* () {
38 const sink = yield* makeTraceSink({
39 filePath: config.serverTracePath,
40 maxBytes: config.traceMaxBytes,
41 maxFiles: config.traceMaxFiles,
42 batchWindowMs: config.traceBatchWindowMs,
43 onFlush: (stats) =>
44 attribution.record({
45 component: "server-trace",
46 operation: "append",
47 logicalWriteBytes: stats.logicalWriteBytes,
48 count: stats.count,
49 durationMs: stats.durationMs,
50 }),
51 });
52 const delegate =
53 config.otlpTracesUrl === undefined
54 ? undefined
55 : yield* OtlpTracer.make({
56 url: config.otlpTracesUrl,
57 exportInterval: `${traces.exportIntervalMs} millis`,
58 headers: traces.headers,
59 resource,
60 });
61
62 const tracer = yield* makeLocalFileTracer({
63 filePath: config.serverTracePath,
64 maxBytes: config.traceMaxBytes,
65 maxFiles: config.traceMaxFiles,
66 batchWindowMs: config.traceBatchWindowMs,
67 sink,
68 ...(delegate ? { delegate } : {}),
69 });
70
71 return Layer.mergeAll(
72 Layer.succeed(Tracer.Tracer, tracer),
73 BrowserTraceCollector.layer(sink),
74 );
75 }),
76 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(layerSerialization));
77
78 const metricsLayer =
79 config.otlpMetricsUrl === undefined
80 ? Layer.empty
81 : OtlpMetrics.layer({
82 url: config.otlpMetricsUrl,
83 exportInterval: `${metrics.exportIntervalMs} millis`,
84 headers: metrics.headers,
85 resource,
86 }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol)));
87
88 // Logged once the server's loggers are installed, so the warnings use them.
89 const layerOtelWarnings = Layer.effectDiscard(
90 Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)),
91 );
92
93 return layerOtelWarnings.pipe(
94 Layer.provideMerge(
95 Layer.mergeAll(ServerLogger.layer, layerTraceReferences, layerTracer, metricsLayer),
96 ),
97 Layer.provide(
98 OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes),
99 ),
100 );
101 }),
102);
apps/server/src/observability/Observability.ts created+102
......@@ -0,0 +1,102 @@
1import * as HttpObservability from "@t3tools/shared/httpObservability";
2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
3import * as SharedObservability from "@t3tools/shared/observability";
4import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as References from "effect/References";
8import * as Tracer from "effect/Tracer";
9import * as OtlpExporter from "effect/observability/OtlpExporter";
10import * as OtlpMetrics from "effect/observability/OtlpMetrics";
11import * as OtlpTracer from "effect/observability/OtlpTracer";
12
13import * as ServerConfig from "../config.ts";
14import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
15import * as ServerLogger from "../serverLogger.ts";
16import * as BrowserTraceCollector from "./BrowserTraceCollector.ts";
17
18export const layer = Layer.unwrap(
19 Effect.gen(function* () {
20 const config = yield* ServerConfig.ServerConfig;
21
22 const traces = config.otlpTracesExport;
23 const metrics = config.otlpMetricsExport;
24 // The trace serializer stays in the returned context because the browser
25 // trace forwarder exports on the same signal.
26 const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol);
27 const resource = ServerConfig.otlpResource(config);
28 const attribution = yield* ResourceAttribution.ResourceAttribution;
29
30 const layerTraceReferences = Layer.mergeAll(
31 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
32 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
33 HttpObservability.layer,
34 );
35
36 const layerTracer = Layer.unwrap(
37 Effect.gen(function* () {
38 const sink = yield* makeTraceSink({
39 filePath: config.serverTracePath,
40 maxBytes: config.traceMaxBytes,
41 maxFiles: config.traceMaxFiles,
42 batchWindowMs: config.traceBatchWindowMs,
43 onFlush: (stats) =>
44 attribution.record({
45 component: "server-trace",
46 operation: "append",
47 logicalWriteBytes: stats.logicalWriteBytes,
48 count: stats.count,
49 durationMs: stats.durationMs,
50 }),
51 });
52 const delegate =
53 config.otlpTracesUrl === undefined
54 ? undefined
55 : yield* OtlpTracer.make({
56 url: config.otlpTracesUrl,
57 exportInterval: `${traces.exportIntervalMs} millis`,
58 headers: traces.headers,
59 resource,
60 });
61
62 const tracer = yield* makeLocalFileTracer({
63 filePath: config.serverTracePath,
64 maxBytes: config.traceMaxBytes,
65 maxFiles: config.traceMaxFiles,
66 batchWindowMs: config.traceBatchWindowMs,
67 sink,
68 ...(delegate ? { delegate } : {}),
69 });
70
71 return Layer.mergeAll(
72 Layer.succeed(Tracer.Tracer, tracer),
73 BrowserTraceCollector.layer(sink),
74 );
75 }),
76 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(layerSerialization));
77
78 const metricsLayer =
79 config.otlpMetricsUrl === undefined
80 ? Layer.empty
81 : OtlpMetrics.layer({
82 url: config.otlpMetricsUrl,
83 exportInterval: `${metrics.exportIntervalMs} millis`,
84 headers: metrics.headers,
85 resource,
86 }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol)));
87
88 // Logged once the server's loggers are installed, so the warnings use them.
89 const layerOtelWarnings = Layer.effectDiscard(
90 Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)),
91 );
92
93 return layerOtelWarnings.pipe(
94 Layer.provideMerge(
95 Layer.mergeAll(ServerLogger.layer, layerTraceReferences, layerTracer, metricsLayer),
96 ),
97 Layer.provide(
98 OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes),
99 ),
100 );
101 }),
102);
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+4-4
......@@ -15,7 +15,7 @@ import {
1515import * as Console from "effect/Console";
1616import * as Effect from "effect/Effect";
1717import * as Layer from "effect/Layer";
18import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";
18import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
1919import { FetchHttpClient } from "effect/http";
2020import { describe } from "vite-plus/test";
2121
......@@ -23,13 +23,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
2323import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2424import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
2525import * as ServerConfig from "../config.ts";
26import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
26import * as SqlitePersistence from "../persistence/Sqlite.ts";
2727import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
2828import * as CodexInstallation from "../provider/CodexInstallation.ts";
2929import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3030import * as ModelManifest from "../provider/ModelManifest.ts";
31import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
32import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";
31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
3333import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
3434import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
3535import * as ServerSettings from "../serverSettings.ts";
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+1-1
......@@ -31,7 +31,7 @@ import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts";
3131import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts";
3232import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
3333import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";
34import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
34import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
3535import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
3636import * as IdAllocator from "../IdAllocator.ts";
3737import { makeProviderFailure } from "../ProviderFailure.ts";
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+1-1
......@@ -59,7 +59,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
5959import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
6060import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
6161import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
62import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
62import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
6363import {
6464 ProviderAdapterV2RuntimePolicy,
6565 type ProviderAdapterV2Event,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+4-4
......@@ -106,13 +106,13 @@ import {
106106 boundProviderEventForLogging,
107107 type EventNdjsonLogger,
108108 shouldPersistProviderEvent,
109} from "../../provider/Layers/EventNdjsonLogger.ts";
110import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
109} from "../../provider/EventNdjsonLogger.ts";
110import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
111111import {
112112 claudeRateLimitEventToUpdate,
113113 type ClaudeScopedLimitNames,
114} from "../../provider/Layers/claudeUsageLimits.ts";
115import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts";
114} from "../../provider/claudeUsageLimits.ts";
115import type { ServerProviderShape } from "../../provider/ServerProvider.ts";
116116import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
117117import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";
118118import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts";
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+2-2
......@@ -51,8 +51,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
5151import packageJson from "../../../package.json" with { type: "json" };
5252import * as ServerConfig from "../../config.ts";
5353import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
54import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
55import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
54import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
55import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
5656import * as IdAllocator from "../IdAllocator.ts";
5757import * as EffectWorker from "../EffectWorker.ts";
5858import * as Orchestrator from "../Orchestrator.ts";
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+6-9
......@@ -12,15 +12,15 @@ import {
1212 completeCodexTurnTokenUsage,
1313 type CodexTurnTokenUsageState,
1414} from "../../provider/CodexTurnTokenUsage.ts";
15import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts";
15import type { ServerProviderShape } from "../../provider/ServerProvider.ts";
1616import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";
17import { buildCodexInitializeParams } from "../../provider/Layers/CodexProvider.ts";
17import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts";
1818import {
1919 codexRateLimitsToUpdate,
2020 mergeCodexRateLimits,
2121 codexUsageLimitResetAt,
2222 type CodexRateLimitSnapshot,
23} from "../../provider/Layers/codexUsageLimits.ts";
23} from "../../provider/codexUsageLimits.ts";
2424import {
2525 CodexSettings,
2626 defaultInstanceIdForDriver,
......@@ -101,12 +101,9 @@ import {
101101 boundProviderEventForLogging,
102102 type EventNdjsonLogger,
103103 shouldPersistProviderEvent,
104} from "../../provider/Layers/EventNdjsonLogger.ts";
105import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
106import {
107 codexAppServerArgs,
108 resolveCodexLaunchArgs,
109} from "../../provider/Layers/codexLaunchArgs.ts";
104} from "../../provider/EventNdjsonLogger.ts";
105import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts";
106import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts";
110107import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
111108import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
112109import {
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts+1-1
......@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import { vi } from "vite-plus/test";
77
8import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
8import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
99import * as CursorAgentSdk from "./CursorAgentSdk.ts";
1010
1111const cursorSdkMock = vi.hoisted(() => {
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts+2-2
......@@ -17,8 +17,8 @@ import * as Layer from "effect/Layer";
1717import * as Schema from "effect/Schema";
1818
1919import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts";
20import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
21import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
20import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
21import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
2222
2323export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;
2424export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor");
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+2-2
......@@ -24,9 +24,9 @@ import type * as EffectAcpSchema from "effect-acp/compat";
2424
2525import * as ServerConfig from "../../config.ts";
2626import * as ProjectStore from "../ProjectStore.ts";
27import { buildInitialGrokProviderSnapshot } from "../../provider/Layers/GrokProvider.ts";
27import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts";
2828import type { ProviderInstance } from "../../provider/ProviderDriver.ts";
29import * as ProviderInstanceRegistry from "../../provider/Services/ProviderInstanceRegistry.ts";
29import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
3030import * as IdAllocator from "../IdAllocator.ts";
3131import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
3232import * as RuntimePolicy from "../RuntimePolicy.ts";
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+1-1
......@@ -56,7 +56,7 @@ import {
5656import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
5757import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";
5858import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
59import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
59import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
6060import * as IdAllocator from "../IdAllocator.ts";
6161import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";
6262import * as ProviderAdapter from "../ProviderAdapter.ts";
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+1-1
......@@ -32,7 +32,7 @@ import * as Stream from "effect/Stream";
3232import * as TestClock from "effect/testing/TestClock";
3333
3434import * as ServerConfig from "../../config.ts";
35import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
35import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
3636import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts";
3737import * as IdAllocator from "../IdAllocator.ts";
3838
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts+1-1
......@@ -8,7 +8,7 @@ import * as Schema from "effect/Schema";
88
99import * as ServerConfig from "../../config.ts";
1010import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts";
11import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
11import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
1212import * as IdAllocator from "../IdAllocator.ts";
1313import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts";
1414import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+2-2
......@@ -58,8 +58,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
5858import * as ServerConfig from "../../config.ts";
5959import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts";
6060import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
61import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
62import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
61import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
62import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
6363import {
6464 structuralProtocolMethod,
6565 summarizeNativeProtocolPayload,
apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts+1-1
......@@ -21,7 +21,7 @@ import * as Layer from "effect/Layer";
2121import * as Ref from "effect/Ref";
2222import * as SqlClient from "effect/sql/SqlClient";
2323
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
2525import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
2626import { VcsProcessTimeoutError } from "@t3tools/contracts";
2727import * as CheckpointService from "./CheckpointService.ts";
apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts+1-1
......@@ -19,7 +19,7 @@ import * as IdAllocator from "./IdAllocator.ts";
1919import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts";
2020import * as ProjectionStore from "./ProjectionStore.ts";
2121import * as ThreadManagement from "./ThreadManagementService.ts";
22import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
22import * as SqlitePersistence from "../persistence/Sqlite.ts";
2323const layerProjection = Layer.mergeAll(
2424 ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),
2525 SqlitePersistence.layerMemory,
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+1-1
......@@ -26,7 +26,7 @@ import * as Stream from "effect/Stream";
2626import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
2727import * as SecretRequests from "../secrets/SecretRequests.ts";
2828import * as Scheduler from "../scheduling/Scheduler.ts";
29import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
29import * as SqlitePersistence from "../persistence/Sqlite.ts";
3030import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
3131import * as EffectWorker from "./EffectWorker.ts";
3232import * as EventSink from "./EventSink.ts";
apps/server/src/orchestration-v2/CommandReceiptStore.ts+2-4
......@@ -5,9 +5,7 @@ import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import * as Schema from "effect/Schema";
8
9import * as OrchestrationCommandReceiptsLayer from "../persistence/Layers/OrchestrationCommandReceipts.ts";
10import * as OrchestrationCommandReceipts from "../persistence/Services/OrchestrationCommandReceipts.ts";
8import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts";
119
1210/**
1311 * ERRORS
......@@ -209,6 +207,6 @@ const layerBase: Layer.Layer<
209207 }),
210208);
211209
212export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceiptsLayer.layer));
210export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceipts.layer));
213211
214212export const layerFromApplicationReceipts = layerBase;
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+4-4
......@@ -13,7 +13,7 @@ import {
1313import * as Console from "effect/Console";
1414import * as Effect from "effect/Effect";
1515import * as Layer from "effect/Layer";
16import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";
16import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
1717import { FetchHttpClient } from "effect/http";
1818import { describe } from "vite-plus/test";
1919
......@@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
2121import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2222import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
2323import * as ServerConfig from "../config.ts";
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
2525import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
2626import * as CodexInstallation from "../provider/CodexInstallation.ts";
2727import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
2828import * as ModelManifest from "../provider/ModelManifest.ts";
29import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
30import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";
29import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
30import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
3131import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
3232import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
3333import * as ServerSettings from "../serverSettings.ts";
apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts+2-2
......@@ -23,11 +23,11 @@ import * as Stream from "effect/Stream";
2323import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
2424import * as ServerConfig from "../config.ts";
2525import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
26import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
26import * as SqlitePersistence from "../persistence/Sqlite.ts";
2727import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
2828import * as ProjectService from "../project/ProjectService.ts";
2929import type { ProviderInstance } from "../provider/ProviderDriver.ts";
30import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
30import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
3131import * as ServerSettings from "../serverSettings.ts";
3232import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
3333import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/EventSink.ts+1-1
......@@ -23,7 +23,7 @@ import * as Stream from "effect/Stream";
2323import * as SqlClient from "effect/sql/SqlClient";
2424
2525import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts";
26import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts";
26import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts";
2727import { projectDomainEventForWire } from "./WireProjection.ts";
2828
2929import * as CommandReceiptStore from "./CommandReceiptStore.ts";
apps/server/src/orchestration-v2/EventStore.ts+2-4
......@@ -12,9 +12,7 @@ import * as Layer from "effect/Layer";
1212import * as Schema from "effect/Schema";
1313import * as Stream from "effect/Stream";
1414import type * as SqlClient from "effect/sql/SqlClient";
15
16import * as OrchestrationEventStoreLayer from "../persistence/Layers/OrchestrationEventStore.ts";
17import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
15import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
1816
1917export class EventStoreAppendEventsError extends Schema.TaggedError<EventStoreAppendEventsError>()(
2018 "EventStoreAppendEventsError",
......@@ -150,7 +148,7 @@ const layerBase: Layer.Layer<EventStoreV2, never, OrchestrationEventStore.Orches
150148 );
151149
152150export const layer: Layer.Layer<EventStoreV2, never, SqlClient.SqlClient> = layerBase.pipe(
153 Layer.provide(OrchestrationEventStoreLayer.layer),
151 Layer.provide(OrchestrationEventStore.layer),
154152);
155153
156154export const layerFromOrchestrationEventStore = layerBase;
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+1-1
......@@ -43,7 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient";
4343import * as Statement from "effect/sql/Statement";
4444
4545import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts";
46import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
46import * as SqlitePersistence from "../persistence/Sqlite.ts";
4747import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
4848import * as CommandReceiptStore from "./CommandReceiptStore.ts";
4949import * as EffectOutbox from "./EffectOutbox.ts";
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+4-4
......@@ -13,7 +13,7 @@ import {
1313import * as Console from "effect/Console";
1414import * as Effect from "effect/Effect";
1515import * as Layer from "effect/Layer";
16import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";
16import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
1717import { FetchHttpClient } from "effect/http";
1818import { describe } from "vite-plus/test";
1919
......@@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
2121import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2222import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
2323import * as ServerConfig from "../config.ts";
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
2525import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
2626import * as CodexInstallation from "../provider/CodexInstallation.ts";
2727import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
2828import * as ModelManifest from "../provider/ModelManifest.ts";
29import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
30import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";
29import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
30import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
3131import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
3232import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
3333import * as ServerSettings from "../serverSettings.ts";
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+5-5
......@@ -43,18 +43,18 @@ import { FetchHttpClient, HttpClient } from "effect/http";
4343import { ChildProcess, ChildProcessSpawner } from "effect/process";
4444import { describe } from "vite-plus/test";
4545
46import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";
46import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
4747import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
4848import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
4949import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
5050import * as ServerConfig from "../config.ts";
51import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
51import * as SqlitePersistence from "../persistence/Sqlite.ts";
5252import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
5353import * as CodexInstallation from "../provider/CodexInstallation.ts";
5454import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
5555import * as ModelManifest from "../provider/ModelManifest.ts";
56import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
57import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";
56import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
57import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
5858import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts";
5959import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
6060import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
......@@ -63,7 +63,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
6363import * as VcsProcess from "../vcs/VcsProcess.ts";
6464import * as EffectWorker from "./EffectWorker.ts";
6565import * as Orchestrator from "./Orchestrator.ts";
66import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
66import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
6767import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts";
6868import * as RuntimeLayer from "./runtimeLayer.ts";
6969import * as IdAllocator from "./IdAllocator.ts";
apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts+1-1
......@@ -21,7 +21,7 @@ import * as DateTime from "effect/DateTime";
2121import * as Effect from "effect/Effect";
2222import * as Layer from "effect/Layer";
2323import * as SqlClient from "effect/sql/SqlClient";
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
2525import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
2626import * as Orchestrator from "./Orchestrator.ts";
2727import * as ProjectionStore from "./ProjectionStore.ts";
apps/server/src/orchestration-v2/ProjectCommands.ts+1-1
......@@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime";
1313import * as Result from "effect/Result";
1414import * as Schema from "effect/Schema";
1515
16import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts";
16import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts";
1717import type { ProjectRow } from "./ProjectStore.ts";
1818
1919export interface ProjectCreateCommand {
apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts+2-2
......@@ -15,11 +15,11 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
1515import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
1616
1717import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
18import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
18import * as SqlitePersistence from "../persistence/Sqlite.ts";
1919import { runMigrations } from "../persistence/Migrations.ts";
2020import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
2121import * as ProjectService from "../project/ProjectService.ts";
22import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
22import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
2323import * as ServerSettings from "../serverSettings.ts";
2424import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
2525import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
apps/server/src/orchestration-v2/ProjectStore.test.ts+1-1
......@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
66import * as SqlClient from "effect/sql/SqlClient";
77
8import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
8import * as SqlitePersistence from "../persistence/Sqlite.ts";
99import * as ProjectStore from "./ProjectStore.ts";
1010
1111it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))(
apps/server/src/orchestration-v2/ProjectionControlReads.test.ts+1-1
......@@ -20,7 +20,7 @@ import * as Effect from "effect/Effect";
2020import * as Layer from "effect/Layer";
2121import * as Option from "effect/Option";
2222import * as SqlClient from "effect/sql/SqlClient";
23import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
23import * as SqlitePersistence from "../persistence/Sqlite.ts";
2424import * as ProjectionStore from "./ProjectionStore.ts";
2525import * as ProviderSessionManager from "./ProviderSessionManager.ts";
2626import * as ProviderTurnControlService from "./ProviderTurnControlService.ts";
apps/server/src/orchestration-v2/ProjectionRecovery.test.ts+1-1
......@@ -22,7 +22,7 @@ import * as Effect from "effect/Effect";
2222import * as Layer from "effect/Layer";
2323import * as SqlClient from "effect/sql/SqlClient";
2424
25import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
25import * as SqlitePersistence from "../persistence/Sqlite.ts";
2626import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
2727import * as EffectOutbox from "./EffectOutbox.ts";
2828import * as ProjectionStore from "./ProjectionStore.ts";
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+1-1
......@@ -21,7 +21,7 @@ import * as Schema from "effect/Schema";
2121import * as SqlClient from "effect/sql/SqlClient";
2222import * as Statement from "effect/sql/Statement";
2323
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
2525import * as ProjectionStore from "./ProjectionStore.ts";
2626import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts";
2727
apps/server/src/orchestration-v2/ProjectionStore.test.ts+1-1
......@@ -30,7 +30,7 @@ import * as Schema from "effect/Schema";
3030import * as SqlClient from "effect/sql/SqlClient";
3131import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";
3232
33import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
33import * as SqlitePersistence from "../persistence/Sqlite.ts";
3434import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
3535import * as ProjectionStore from "./ProjectionStore.ts";
3636import {
apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts+2-2
......@@ -19,9 +19,9 @@ import type * as Scope from "effect/Scope";
1919import * as Stream from "effect/Stream";
2020
2121import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts";
22import type { ProviderAuthController } from "../provider/Services/ProviderAuthService.ts";
22import type { ProviderAuthController } from "../provider/ProviderAuthService.ts";
2323import type { ProviderInstance } from "../provider/ProviderDriver.ts";
24import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
24import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
2525import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
2626import {
2727 ProviderAdapterDriverCreateError,
apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts+1-1
......@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";
1313import * as Schema from "effect/Schema";
1414import * as Scope from "effect/Scope";
1515
16import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
16import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
1717import {
1818 ProviderAdapterDriverCreateError,
1919 type AnyProviderAdapterDriver,
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+1-1
......@@ -27,7 +27,7 @@ import * as Layer from "effect/Layer";
2727import * as Stream from "effect/Stream";
2828import * as TestClock from "effect/testing/TestClock";
2929
30import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";
3131import * as EventSink from "./EventSink.ts";
3232import * as EventStore from "./EventStore.ts";
3333import * as IdAllocator from "./IdAllocator.ts";
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts+1-1
......@@ -25,7 +25,7 @@ import * as Effect from "effect/Effect";
2525import * as Layer from "effect/Layer";
2626import * as SqlClient from "effect/sql/SqlClient";
2727
28import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
28import * as SqlitePersistence from "../persistence/Sqlite.ts";
2929import * as ServerSettings from "../serverSettings.ts";
3030import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
3131import * as EffectOutbox from "./EffectOutbox.ts";
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+1-1
......@@ -36,7 +36,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3636import * as ProjectService from "../project/ProjectService.ts";
3737import * as McpProviderSession from "../mcp/McpProviderSession.ts";
3838import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
39import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
39import * as SqlitePersistence from "../persistence/Sqlite.ts";
4040import * as ServerSettings from "../serverSettings.ts";
4141import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
4242import * as EventSink from "./EventSink.ts";
apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts+1-1
......@@ -26,7 +26,7 @@ import * as Schema from "effect/Schema";
2626
2727import * as GitWorkflow from "../git/GitWorkflowService.ts";
2828import * as ProjectService from "../project/ProjectService.ts";
29import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts";
29import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
3030import * as ContextHandoffService from "./ContextHandoffService.ts";
3131import * as EventSink from "./EventSink.ts";
3232import * as IdAllocator from "./IdAllocator.ts";
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+1-1
......@@ -23,7 +23,7 @@ import * as Schema from "effect/Schema";
2323
2424import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2525import * as ProjectService from "../project/ProjectService.ts";
26import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts";
26import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
2727import * as EventSink from "./EventSink.ts";
2828import * as ContextHandoffService from "./ContextHandoffService.ts";
2929import {
apps/server/src/orchestration-v2/RunCompletionReads.test.ts+1-1
......@@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime";
1313import * as Effect from "effect/Effect";
1414import * as Layer from "effect/Layer";
1515import * as SqlClient from "effect/sql/SqlClient";
16import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
16import * as SqlitePersistence from "../persistence/Sqlite.ts";
1717import * as ProjectionStore from "./ProjectionStore.ts";
1818
1919const layerDatabase = Layer.mergeAll(
apps/server/src/orchestration-v2/RuntimePolicy.test.ts+1-1
......@@ -15,7 +15,7 @@ import * as Option from "effect/Option";
1515import * as Stream from "effect/Stream";
1616
1717import type { ProviderInstance } from "../provider/ProviderDriver.ts";
18import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
18import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
1919import * as ProjectStore from "./ProjectStore.ts";
2020import * as RuntimePolicy from "./RuntimePolicy.ts";
2121
apps/server/src/orchestration-v2/RuntimePolicy.ts+1-1
......@@ -11,7 +11,7 @@ import * as Layer from "effect/Layer";
1111import * as Option from "effect/Option";
1212import * as Schema from "effect/Schema";
1313
14import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
14import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
1515import {
1616 ProviderAdapterV2RuntimePolicy,
1717 type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType,
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+1-1
......@@ -40,7 +40,7 @@ import * as Schema from "effect/Schema";
4040import * as TestClock from "effect/testing/TestClock";
4141
4242import * as GitWorkflow from "../git/GitWorkflowService.ts";
43import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
43import * as SqlitePersistence from "../persistence/Sqlite.ts";
4444import * as ProjectStore from "./ProjectStore.ts";
4545import * as ProjectService from "../project/ProjectService.ts";
4646import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
apps/server/src/orchestration-v2/ThreadLaunchService.ts+1-1
......@@ -39,7 +39,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
3939import * as ProjectService from "../project/ProjectService.ts";
4040import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
4141import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
42import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
42import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
4343import * as ServerSettings from "../serverSettings.ts";
4444import * as TextGeneration from "../textGeneration/TextGeneration.ts";
4545import * as CommandReceiptStore from "./CommandReceiptStore.ts";
apps/server/src/orchestration-v2/ThreadSearch.test.ts+1-1
......@@ -12,7 +12,7 @@ import * as Effect from "effect/Effect";
1212import * as Layer from "effect/Layer";
1313import * as SqlClient from "effect/sql/SqlClient";
1414
15import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
15import * as SqlitePersistence from "../persistence/Sqlite.ts";
1616import * as ProjectionStore from "./ProjectionStore.ts";
1717import * as ProjectStore from "./ProjectStore.ts";
1818import * as ThreadSearch from "./ThreadSearch.ts";
apps/server/src/orchestration-v2/ThreadStop.test.ts+1-1
......@@ -19,7 +19,7 @@ import * as Schema from "effect/Schema";
1919import * as SqlClient from "effect/sql/SqlClient";
2020import * as TestClock from "effect/testing/TestClock";
2121
22import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
22import * as SqlitePersistence from "../persistence/Sqlite.ts";
2323import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
2424import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
2525import * as Orchestrator from "./Orchestrator.ts";
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+1-1
......@@ -16,7 +16,7 @@ import * as TestClock from "effect/testing/TestClock";
1616import * as Layer from "effect/Layer";
1717import * as Option from "effect/Option";
1818
19import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
19import * as SqlitePersistence from "../persistence/Sqlite.ts";
2020import * as ProjectStore from "./ProjectStore.ts";
2121import * as ServerSettings from "../serverSettings.ts";
2222import * as TextGeneration from "../textGeneration/TextGeneration.ts";
apps/server/src/orchestration-v2/TurnStartReads.test.ts+1-1
......@@ -16,7 +16,7 @@ import * as DateTime from "effect/DateTime";
1616import * as Effect from "effect/Effect";
1717import * as Layer from "effect/Layer";
1818import * as SqlClient from "effect/sql/SqlClient";
19import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
19import * as SqlitePersistence from "../persistence/Sqlite.ts";
2020import * as ProjectionStore from "./ProjectionStore.ts";
2121
2222const layerDatabase = Layer.mergeAll(
apps/server/src/orchestration-v2/http.ts+1-1
......@@ -17,7 +17,7 @@ import {
1717 failEnvironmentNotFound,
1818 requireEnvironmentScope,
1919} from "../auth/http.ts";
20import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
20import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
2121import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
2222import {
2323 buildBoundedThreadProjection,
apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts+1-1
......@@ -27,7 +27,7 @@ import * as Stream from "effect/Stream";
2727import * as SqlClient from "effect/sql/SqlClient";
2828
2929import { runMigrations } from "../../persistence/Migrations.ts";
30import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";
30import * as SqlitePersistence from "../../persistence/Sqlite.ts";
3131import Migration0042 from "../../persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts";
3232import Migration0043 from "../../persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts";
3333import Migration0044 from "../../persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts";
apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";
66import * as Tracer from "effect/Tracer";
77import * as SqlClient from "effect/sql/SqlClient";
88
9import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";
9import * as SqlitePersistence from "../../persistence/Sqlite.ts";
1010import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts";
1111import * as EventSink from "../EventSink.ts";
1212import * as EventStore from "../EventStore.ts";
apps/server/src/orchestration-v2/runtimeLayer.test.ts+3-3
......@@ -43,13 +43,13 @@ import * as SqlClient from "effect/sql/SqlClient";
4343import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
4444import * as GitWorkflow from "../git/GitWorkflowService.ts";
4545import * as ServerConfig from "../config.ts";
46import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
47import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
46import * as SqlitePersistence from "../persistence/Sqlite.ts";
47import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
4848import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
4949import * as ProjectService from "../project/ProjectService.ts";
5050import * as ServerSettings from "../serverSettings.ts";
5151import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
52import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
52import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
5353import type { ProviderInstance } from "../provider/ProviderDriver.ts";
5454import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
5555import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/runtimeLayer.ts+3-3
......@@ -1,11 +1,11 @@
11import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts";
22import * as Scheduler from "../scheduling/Scheduler.ts";
33import * as Layer from "effect/Layer";
4import * as OrchestrationCommandReceipts from "../persistence/Layers/OrchestrationCommandReceipts.ts";
5import * as OrchestrationEventStore from "../persistence/Layers/OrchestrationEventStore.ts";
4import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts";
5import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
66import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";
77import * as TextGeneration from "../textGeneration/TextGeneration.ts";
8import * as ProviderAuthService from "../provider/Layers/ProviderAuthService.ts";
8import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
99import * as AgentSessionImporter from "../project/AgentSessionImporter.ts";
1010import * as AgentSessionScanner from "../project/AgentSessionScanner.ts";
1111import * as ProjectService from "../project/ProjectService.ts";
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts+1-1
......@@ -21,7 +21,7 @@ import {
2121} from "../Adapters/CursorAdapterV2.testkit.ts";
2222import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts";
2323import * as IdAllocator from "../IdAllocator.ts";
24import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";
24import * as SqlitePersistence from "../../persistence/Sqlite.ts";
2525import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
2626import {
2727 CLAUDE_MODEL_SELECTION,
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts+1-1
......@@ -12,7 +12,7 @@ import {
1212} from "../Adapters/ClaudeAdapterV2.testkit.ts";
1313import * as IdAllocator from "../IdAllocator.ts";
1414import * as EffectWorker from "../EffectWorker.ts";
15import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";
15import * as SqlitePersistence from "../../persistence/Sqlite.ts";
1616import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
1717import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts";
1818import {
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+2-2
......@@ -13,7 +13,7 @@ import type { SqlError } from "effect/sql/SqlError";
1313
1414import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
1515import * as ServerConfig from "../../config.ts";
16import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";
16import * as SqlitePersistence from "../../persistence/Sqlite.ts";
1717import * as ServerSettings from "../../serverSettings.ts";
1818import * as ThreadManagementService from "../ThreadManagementService.ts";
1919import * as McpSessionRegistryTestkit from "../../mcp/McpSessionRegistry.testkit.ts";
......@@ -34,7 +34,7 @@ import * as Orchestrator from "../Orchestrator.ts";
3434import * as ProjectionStore from "../ProjectionStore.ts";
3535import * as ProjectStore from "../ProjectStore.ts";
3636import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
37import * as ProviderAuthService from "../../provider/Services/ProviderAuthService.ts";
37import * as ProviderAuthService from "../../provider/ProviderAuthService.ts";
3838import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";
3939import * as ProviderContinuationService from "../ProviderContinuationService.ts";
4040import * as ProviderEventIngestor from "../ProviderEventIngestor.ts";
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1
......@@ -32,7 +32,7 @@ import * as Stream from "effect/Stream";
3232import * as Schema from "effect/Schema";
3333import * as SqlClient from "effect/sql/SqlClient";
3434
35import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";
35import * as SqlitePersistence from "../../persistence/Sqlite.ts";
3636import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts";
3737import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts";
3838import {
apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs+1-1
......@@ -25,7 +25,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project
2525 app("orchestration-v2/ContextHandoffService"),
2626 app("git/GitWorkflowService"),
2727 app("project/ProjectService"),
28 app("provider/Services/ProviderAuthService"),
28 app("provider/ProviderAuthService"),
2929 ]);
3030let current;
3131let fullReads = 0;
apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts deleted-126
......@@ -1,126 +0,0 @@
1import * as SqlClient from "effect/sql/SqlClient";
2import * as SqlSchema from "effect/sql/SqlSchema";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { toPersistenceSqlError } from "../Errors.ts";
7
8import * as OrchestrationCommandReceipts from "../Services/OrchestrationCommandReceipts.ts";
9
10const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () {
11 const sql = yield* SqlClient.SqlClient;
12
13 const upsertReceiptRow = SqlSchema.void({
14 Request: OrchestrationCommandReceipts.OrchestrationCommandReceipt,
15 execute: (receipt) =>
16 sql`
17 INSERT INTO orchestration_command_receipts (
18 command_id,
19 aggregate_kind,
20 aggregate_id,
21 command_type,
22 accepted_at,
23 result_sequence,
24 status,
25 error
26 )
27 VALUES (
28 ${receipt.commandId},
29 ${receipt.aggregateKind},
30 ${receipt.aggregateId},
31 ${receipt.commandType},
32 ${receipt.acceptedAt},
33 ${receipt.resultSequence},
34 ${receipt.status},
35 ${receipt.error}
36 )
37 ON CONFLICT (command_id)
38 DO UPDATE SET
39 aggregate_kind = excluded.aggregate_kind,
40 aggregate_id = excluded.aggregate_id,
41 command_type = excluded.command_type,
42 accepted_at = excluded.accepted_at,
43 result_sequence = excluded.result_sequence,
44 status = excluded.status,
45 error = excluded.error
46 `,
47 });
48
49 const findReceiptByCommandId = SqlSchema.findOneOption({
50 Request: OrchestrationCommandReceipts.GetByCommandIdInput,
51 Result: OrchestrationCommandReceipts.OrchestrationCommandReceipt,
52 execute: ({ commandId }) =>
53 sql`
54 SELECT
55 command_id AS "commandId",
56 aggregate_kind AS "aggregateKind",
57 aggregate_id AS "aggregateId",
58 command_type AS "commandType",
59 accepted_at AS "acceptedAt",
60 result_sequence AS "resultSequence",
61 status,
62 error
63 FROM orchestration_command_receipts
64 WHERE command_id = ${commandId}
65 `,
66 });
67
68 const upsert: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["upsert"] =
69 (receipt) =>
70 upsertReceiptRow(receipt).pipe(
71 Effect.mapError(
72 toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query"),
73 ),
74 );
75
76 const insertIfAbsent: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["insertIfAbsent"] =
77 (receipt) =>
78 sql<{ readonly command_id: string }>`
79 INSERT INTO orchestration_command_receipts (
80 command_id,
81 aggregate_kind,
82 aggregate_id,
83 command_type,
84 accepted_at,
85 result_sequence,
86 status,
87 error
88 )
89 VALUES (
90 ${receipt.commandId},
91 ${receipt.aggregateKind},
92 ${receipt.aggregateId},
93 ${receipt.commandType},
94 ${receipt.acceptedAt},
95 ${receipt.resultSequence},
96 ${receipt.status},
97 ${receipt.error}
98 )
99 ON CONFLICT(command_id) DO NOTHING
100 RETURNING command_id
101 `.pipe(
102 Effect.map((rows) => rows.length === 1),
103 Effect.mapError(
104 toPersistenceSqlError("OrchestrationCommandReceiptRepository.insertIfAbsent:query"),
105 ),
106 );
107
108 const getByCommandId: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["getByCommandId"] =
109 (input) =>
110 findReceiptByCommandId(input).pipe(
111 Effect.mapError(
112 toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"),
113 ),
114 );
115
116 return {
117 insertIfAbsent,
118 upsert,
119 getByCommandId,
120 } satisfies OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape;
121});
122
123export const layer = Layer.effect(
124 OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository,
125 makeOrchestrationCommandReceiptRepository,
126);
apps/server/src/persistence/Layers/OrchestrationEventStore.sequence.test.ts deleted-275
......@@ -1,275 +0,0 @@
1import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import * as Tracer from "effect/Tracer";
9import * as SqlClient from "effect/sql/SqlClient";
10
11import { runMigrations } from "../Migrations.ts";
12import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
13import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts";
14import * as OrchestrationEventStoreLayer from "./OrchestrationEventStore.ts";
15import * as SqlitePersistence from "./Sqlite.ts";
16
17const layerEventStore = OrchestrationEventStoreLayer.layer.pipe(
18 Layer.provideMerge(SqlitePersistence.layerMemory),
19);
20const occurredAt = "2026-09-03T00:00:00.000Z";
21const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
22
23function eventRow(
24 ordinal: number,
25 aggregateKind: "project" | "thread",
26 version: number,
27 streamId = `${aggregateKind}:${ordinal}`,
28 commandId: string | null = null,
29) {
30 return {
31 event_id: `event:${ordinal}`,
32 aggregate_kind: aggregateKind,
33 stream_id: streamId,
34 stream_version: ordinal,
35 event_type: aggregateKind === "project" ? "project.created" : "provider-session.detached",
36 occurred_at: occurredAt,
37 command_id: commandId,
38 causation_event_id: null,
39 correlation_id: null,
40 actor_kind: "server",
41 payload_json: encodeJson(
42 aggregateKind === "project"
43 ? {
44 projectId: streamId,
45 title: "Sequence fixture",
46 workspaceRoot: "/tmp/sequence-fixture",
47 defaultModelSelection: null,
48 scripts: [],
49 createdAt: occurredAt,
50 updatedAt: occurredAt,
51 }
52 : { providerSessionId: `session:${ordinal}`, detachedAt: occurredAt },
53 ),
54 metadata_json: "{}",
55 application_event_version: version,
56 };
57}
58
59const seedEvents = Effect.fn("test.seedSequenceEvents")(function* (
60 rows: ReadonlyArray<ReturnType<typeof eventRow>>,
61) {
62 const sql = yield* SqlClient.SqlClient;
63 return yield* sql<{
64 readonly sequence: number;
65 readonly aggregate_kind: string;
66 readonly application_event_version: number;
67 readonly stream_id: string;
68 readonly command_id: string | null;
69 }>`
70 INSERT INTO orchestration_events ${sql.insert(rows)}
71 RETURNING sequence, aggregate_kind, application_event_version, stream_id, command_id
72 `;
73});
74
75it.effect("keeps application and scoped agent high-water marks separate from legacy history", () =>
76 Effect.gen(function* () {
77 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
78 const sql = yield* SqlClient.SqlClient;
79 const target = ThreadId.make("target");
80 assert.equal(yield* store.latestApplicationSequence, 0);
81 assert.equal(yield* store.latestAgentSequence(), 0);
82 assert.equal(yield* store.latestAgentSequence(target), 0);
83
84 yield* seedEvents([eventRow(1, "thread", 1, "legacy-only")]);
85 assert.equal(yield* store.latestApplicationSequence, 0);
86 assert.equal(yield* store.latestAgentSequence(ThreadId.make("legacy-only")), 0);
87
88 const [legacyProject, targetEvent, otherEvent] = yield* seedEvents([
89 eventRow(2, "project", 1),
90 eventRow(3, "thread", 2, target),
91 eventRow(4, "thread", 2, "other"),
92 ]);
93 assert.ok(legacyProject);
94 assert.ok(targetEvent);
95 assert.ok(otherEvent);
96 assert.equal(yield* store.latestApplicationSequence, otherEvent.sequence);
97 assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence);
98 assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence);
99
100 const [projectEvent] = yield* seedEvents([
101 eventRow(5, "project", 2),
102 eventRow(6, "thread", 1, target),
103 eventRow(7, "thread", 3, target),
104 ]);
105 assert.ok(projectEvent);
106 assert.equal(yield* store.latestApplicationSequence, projectEvent.sequence);
107 assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence);
108 assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence);
109 assert.equal(yield* store.latestAgentSequence(ThreadId.make("missing")), 0);
110
111 yield* sql`DELETE FROM orchestration_events WHERE sequence > ${legacyProject.sequence}`;
112 assert.equal(yield* store.latestApplicationSequence, legacyProject.sequence);
113 assert.equal(yield* store.latestAgentSequence(), 0);
114 assert.equal(yield* store.latestAgentSequence(target), 0);
115 }).pipe(Effect.provide(Layer.fresh(layerEventStore))),
116);
117
118it.effect(
119 "preserves mixed application paging, scoped replay, and the catch-up to live boundary",
120 () =>
121 Effect.gen(function* () {
122 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
123 const rows = yield* seedEvents(
124 Array.from({ length: 1_560 }, (_, index) => {
125 const kind = index % 3 === 1 ? "project" : "thread";
126 return eventRow(
127 index,
128 kind,
129 index % 3 === 0 ? 1 : 2,
130 kind === "project" ? `project:${index}` : index % 2 === 0 ? "target" : "other",
131 index % 5 === 0 ? "selected-command" : "other-command",
132 );
133 }),
134 );
135 const afterSequence = rows[25]!.sequence;
136 const throughSequence = rows[1_525]!.sequence;
137 const retained = rows.filter(
138 (row) => row.aggregate_kind === "project" || row.application_event_version === 2,
139 );
140 const replay = yield* store
141 .readApplicationEvents({ afterSequence, throughSequence })
142 .pipe(Stream.runCollect);
143 assert.deepEqual(
144 replay.map((event) => event.sequence),
145 retained
146 .filter((row) => row.sequence > afterSequence && row.sequence <= throughSequence)
147 .map((row) => row.sequence),
148 );
149
150 const scoped = yield* store
151 .readAgentEvents({
152 afterSequence,
153 throughSequence,
154 threadId: ThreadId.make("target"),
155 commandId: CommandId.make("selected-command"),
156 limit: 17,
157 })
158 .pipe(Stream.runCollect);
159 assert.deepEqual(
160 scoped.map((event) => event.sequence),
161 retained
162 .filter(
163 (row) =>
164 row.sequence > afterSequence &&
165 row.sequence <= throughSequence &&
166 row.aggregate_kind === "thread" &&
167 row.stream_id === "target" &&
168 row.command_id === "selected-command",
169 )
170 .slice(0, 17)
171 .map((row) => row.sequence),
172 );
173
174 const catchUp = retained.filter((row) => row.sequence > afterSequence);
175 const highWater = catchUp.at(-1)!.sequence;
176 let liveSequence = 0;
177 const streamed = yield* store.streamApplicationEvents({ afterSequence }).pipe(
178 Stream.tap((event) =>
179 event.sequence !== highWater
180 ? Effect.void
181 : Effect.gen(function* () {
182 const [stored] = yield* store.appendAgentEvents({
183 events: [
184 {
185 id: EventId.make("live-after-catch-up"),
186 type: "provider-session.detached",
187 threadId: ThreadId.make("target"),
188 occurredAt: DateTime.makeUnsafe(occurredAt),
189 payload: {
190 providerSessionId: ProviderSessionId.make("live-session"),
191 detachedAt: DateTime.makeUnsafe(occurredAt),
192 },
193 },
194 ],
195 });
196 assert.ok(stored);
197 liveSequence = stored.sequence;
198 yield* store.publishCommitted([stored]);
199 }),
200 ),
201 Stream.take(catchUp.length + 1),
202 Stream.runCollect,
203 );
204 assert.deepEqual(
205 streamed.map((event) => event.sequence),
206 [...catchUp.map((row) => row.sequence), liveSequence],
207 );
208 assert.isAbove(liveSequence, highWater);
209 }).pipe(Effect.provide(Layer.fresh(layerEventStore))),
210);
211
212it.effect("uses indexed high-water lookups for populated history without OR scans", () =>
213 Effect.gen(function* () {
214 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
215 const sql = yield* SqlClient.SqlClient;
216 yield* runMigrations();
217 yield* sql`
218 WITH RECURSIVE history(n) AS (
219 SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000
220 )
221 INSERT INTO orchestration_events (
222 event_id, aggregate_kind, stream_id, stream_version, event_type,
223 occurred_at, actor_kind, payload_json, metadata_json, application_event_version
224 )
225 SELECT 'history:' || n, 'thread',
226 CASE WHEN n = 1 THEN 'target' ELSE 'other' END,
227 n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}',
228 CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END
229 FROM history
230 `;
231 const statements: Array<string> = [];
232 const tracer = Tracer.make({
233 span(options) {
234 const span = new Tracer.NativeSpan(options);
235 const end = span.end.bind(span);
236 span.end = (endTime, exit) => {
237 end(endTime, exit);
238 const query = span.attributes.get("db.query.text");
239 if (typeof query === "string") statements.push(query);
240 };
241 return span;
242 },
243 });
244 assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000);
245 assert.equal(
246 yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)),
247 1,
248 );
249 assert.equal(statements.length, 2);
250 const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>(
251 `EXPLAIN QUERY PLAN ${statements[0]}`,
252 );
253 const agentPlan = yield* sql.unsafe<{ readonly detail: string }>(
254 `EXPLAIN QUERY PLAN ${statements[1]}`,
255 ["target"],
256 );
257 assert.match(
258 applicationPlan.map((row) => row.detail).join("\n"),
259 /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/,
260 );
261 assert.match(
262 agentPlan.map((row) => row.detail).join("\n"),
263 /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/,
264 );
265 for (const row of [...applicationPlan, ...agentPlan]) {
266 assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/);
267 }
268 }).pipe(
269 Effect.provide(
270 OrchestrationEventStoreLayer.layer.pipe(
271 Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })),
272 ),
273 ),
274 ),
275);
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts deleted-522
......@@ -1,522 +0,0 @@
1import * as NodeV8 from "node:v8";
2
3import {
4 CommandId,
5 EventId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 TurnItemId,
10 type OrchestrationV2DomainEvent,
11} from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
13import * as DateTime from "effect/DateTime";
14import * as Deferred from "effect/Deferred";
15import * as Fiber from "effect/Fiber";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
20import * as SqlClient from "effect/sql/SqlClient";
21import * as Statement from "effect/sql/Statement";
22
23import {
24 LIVE_STREAM_MAX_ITEMS,
25 LiveStreamBufferError,
26} from "../../orchestration-v2/LiveStreamBudget.ts";
27import { PersistenceDecodeError } from "../Errors.ts";
28import { toShellApplicationEvent } from "../../orchestration-v2/ShellStream.ts";
29import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts";
30import * as OrchestrationEventStoreLayer from "./OrchestrationEventStore.ts";
31import * as SqlitePersistence from "./Sqlite.ts";
32const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
33const isLiveStreamBufferError = Schema.is(LiveStreamBufferError);
34
35const layerTest = OrchestrationEventStoreLayer.layer.pipe(
36 Layer.provideMerge(SqlitePersistence.layerMemory),
37);
38const layer = it.layer(layerTest);
39
40layer("OrchestrationEventStore", (it) => {
41 it.effect("retains only shell metadata from oversized replay and live application events", () =>
42 Effect.scoped(
43 Effect.gen(function* () {
44 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
45 const afterSequence = yield* store.latestApplicationSequence;
46 const now = yield* DateTime.now;
47 const threadId = ThreadId.make("thread:large-shell-body");
48 const input = "x".repeat(9 * 1024 * 1024);
49 const event: OrchestrationV2DomainEvent = {
50 id: EventId.make("event:large-shell-body:replay"),
51 type: "turn-item.updated",
52 threadId,
53 occurredAt: now,
54 payload: {
55 id: TurnItemId.make("tool:large-shell-body"),
56 type: "command_execution",
57 threadId,
58 runId: null,
59 nodeId: null,
60 providerThreadId: null,
61 providerTurnId: null,
62 nativeItemRef: null,
63 parentItemId: null,
64 ordinal: 1,
65 status: "running",
66 title: "Large command",
67 input,
68 startedAt: now,
69 completedAt: null,
70 updatedAt: now,
71 },
72 };
73 const [replayed] = yield* store.appendAgentEvents({ events: [event] });
74 const pull = yield* Stream.toPull(
75 store.streamProjectedApplicationEvents({
76 afterSequence,
77 project: toShellApplicationEvent,
78 }),
79 );
80 assert.deepEqual(yield* pull, [{ sequence: replayed!.sequence, event: { threadId } }]);
81 const [live] = yield* store.appendAgentEvents({
82 events: [
83 {
84 ...event,
85 id: EventId.make("event:large-shell-body:live"),
86 payload: { ...event.payload, status: "completed", completedAt: now },
87 },
88 ],
89 });
90 yield* store.publishCommitted([live!]);
91 assert.deepEqual(yield* pull, [{ sequence: live!.sequence, event: { threadId } }]);
92 const durable = yield* store
93 .readAgentEvents({ threadId, afterSequence })
94 .pipe(Stream.runCollect);
95 assert.lengthOf(durable, 2);
96 for (const stored of durable) {
97 assert.equal(stored.event.type, "turn-item.updated");
98 if (stored.event.type !== "turn-item.updated") return;
99 assert.equal(stored.event.payload.type, "command_execution");
100 if (stored.event.payload.type !== "command_execution") return;
101 assert.equal(stored.event.payload.input, input);
102 }
103 }),
104 ),
105 );
106
107 it.effect("stores json columns as strings and replays CLI-origin events", () =>
108 Effect.gen(function* () {
109 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
110 const sql = yield* SqlClient.SqlClient;
111 const now = "2026-01-01T00:00:00.000Z";
112
113 const appended = yield* eventStore.appendProjectEvent({
114 type: "project.created",
115 eventId: EventId.make("evt-store-roundtrip"),
116 aggregateKind: "project",
117 aggregateId: ProjectId.make("project-roundtrip"),
118 occurredAt: now,
119 commandId: CommandId.make("cmd-store-roundtrip"),
120 causationEventId: null,
121 correlationId: CommandId.make("cmd-store-roundtrip"),
122 metadata: {
123 adapterKey: "codex",
124 origin: {
125 surface: "cli",
126 },
127 },
128 payload: {
129 projectId: ProjectId.make("project-roundtrip"),
130 title: "Roundtrip Project",
131 workspaceRoot: "/tmp/project-roundtrip",
132 defaultModelSelection: null,
133 scripts: [],
134 createdAt: now,
135 updatedAt: now,
136 },
137 });
138
139 const storedRows = yield* sql<{
140 readonly payloadJson: string;
141 readonly metadataJson: string;
142 }>`
143 SELECT
144 payload_json AS "payloadJson",
145 metadata_json AS "metadataJson"
146 FROM orchestration_events
147 WHERE event_id = ${appended.eventId}
148 `;
149 assert.equal(storedRows.length, 1);
150 assert.equal(typeof storedRows[0]?.payloadJson, "string");
151 assert.equal(typeof storedRows[0]?.metadataJson, "string");
152
153 const replayed = yield* eventStore
154 .readApplicationEvents({
155 afterSequence: appended.sequence - 1,
156 throughSequence: appended.sequence,
157 })
158 .pipe(
159 Stream.runCollect,
160 Effect.map((chunk) => Array.from(chunk)),
161 );
162 assert.equal(replayed.length, 1);
163 const [project] = replayed;
164 if (project === undefined || !("aggregateKind" in project)) {
165 return assert.fail("Expected a project event");
166 }
167 assert.equal(project.type, "project.created");
168 assert.equal(project.metadata.adapterKey, "codex");
169 assert.deepEqual(project.metadata.origin, { surface: "cli" });
170 }),
171 );
172
173 it.effect("fails with PersistenceDecodeError when stored json is invalid", () =>
174 Effect.gen(function* () {
175 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
176 const sql = yield* SqlClient.SqlClient;
177 const now = "2026-01-01T00:00:00.000Z";
178
179 yield* sql`
180 INSERT INTO orchestration_events (
181 event_id,
182 aggregate_kind,
183 stream_id,
184 stream_version,
185 event_type,
186 occurred_at,
187 command_id,
188 causation_event_id,
189 correlation_id,
190 actor_kind,
191 payload_json,
192 metadata_json
193 )
194 VALUES (
195 ${EventId.make("evt-store-invalid-json")},
196 ${"project"},
197 ${ProjectId.make("project-invalid-json")},
198 ${0},
199 ${"project.created"},
200 ${now},
201 ${CommandId.make("cmd-store-invalid-json")},
202 ${null},
203 ${null},
204 ${"server"},
205 ${"{"},
206 ${"{}"}
207 )
208 `;
209
210 const replayResult = yield* Effect.result(
211 Stream.runCollect(
212 eventStore.readApplicationEvents({
213 afterSequence: 0,
214 throughSequence: Number.MAX_SAFE_INTEGER,
215 }),
216 ),
217 );
218 assert.equal(replayResult._tag, "Failure");
219 if (replayResult._tag === "Failure") {
220 assert.ok(isPersistenceDecodeError(replayResult.failure));
221 }
222 }),
223 );
224
225 it.effect("orders project and V2 agent events in the retained application event source", () =>
226 Effect.gen(function* () {
227 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
228 const projectId = ProjectId.make("project-shared-stream");
229 const threadId = ThreadId.make("thread-shared-stream");
230 const providerInstanceId = ProviderInstanceId.make("codex");
231 const occurredAt = DateTime.makeUnsafe("2026-01-02T00:00:00.000Z");
232 const now = DateTime.formatIso(occurredAt);
233 const baselineSequence = yield* eventStore.latestApplicationSequence;
234
235 const projectEvent = yield* eventStore.appendProjectEvent({
236 type: "project.created",
237 eventId: EventId.make("event-project-shared-stream"),
238 aggregateKind: "project",
239 aggregateId: projectId,
240 occurredAt: now,
241 commandId: CommandId.make("command-project-shared-stream"),
242 causationEventId: null,
243 correlationId: CommandId.make("command-project-shared-stream"),
244 metadata: {},
245 payload: {
246 projectId,
247 title: "Shared stream",
248 workspaceRoot: "/tmp/shared-stream",
249 defaultModelSelection: null,
250 scripts: [],
251 createdAt: now,
252 updatedAt: now,
253 },
254 });
255 const [threadEvent] = yield* eventStore.appendAgentEvents({
256 commandId: CommandId.make("command-thread-shared-stream"),
257 events: [
258 {
259 id: EventId.make("event-thread-shared-stream"),
260 type: "thread.created",
261 threadId,
262 providerInstanceId,
263 occurredAt,
264 payload: {
265 id: threadId,
266 projectId,
267 title: "Thread",
268 providerInstanceId,
269 modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
270 runtimeMode: "full-access",
271 interactionMode: "default",
272 branch: null,
273 worktreePath: null,
274 activeProviderThreadId: null,
275 lineage: {
276 rootThreadId: threadId,
277 parentThreadId: null,
278 relationshipToParent: null,
279 },
280 forkedFrom: null,
281 createdBy: "user",
282 creationSource: "web",
283 createdAt: occurredAt,
284 updatedAt: occurredAt,
285 archivedAt: null,
286 settledOverride: null,
287 settledAt: null,
288 lastVisitedAt: null,
289 deletedAt: null,
290 },
291 },
292 ],
293 });
294
295 const applicationEvents = yield* eventStore
296 .streamApplicationEvents({ afterSequence: baselineSequence })
297 .pipe(
298 Stream.take(2),
299 Stream.runCollect,
300 Effect.map((chunk) => Array.from(chunk)),
301 );
302 assert.deepEqual(
303 applicationEvents.map((event) => event.sequence),
304 [projectEvent.sequence, threadEvent!.sequence],
305 );
306 assert.isTrue("aggregateKind" in applicationEvents[0]!);
307 assert.isTrue("event" in applicationEvents[1]!);
308
309 const finiteReplay = yield* eventStore
310 .readApplicationEvents({
311 afterSequence: baselineSequence,
312 throughSequence: threadEvent!.sequence,
313 })
314 .pipe(
315 Stream.runCollect,
316 Effect.map((chunk) => Array.from(chunk)),
317 );
318 assert.deepEqual(
319 finiteReplay.map((event) => event.sequence),
320 [projectEvent.sequence, threadEvent!.sequence],
321 );
322 }),
323 );
324 it.effect("measures only a bounded thread replay before decoding its payloads", () =>
325 Effect.gen(function* () {
326 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
327 const sql = yield* SqlClient.SqlClient;
328 const threadId = ThreadId.make("measured-replay-thread");
329 const baseline = yield* store.latestApplicationSequence;
330 const unicodePayload = '{"text":"🦊"}';
331 const oversizedPayload = "{" + "x".repeat(1_048_576);
332 let streamVersion = 0;
333 const insert = Effect.fn(function* (input: {
334 readonly id: string;
335 readonly payload: string;
336 readonly type?: string;
337 readonly threadId?: string;
338 readonly version?: number;
339 }) {
340 const rows = yield* sql<{ readonly sequence: number }>`
341 INSERT INTO orchestration_events (
342 event_id, aggregate_kind, stream_id, stream_version, event_type,
343 occurred_at, actor_kind, payload_json, metadata_json, application_event_version
344 ) VALUES (
345 ${input.id}, 'thread', ${input.threadId ?? threadId},
346 ${streamVersion++}, ${input.type ?? "thread.metadata-updated"},
347 '2026-09-04T00:00:00.000Z', 'server', ${input.payload}, '{}', ${input.version ?? 2}
348 ) RETURNING sequence
349 `;
350 return rows[0]!.sequence;
351 });
352 const first = yield* insert({
353 id: "measured-replay-1",
354 payload: unicodePayload,
355 type: "thread.created",
356 });
357 yield* insert({
358 id: "measured-unrelated",
359 threadId: "other-replay-thread",
360 payload: oversizedPayload,
361 });
362 yield* insert({ id: "measured-legacy", payload: oversizedPayload, version: 1 });
363 const head = yield* insert({ id: "measured-replay-02", payload: oversizedPayload });
364 const later = yield* insert({ id: "measured-replay-003", payload: "{}" });
365 const range = { threadId, afterSequence: baseline, throughSequence: head, maxEvents: 128 };
366 assert.deepEqual(yield* store.getAgentReplayStats(range), {
367 eventCount: 2,
368 rawPayloadBytes: Buffer.byteLength(unicodePayload) + Buffer.byteLength(oversizedPayload),
369 hasCreateEvent: true,
370 });
371 assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: first }), {
372 eventCount: 1,
373 rawPayloadBytes: Buffer.byteLength(oversizedPayload),
374 hasCreateEvent: false,
375 });
376 assert.equal(
377 (yield* store.getAgentReplayStats({ ...range, throughSequence: later, maxEvents: 1 }))
378 .eventCount,
379 2,
380 );
381 assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: later }), {
382 eventCount: 0,
383 rawPayloadBytes: 0,
384 hasCreateEvent: false,
385 });
386 // The application range skips V1 rows, which a shell resume never replays.
387 assert.deepEqual(
388 yield* store.getReplayStats({ afterSequence: baseline, throughSequence: head }),
389 {
390 eventCount: 3,
391 rawPayloadBytes:
392 Buffer.byteLength(unicodePayload) + 2 * Buffer.byteLength(oversizedPayload),
393 },
394 );
395 }),
396 );
397});
398
399it.effect.each(["high-water", "replay"] as const)(
400 "bounds application live events while the %s query is blocked",
401 (phase) =>
402 Effect.scoped(
403 Effect.gen(function* () {
404 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
405 const now = "2026-01-03T00:00:00.000Z";
406 const projectId = ProjectId.make(`project:blocked-${phase}`);
407 const projectEvent = yield* store.appendProjectEvent({
408 type: "project.created",
409 eventId: EventId.make(`event:blocked-${phase}:created`),
410 aggregateKind: "project",
411 aggregateId: projectId,
412 occurredAt: now,
413 commandId: CommandId.make(`command:blocked-${phase}`),
414 causationEventId: null,
415 correlationId: null,
416 metadata: {},
417 payload: {
418 projectId,
419 title: "Blocked stream",
420 workspaceRoot: "/tmp/blocked-stream",
421 defaultModelSelection: null,
422 scripts: [],
423 createdAt: now,
424 updatedAt: now,
425 },
426 });
427 if (projectEvent.type !== "project.created")
428 return yield* Effect.die("Expected project.created");
429 const readStarted = yield* Deferred.make<void>();
430 const readClosed = yield* Deferred.make<void>();
431 const blockRead: Statement.Transformer = (statement) => {
432 const [query] = statement.compile();
433 if (
434 query.includes("FROM orchestration_events") &&
435 query.includes("MAX(sequence)") === (phase === "high-water")
436 ) {
437 return Deferred.succeed(readStarted, undefined).pipe(
438 Effect.andThen(Effect.never),
439 Effect.ensuring(Deferred.succeed(readClosed, undefined)),
440 Effect.as(statement),
441 );
442 }
443 return Effect.succeed(statement);
444 };
445 const reader = yield* store
446 .streamApplicationEvents({ afterSequence: projectEvent.sequence })
447 .pipe(
448 Stream.provideService(Statement.CurrentTransformer, blockRead),
449 Stream.runDrain,
450 Effect.result,
451 Effect.forkScoped,
452 );
453 yield* Deferred.await(readStarted);
454 yield* store.publishCommitted(
455 Array.from({ length: LIVE_STREAM_MAX_ITEMS + 1 }, (_, index) => ({
456 ...projectEvent,
457 eventId: EventId.make(`event:blocked-${phase}:${index}`),
458 sequence: projectEvent.sequence + index + 1,
459 })),
460 );
461 yield* Deferred.await(readClosed);
462 const result = yield* Fiber.join(reader);
463 assert.equal(result._tag, "Failure");
464 if (result._tag === "Failure") {
465 assert.equal(result.failure._tag, "PersistenceSqlError");
466 assert.isTrue(isLiveStreamBufferError(result.failure.cause));
467 }
468 }),
469 ).pipe(Effect.provide(Layer.fresh(layerTest))),
470);
471
472it.effect("releases consumed application replay pages", () =>
473 Effect.gen(function* () {
474 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
475 const afterSequence = yield* store.latestApplicationSequence;
476 yield* Effect.forEach(
477 Array.from({ length: 1501 }, (_, index) => index),
478 (index) =>
479 store.appendProjectEvent({
480 type: "project.created",
481 eventId: EventId.make(`retention-${index}`),
482 aggregateKind: "project",
483 aggregateId: ProjectId.make(`retention-${index}`),
484 occurredAt: "2026-09-08T00:00:00Z",
485 commandId: null,
486 causationEventId: null,
487 correlationId: null,
488 metadata: {},
489 payload: {
490 projectId: ProjectId.make(`retention-${index}`),
491 title: "Replay",
492 workspaceRoot: "/tmp/replay",
493 defaultModelSelection: null,
494 scripts: [],
495 createdAt: "2026-09-08T00:00:00Z",
496 updatedAt: "2026-09-08T00:00:00Z",
497 },
498 }),
499 { discard: true },
500 );
501 // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC.
502 class ReplayPage {}
503 let count = 0;
504 yield* store
505 .readApplicationEvents({
506 afterSequence,
507 throughSequence: yield* store.latestApplicationSequence,
508 })
509 .pipe(
510 Stream.runForEach((event) =>
511 Effect.sync(() => {
512 if (count % 500 === 0) {
513 Object.assign(event, { replayPage: new ReplayPage() });
514 assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1);
515 }
516 count++;
517 }),
518 ),
519 );
520 assert.equal(count, 1501);
521 }).pipe(Effect.provide(layerTest)),
522);
apps/server/src/persistence/Layers/OrchestrationEventStore.ts deleted-609
......@@ -1,609 +0,0 @@
1import {
2 ApplicationEventMetadata,
3 ApplicationProjectEvent,
4 type ApplicationStoredEvent,
5 CommandId,
6 EventId,
7 IsoDateTime,
8 NonNegativeInt,
9 OrchestrationV2DomainEventJson,
10 OrchestrationV2StoredEvent,
11 ProjectId,
12 StoredProjectIcon,
13 ThreadId,
14 type OrchestrationV2DomainEvent,
15} from "@t3tools/contracts";
16import * as SqlClient from "effect/sql/SqlClient";
17import * as SqlSchema from "effect/sql/SqlSchema";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as PubSub from "effect/PubSub";
21import * as Option from "effect/Option";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24
25import { replayAndBufferProjectedLiveEvents } from "../../orchestration-v2/LiveStreamBudget.ts";
26
27import {
28 toPersistenceDecodeError,
29 toPersistenceSqlError,
30 type OrchestrationEventStoreError,
31} from "../Errors.ts";
32import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts";
33
34const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon);
35const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent);
36const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
37const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata);
38const ProjectEventType = Schema.Literals([
39 "project.created",
40 "project.meta-updated",
41 "project.deleted",
42]);
43const ActorKind = Schema.Literals(["client", "server", "provider"]);
44
45const AppendProjectEventRequestSchema = Schema.Struct({
46 eventId: EventId,
47 streamId: ProjectId,
48 type: ProjectEventType,
49 causationEventId: Schema.NullOr(EventId),
50 correlationId: Schema.NullOr(CommandId),
51 actorKind: ActorKind,
52 occurredAt: IsoDateTime,
53 commandId: Schema.NullOr(CommandId),
54 payloadJson: UnknownFromJsonString,
55 metadataJson: EventMetadataFromJsonString,
56});
57
58const ProjectEventPersistedRowSchema = Schema.Struct({
59 sequence: NonNegativeInt,
60 eventId: EventId,
61 type: ProjectEventType,
62 aggregateKind: Schema.Literal("project"),
63 aggregateId: ProjectId,
64 occurredAt: IsoDateTime,
65 commandId: Schema.NullOr(CommandId),
66 causationEventId: Schema.NullOr(EventId),
67 correlationId: Schema.NullOr(CommandId),
68 payload: UnknownFromJsonString,
69 metadata: EventMetadataFromJsonString,
70});
71
72const READ_PAGE_SIZE = 500;
73
74interface ApplicationEventRow {
75 readonly sequence: number;
76 readonly event_id: string;
77 readonly command_id: string | null;
78 readonly aggregate_kind: "project" | "thread";
79 readonly stream_id: string;
80 readonly event_type: string;
81 readonly occurred_at: string;
82 readonly payload_json: string;
83 readonly metadata_json: string;
84 readonly application_event_version: number;
85 readonly causation_event_id: string | null;
86 readonly correlation_id: string | null;
87}
88
89const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson);
90const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson);
91const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent);
92const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
93const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
94
95function metadataForV2Event(event: OrchestrationV2DomainEvent): Record<string, unknown> {
96 return {
97 ...(event.runId === undefined ? {} : { runId: event.runId }),
98 ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
99 ...(event.driver === undefined ? {} : { driver: event.driver }),
100 ...(event.providerInstanceId === undefined
101 ? {}
102 : { providerInstanceId: event.providerInstanceId }),
103 ...(event.rawEventId === undefined ? {} : { rawEventId: event.rawEventId }),
104 };
105}
106
107const rowToV2StoredEvent = Effect.fn("OrchestrationEventStore.rowToV2StoredEvent")(function* (
108 row: ApplicationEventRow,
109) {
110 const payload = yield* decodeJson(row.payload_json);
111 const metadata = yield* decodeJson(row.metadata_json);
112 const values =
113 typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>) : {};
114 const event = yield* decodeV2EventJson({
115 id: row.event_id,
116 threadId: row.stream_id,
117 type: row.event_type,
118 occurredAt: row.occurred_at,
119 payload,
120 ...(values.runId === undefined ? {} : { runId: values.runId }),
121 ...(values.nodeId === undefined ? {} : { nodeId: values.nodeId }),
122 ...(values.driver === undefined ? {} : { driver: values.driver }),
123 ...(values.providerInstanceId === undefined
124 ? {}
125 : { providerInstanceId: values.providerInstanceId }),
126 ...(values.rawEventId === undefined ? {} : { rawEventId: values.rawEventId }),
127 });
128 return yield* decodeV2StoredEvent({
129 sequence: row.sequence,
130 commandId: row.command_id,
131 event,
132 });
133});
134
135const rowToProjectEvent = Effect.fn("OrchestrationEventStore.rowToProjectEvent")(function* (
136 row: ApplicationEventRow,
137) {
138 return yield* decodeProjectEvent({
139 sequence: row.sequence,
140 eventId: row.event_id,
141 type: row.event_type,
142 aggregateKind: row.aggregate_kind,
143 aggregateId: row.stream_id,
144 occurredAt: row.occurred_at,
145 commandId: row.command_id,
146 causationEventId: row.causation_event_id,
147 correlationId: row.correlation_id,
148 payload: yield* decodeJson(row.payload_json),
149 metadata: yield* decodeJson(row.metadata_json),
150 });
151});
152
153function rowToApplicationStoredEvent(
154 row: ApplicationEventRow,
155): Effect.Effect<ApplicationStoredEvent, Schema.SchemaError> {
156 return row.aggregate_kind === "project" ? rowToProjectEvent(row) : rowToV2StoredEvent(row);
157}
158
159function inferActorKind(
160 event: OrchestrationEventStore.UnsequencedProjectEvent,
161): typeof ActorKind.Type {
162 if (event.commandId !== null && event.commandId.startsWith("provider:")) {
163 return "provider";
164 }
165 if (event.commandId !== null && event.commandId.startsWith("server:")) {
166 return "server";
167 }
168 if (
169 event.metadata.providerTurnId !== undefined ||
170 event.metadata.providerItemId !== undefined ||
171 event.metadata.adapterKey !== undefined
172 ) {
173 return "provider";
174 }
175 if (event.commandId === null) {
176 return "server";
177 }
178 return "client";
179}
180
181function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
182 return (cause: unknown): OrchestrationEventStoreError =>
183 Schema.isSchemaError(cause)
184 ? toPersistenceDecodeError(decodeOperation)(cause)
185 : toPersistenceSqlError(sqlOperation)(cause);
186}
187
188const makeEventStore = Effect.gen(function* () {
189 const sql = yield* SqlClient.SqlClient;
190 const committedEvents = yield* PubSub.unbounded<ApplicationStoredEvent>();
191
192 const appendProjectEventRow = SqlSchema.findOne({
193 Request: AppendProjectEventRequestSchema,
194 Result: ProjectEventPersistedRowSchema,
195 execute: (request) =>
196 sql`
197 INSERT INTO orchestration_events (
198 event_id,
199 aggregate_kind,
200 stream_id,
201 stream_version,
202 event_type,
203 occurred_at,
204 command_id,
205 causation_event_id,
206 correlation_id,
207 actor_kind,
208 payload_json,
209 metadata_json,
210 application_event_version
211 )
212 VALUES (
213 ${request.eventId},
214 'project',
215 ${request.streamId},
216 COALESCE(
217 (
218 SELECT stream_version + 1
219 FROM orchestration_events
220 WHERE aggregate_kind = 'project'
221 AND stream_id = ${request.streamId}
222 ORDER BY stream_version DESC
223 LIMIT 1
224 ),
225 0
226 ),
227 ${request.type},
228 ${request.occurredAt},
229 ${request.commandId},
230 ${request.causationEventId},
231 ${request.correlationId},
232 ${request.actorKind},
233 ${request.payloadJson},
234 ${request.metadataJson},
235 2
236 )
237 RETURNING
238 sequence,
239 event_id AS "eventId",
240 event_type AS "type",
241 aggregate_kind AS "aggregateKind",
242 stream_id AS "aggregateId",
243 occurred_at AS "occurredAt",
244 command_id AS "commandId",
245 causation_event_id AS "causationEventId",
246 correlation_id AS "correlationId",
247 payload_json AS "payload",
248 metadata_json AS "metadata"
249 `,
250 });
251
252 const appendProjectEvent: OrchestrationEventStore.OrchestrationEventStoreShape["appendProjectEvent"] =
253 (event) =>
254 appendProjectEventRow({
255 eventId: event.eventId,
256 streamId: event.aggregateId,
257 type: event.type,
258 causationEventId: event.causationEventId,
259 correlationId: event.correlationId,
260 actorKind: inferActorKind(event),
261 occurredAt: event.occurredAt,
262 commandId: event.commandId,
263 payloadJson:
264 event.type === "project.deleted" || !event.payload.projectIcon
265 ? event.payload
266 : { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) },
267 metadataJson: event.metadata,
268 }).pipe(
269 Effect.flatMap((row) => decodeProjectEvent(row)),
270 Effect.mapError(
271 toPersistenceSqlOrDecodeError(
272 "OrchestrationEventStore.appendProjectEvent:insert",
273 "OrchestrationEventStore.appendProjectEvent:decode",
274 ),
275 ),
276 );
277
278 const readApplicationRows = (input: {
279 readonly afterSequence: number;
280 readonly throughSequence?: number;
281 readonly threadId?: ThreadId;
282 readonly commandId?: CommandId;
283 readonly eventType?: OrchestrationV2DomainEvent["type"];
284 readonly onlyAgentEvents?: boolean;
285 readonly limit: number;
286 }) =>
287 sql<ApplicationEventRow>`
288 SELECT
289 sequence,
290 event_id,
291 command_id,
292 aggregate_kind,
293 stream_id,
294 event_type,
295 occurred_at,
296 payload_json,
297 metadata_json,
298 application_event_version,
299 causation_event_id,
300 correlation_id
301 FROM orchestration_events
302 ${
303 input.onlyAgentEvents === true
304 ? sql``
305 : sql`INDEXED BY idx_orchestration_events_application_high_water`
306 }
307 WHERE sequence > ${input.afterSequence}
308 AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER}
309 AND (
310 ${
311 input.onlyAgentEvents === true
312 ? sql`application_event_version = 2 AND aggregate_kind = 'thread'`
313 : sql`aggregate_kind = 'project'
314 OR (application_event_version = 2 AND aggregate_kind = 'thread')`
315 }
316 )
317 AND ${sql.and([
318 ...(input.threadId === undefined ? [] : [sql`stream_id = ${input.threadId}`]),
319 ...(input.commandId === undefined ? [] : [sql`command_id = ${input.commandId}`]),
320 ...(input.eventType === undefined ? [] : [sql`event_type = ${input.eventType}`]),
321 ])}
322 ORDER BY sequence ASC
323 LIMIT ${input.limit}
324 `;
325
326 const appendAgentEvents: OrchestrationEventStore.OrchestrationEventStoreShape["appendAgentEvents"] =
327 (input) =>
328 Effect.forEach(
329 input.events,
330 (event) =>
331 Effect.gen(function* () {
332 const encoded = yield* encodeV2EventJson(event);
333 const rows = yield* sql<{ readonly sequence: number }>`
334 INSERT INTO orchestration_events (
335 event_id,
336 aggregate_kind,
337 stream_id,
338 stream_version,
339 event_type,
340 occurred_at,
341 command_id,
342 causation_event_id,
343 correlation_id,
344 actor_kind,
345 payload_json,
346 metadata_json,
347 application_event_version
348 )
349 VALUES (
350 ${event.id},
351 'thread',
352 ${event.threadId},
353 COALESCE(
354 (
355 SELECT MAX(stream_version) + 1
356 FROM orchestration_events
357 WHERE aggregate_kind = 'thread' AND stream_id = ${event.threadId}
358 ),
359 0
360 ),
361 ${event.type},
362 ${encoded.occurredAt},
363 ${input.commandId ?? null},
364 NULL,
365 ${input.commandId ?? null},
366 ${event.rawEventId === undefined ? "server" : "provider"},
367 ${yield* encodeJson(encoded.payload)},
368 ${yield* encodeJson(metadataForV2Event(event))},
369 2
370 )
371 RETURNING sequence
372 `;
373 return yield* decodeV2StoredEvent({
374 sequence: rows[0]?.sequence,
375 commandId: input.commandId ?? null,
376 event,
377 });
378 }),
379 { concurrency: 1 },
380 ).pipe(
381 Effect.mapError(
382 toPersistenceSqlOrDecodeError(
383 "OrchestrationEventStore.appendAgentEvents:insert",
384 "OrchestrationEventStore.appendAgentEvents:decode",
385 ),
386 ),
387 );
388
389 const readAgentEvents: OrchestrationEventStore.OrchestrationEventStoreShape["readAgentEvents"] = (
390 input,
391 ) => {
392 const totalLimit =
393 input?.limit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(input.limit));
394 if (totalLimit === 0) {
395 return Stream.empty;
396 }
397 return Stream.paginate(
398 { cursor: input?.afterSequence ?? 0, remaining: totalLimit },
399 ({ cursor, remaining }) => {
400 const pageLimit = Math.min(remaining, READ_PAGE_SIZE);
401 return readApplicationRows({
402 afterSequence: cursor,
403 ...(input?.throughSequence === undefined
404 ? {}
405 : { throughSequence: input.throughSequence }),
406 ...(input?.threadId === undefined ? {} : { threadId: input.threadId }),
407 ...(input?.commandId === undefined ? {} : { commandId: input.commandId }),
408 ...(input?.eventType === undefined ? {} : { eventType: input.eventType }),
409 onlyAgentEvents: true,
410 limit: pageLimit,
411 }).pipe(
412 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readAgentEvents:query")),
413 Effect.map((rows) => {
414 const last = rows.at(-1);
415 const nextRemaining = remaining - rows.length;
416 return [
417 rows,
418 last === undefined ||
419 rows.length < pageLimit ||
420 nextRemaining <= 0 ||
421 (input?.throughSequence !== undefined && last.sequence >= input.throughSequence)
422 ? Option.none()
423 : Option.some({ cursor: last.sequence, remaining: nextRemaining }),
424 ] as const;
425 }),
426 );
427 },
428 ).pipe(
429 Stream.mapEffect((row) =>
430 rowToV2StoredEvent(row).pipe(
431 Effect.mapError(
432 toPersistenceDecodeError("OrchestrationEventStore.readAgentEvents:decode"),
433 ),
434 ),
435 ),
436 );
437 };
438
439 const getAgentReplayStats: OrchestrationEventStore.OrchestrationEventStoreShape["getAgentReplayStats"] =
440 (input) =>
441 sql<{
442 readonly eventCount: number;
443 readonly rawPayloadBytes: number;
444 readonly hasCreateEvent: number;
445 }>`
446 SELECT
447 COUNT(*) AS "eventCount",
448 COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes",
449 COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent"
450 FROM (
451 SELECT payload_json, event_type
452 FROM orchestration_events
453 WHERE aggregate_kind = 'thread'
454 AND stream_id = ${input.threadId}
455 AND application_event_version = 2
456 AND sequence > ${input.afterSequence}
457 AND sequence <= ${input.throughSequence}
458 ORDER BY sequence ASC
459 LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1}
460 )
461 `.pipe(
462 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")),
463 Effect.map((rows) => ({
464 eventCount: rows[0]?.eventCount ?? 0,
465 rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0,
466 hasCreateEvent: (rows[0]?.hasCreateEvent ?? 0) !== 0,
467 })),
468 );
469
470 const getReplayStats: OrchestrationEventStore.OrchestrationEventStoreShape["getReplayStats"] = (
471 input,
472 ) =>
473 sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>`
474 SELECT
475 COUNT(*) AS "eventCount",
476 COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes"
477 FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water
478 WHERE sequence > ${input.afterSequence}
479 AND sequence <= ${input.throughSequence}
480 AND (
481 aggregate_kind = 'project'
482 OR (application_event_version = 2 AND aggregate_kind = 'thread')
483 )
484 `.pipe(
485 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")),
486 Effect.map((rows) => ({
487 eventCount: rows[0]?.eventCount ?? 0,
488 rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0,
489 })),
490 );
491
492 const latestAgentSequence: OrchestrationEventStore.OrchestrationEventStoreShape["latestAgentSequence"] =
493 (threadId) =>
494 sql<{ readonly sequence: number | null }>`
495 SELECT MAX(sequence) AS sequence
496 FROM orchestration_events
497 ${
498 threadId === undefined
499 ? sql``
500 : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence`
501 }
502 WHERE application_event_version = 2
503 AND aggregate_kind = 'thread'
504 ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`}
505 `.pipe(
506 Effect.map((rows) => rows[0]?.sequence ?? 0),
507 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")),
508 );
509
510 // The OR planner otherwise scans every V2 event instead of seeking the final sequence.
511 const latestApplicationSequence = sql<{ readonly sequence: number | null }>`
512 SELECT MAX(sequence) AS sequence
513 FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water
514 WHERE aggregate_kind = 'project'
515 OR (application_event_version = 2 AND aggregate_kind = 'thread')
516 `.pipe(
517 Effect.map((rows) => rows[0]?.sequence ?? 0),
518 Effect.mapError(
519 toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"),
520 ),
521 );
522
523 const readApplicationEventPage = (input: {
524 readonly afterSequence: number;
525 readonly throughSequence: number;
526 readonly limit: number;
527 }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> =>
528 Stream.fromEffect(
529 readApplicationRows(input).pipe(
530 Effect.mapError(
531 toPersistenceSqlError("OrchestrationEventStore.readApplicationEvents:query"),
532 ),
533 ),
534 ).pipe(
535 Stream.flatMap(Stream.fromIterable),
536 Stream.mapEffect((row) =>
537 rowToApplicationStoredEvent(row).pipe(
538 Effect.mapError(
539 toPersistenceDecodeError("OrchestrationEventStore.readApplicationEvents:decode"),
540 ),
541 ),
542 ),
543 );
544
545 const catchUpApplicationEvents = (input: {
546 readonly afterSequence: number;
547 readonly throughSequence: number;
548 }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> => {
549 return Stream.paginate(input.afterSequence, (afterSequence) =>
550 readApplicationEventPage({
551 afterSequence,
552 throughSequence: input.throughSequence,
553 limit: READ_PAGE_SIZE,
554 }).pipe(
555 Stream.runCollect,
556 Effect.map((events) => {
557 const last = events.at(-1);
558 return [
559 events,
560 last === undefined ||
561 events.length < READ_PAGE_SIZE ||
562 last.sequence >= input.throughSequence
563 ? Option.none()
564 : Option.some(last.sequence),
565 ] as const;
566 }),
567 ),
568 );
569 };
570
571 const streamProjectedApplicationEvents: OrchestrationEventStore.OrchestrationEventStoreShape["streamProjectedApplicationEvents"] =
572 (input) =>
573 replayAndBufferProjectedLiveEvents({
574 subscribe: PubSub.subscribe(committedEvents),
575 latestSequence: latestApplicationSequence,
576 afterSequence: input.afterSequence ?? 0,
577 project: input.project,
578 replay: (throughSequence) =>
579 catchUpApplicationEvents({
580 afterSequence: input?.afterSequence ?? 0,
581 throughSequence,
582 }),
583 }).pipe(
584 Stream.catchTag("LiveStreamBufferError", (cause) =>
585 Stream.fail(
586 toPersistenceSqlError("OrchestrationEventStore.streamApplicationEvents:buffer")(cause),
587 ),
588 ),
589 );
590
591 const streamApplicationEvents: OrchestrationEventStore.OrchestrationEventStoreShape["streamApplicationEvents"] =
592 (input) => streamProjectedApplicationEvents({ ...input, project: (event) => event });
593
594 return {
595 appendProjectEvent,
596 appendAgentEvents,
597 readAgentEvents,
598 getAgentReplayStats,
599 getReplayStats,
600 latestAgentSequence,
601 latestApplicationSequence,
602 readApplicationEvents: catchUpApplicationEvents,
603 publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid),
604 streamApplicationEvents,
605 streamProjectedApplicationEvents,
606 } satisfies OrchestrationEventStore.OrchestrationEventStoreShape;
607});
608
609export const layer = Layer.effect(OrchestrationEventStore.OrchestrationEventStore, makeEventStore);
apps/server/src/persistence/Layers/Sqlite.test.ts deleted-93
......@@ -1,93 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as SqlClient from "effect/sql/SqlClient";
12
13import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts";
14import * as SqlitePersistence from "./Sqlite.ts";
15
16const lockHolderSource = `
17const { DatabaseSync } = require("node:sqlite");
18const db = new DatabaseSync(process.argv[1]);
19db.exec("BEGIN IMMEDIATE");
20process.stdout.write("locked\\n");
21setTimeout(() => {
22 db.exec("COMMIT");
23 db.close();
24}, Number(process.argv[2]));
25`;
26
27const spawnWriteLockHolder = (dbPath: string, holdMs: number) =>
28 Effect.promise(
29 () =>
30 new Promise<void>((resolve, reject) => {
31 const holder = NodeChildProcess.spawn(
32 process.execPath,
33 ["-e", lockHolderSource, dbPath, String(holdMs)],
34 { stdio: ["ignore", "pipe", "ignore"] },
35 );
36 holder.stdout.once("data", () => resolve());
37 holder.on("error", reject);
38 holder.on("exit", () =>
39 reject(new Error("lock holder exited before acquiring the write lock")),
40 );
41 }),
42 );
43
44it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => {
45 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-"));
46 const dbPath = NodePath.join(tempDir, "state.sqlite");
47
48 return Effect.gen(function* () {
49 const sql = yield* SqlClient.SqlClient;
50 yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`;
51 yield* spawnWriteLockHolder(dbPath, 300);
52 yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`;
53 const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`;
54 assert.deepEqual([...rows], [{ id: 1 }]);
55 }).pipe(
56 Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))),
57 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
58 );
59});
60
61it.effect("shrinks the WAL file back to the size limit after a large write", () => {
62 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-"));
63 const dbPath = NodePath.join(tempDir, "state.sqlite");
64 const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size;
65 // About 25% more 4 KB rows than the limit holds, in one transaction.
66 const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000);
67
68 return Effect.gen(function* () {
69 const sql = yield* SqlClient.SqlClient;
70 yield* sql`CREATE TABLE wal_probe(payload BLOB)`;
71 yield* sql`
72 WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount})
73 INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n
74 `;
75 assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES);
76
77 // The auto-checkpoint after the large commit copied every frame into the
78 // database, so the next commit restarts the WAL and cuts the file back.
79 yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`;
80 assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES);
81 }).pipe(
82 Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))),
83 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
84 );
85});
86
87it.effect("applies busy_timeout in the shared persistence setup", () =>
88 Effect.gen(function* () {
89 const sql = yield* SqlClient.SqlClient;
90 const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`;
91 assert.equal(rows[0]?.timeout, 5000);
92 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
93);
apps/server/src/persistence/Layers/Sqlite.ts deleted-57
......@@ -1,57 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as SqlClient from "effect/sql/SqlClient";
6import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
7
8import { runMigrations } from "../Migrations.ts";
9import { initializeV2Database } from "../initializeV2Database.ts";
10import * as ServerConfig from "../../config.ts";
11
12// Size the -wal file is cut back to on the first commit after a WAL reset.
13export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024;
14
15const layerSetup = Layer.effectDiscard(
16 Effect.gen(function* () {
17 const sql = yield* SqlClient.SqlClient;
18 // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY.
19 yield* sql`PRAGMA busy_timeout = 5000;`;
20 yield* sql`PRAGMA foreign_keys = ON;`;
21 yield* sql`PRAGMA journal_mode = WAL;`;
22 // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its
23 // largest size until the last connection closes.
24 yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`);
25 yield* runMigrations();
26 }),
27);
28
29export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) {
30 const fs = yield* FileSystem.FileSystem;
31 const path = yield* Path.Path;
32 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
33
34 return Layer.provideMerge(
35 layerSetup,
36 NodeSqliteClient.layer({
37 filename: dbPath,
38 spanAttributes: {
39 "db.name": path.basename(dbPath),
40 "service.name": "t3code-server",
41 },
42 }),
43 );
44}, Layer.unwrap);
45
46export const layerMemory = Layer.provideMerge(
47 layerSetup,
48 NodeSqliteClient.layer({ filename: ":memory:" }),
49);
50
51export const layerConfig = Layer.unwrap(
52 Effect.gen(function* () {
53 const { dbPath } = yield* ServerConfig.ServerConfig;
54 yield* initializeV2Database(dbPath);
55 return layerFromPath(dbPath);
56 }),
57);
apps/server/src/persistence/OrchestrationCommandReceipts.ts created+186
......@@ -0,0 +1,186 @@
1/**
2 * OrchestrationCommandReceiptRepository - Repository interface for command receipts.
3 *
4 * Owns persistence operations for deduplication and status tracking of
5 * orchestration command handling.
6 *
7 * @module OrchestrationCommandReceiptRepository
8 */
9import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts";
10import * as SqlClient from "effect/sql/SqlClient";
11import * as SqlSchema from "effect/sql/SqlSchema";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17
18import {
19 toPersistenceSqlError,
20 type OrchestrationCommandReceiptRepositoryError,
21} from "./Errors.ts";
22
23export const OrchestrationCommandReceipt = Schema.Struct({
24 commandId: CommandId,
25 aggregateKind: Schema.Literals(["project", "thread"]),
26 aggregateId: Schema.Union([ProjectId, ThreadId]),
27 commandType: Schema.String,
28 acceptedAt: IsoDateTime,
29 resultSequence: NonNegativeInt,
30 status: Schema.Literals(["accepted", "rejected"]),
31 error: Schema.NullOr(Schema.String),
32});
33export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type;
34
35export const GetByCommandIdInput = Schema.Struct({
36 commandId: CommandId,
37});
38export type GetByCommandIdInput = typeof GetByCommandIdInput.Type;
39
40/**
41 * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence.
42 */
43export class OrchestrationCommandReceiptRepository extends Context.Service<
44 OrchestrationCommandReceiptRepository,
45 {
46 readonly insertIfAbsent: (
47 receipt: OrchestrationCommandReceipt,
48 ) => Effect.Effect<boolean, OrchestrationCommandReceiptRepositoryError>;
49
50 /**
51 * Insert or replace a command receipt row.
52 *
53 * Upserts by `commandId` for idempotent command-result tracking.
54 */
55 readonly upsert: (
56 receipt: OrchestrationCommandReceipt,
57 ) => Effect.Effect<void, OrchestrationCommandReceiptRepositoryError>;
58
59 /**
60 * Read a command receipt by command id.
61 */
62 readonly getByCommandId: (
63 input: GetByCommandIdInput,
64 ) => Effect.Effect<
65 Option.Option<OrchestrationCommandReceipt>,
66 OrchestrationCommandReceiptRepositoryError
67 >;
68 }
69>()("t3/persistence/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {}
70
71const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () {
72 const sql = yield* SqlClient.SqlClient;
73
74 const upsertReceiptRow = SqlSchema.void({
75 Request: OrchestrationCommandReceipt,
76 execute: (receipt) =>
77 sql`
78 INSERT INTO orchestration_command_receipts (
79 command_id,
80 aggregate_kind,
81 aggregate_id,
82 command_type,
83 accepted_at,
84 result_sequence,
85 status,
86 error
87 )
88 VALUES (
89 ${receipt.commandId},
90 ${receipt.aggregateKind},
91 ${receipt.aggregateId},
92 ${receipt.commandType},
93 ${receipt.acceptedAt},
94 ${receipt.resultSequence},
95 ${receipt.status},
96 ${receipt.error}
97 )
98 ON CONFLICT (command_id)
99 DO UPDATE SET
100 aggregate_kind = excluded.aggregate_kind,
101 aggregate_id = excluded.aggregate_id,
102 command_type = excluded.command_type,
103 accepted_at = excluded.accepted_at,
104 result_sequence = excluded.result_sequence,
105 status = excluded.status,
106 error = excluded.error
107 `,
108 });
109
110 const findReceiptByCommandId = SqlSchema.findOneOption({
111 Request: GetByCommandIdInput,
112 Result: OrchestrationCommandReceipt,
113 execute: ({ commandId }) =>
114 sql`
115 SELECT
116 command_id AS "commandId",
117 aggregate_kind AS "aggregateKind",
118 aggregate_id AS "aggregateId",
119 command_type AS "commandType",
120 accepted_at AS "acceptedAt",
121 result_sequence AS "resultSequence",
122 status,
123 error
124 FROM orchestration_command_receipts
125 WHERE command_id = ${commandId}
126 `,
127 });
128
129 const upsert: OrchestrationCommandReceiptRepository["Service"]["upsert"] = (receipt) =>
130 upsertReceiptRow(receipt).pipe(
131 Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")),
132 );
133
134 const insertIfAbsent: OrchestrationCommandReceiptRepository["Service"]["insertIfAbsent"] = (
135 receipt,
136 ) =>
137 sql<{ readonly command_id: string }>`
138 INSERT INTO orchestration_command_receipts (
139 command_id,
140 aggregate_kind,
141 aggregate_id,
142 command_type,
143 accepted_at,
144 result_sequence,
145 status,
146 error
147 )
148 VALUES (
149 ${receipt.commandId},
150 ${receipt.aggregateKind},
151 ${receipt.aggregateId},
152 ${receipt.commandType},
153 ${receipt.acceptedAt},
154 ${receipt.resultSequence},
155 ${receipt.status},
156 ${receipt.error}
157 )
158 ON CONFLICT(command_id) DO NOTHING
159 RETURNING command_id
160 `.pipe(
161 Effect.map((rows) => rows.length === 1),
162 Effect.mapError(
163 toPersistenceSqlError("OrchestrationCommandReceiptRepository.insertIfAbsent:query"),
164 ),
165 );
166
167 const getByCommandId: OrchestrationCommandReceiptRepository["Service"]["getByCommandId"] = (
168 input,
169 ) =>
170 findReceiptByCommandId(input).pipe(
171 Effect.mapError(
172 toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"),
173 ),
174 );
175
176 return {
177 insertIfAbsent,
178 upsert,
179 getByCommandId,
180 } satisfies OrchestrationCommandReceiptRepository["Service"];
181});
182
183export const layer = Layer.effect(
184 OrchestrationCommandReceiptRepository,
185 makeOrchestrationCommandReceiptRepository,
186);
apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts created+274
......@@ -0,0 +1,274 @@
1import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import * as Tracer from "effect/Tracer";
9import * as SqlClient from "effect/sql/SqlClient";
10
11import { runMigrations } from "./Migrations.ts";
12import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
13import * as OrchestrationEventStore from "./OrchestrationEventStore.ts";
14import * as SqlitePersistence from "./Sqlite.ts";
15
16const layerEventStore = OrchestrationEventStore.layer.pipe(
17 Layer.provideMerge(SqlitePersistence.layerMemory),
18);
19const occurredAt = "2026-09-03T00:00:00.000Z";
20const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
21
22function eventRow(
23 ordinal: number,
24 aggregateKind: "project" | "thread",
25 version: number,
26 streamId = `${aggregateKind}:${ordinal}`,
27 commandId: string | null = null,
28) {
29 return {
30 event_id: `event:${ordinal}`,
31 aggregate_kind: aggregateKind,
32 stream_id: streamId,
33 stream_version: ordinal,
34 event_type: aggregateKind === "project" ? "project.created" : "provider-session.detached",
35 occurred_at: occurredAt,
36 command_id: commandId,
37 causation_event_id: null,
38 correlation_id: null,
39 actor_kind: "server",
40 payload_json: encodeJson(
41 aggregateKind === "project"
42 ? {
43 projectId: streamId,
44 title: "Sequence fixture",
45 workspaceRoot: "/tmp/sequence-fixture",
46 defaultModelSelection: null,
47 scripts: [],
48 createdAt: occurredAt,
49 updatedAt: occurredAt,
50 }
51 : { providerSessionId: `session:${ordinal}`, detachedAt: occurredAt },
52 ),
53 metadata_json: "{}",
54 application_event_version: version,
55 };
56}
57
58const seedEvents = Effect.fn("test.seedSequenceEvents")(function* (
59 rows: ReadonlyArray<ReturnType<typeof eventRow>>,
60) {
61 const sql = yield* SqlClient.SqlClient;
62 return yield* sql<{
63 readonly sequence: number;
64 readonly aggregate_kind: string;
65 readonly application_event_version: number;
66 readonly stream_id: string;
67 readonly command_id: string | null;
68 }>`
69 INSERT INTO orchestration_events ${sql.insert(rows)}
70 RETURNING sequence, aggregate_kind, application_event_version, stream_id, command_id
71 `;
72});
73
74it.effect("keeps application and scoped agent high-water marks separate from legacy history", () =>
75 Effect.gen(function* () {
76 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
77 const sql = yield* SqlClient.SqlClient;
78 const target = ThreadId.make("target");
79 assert.equal(yield* store.latestApplicationSequence, 0);
80 assert.equal(yield* store.latestAgentSequence(), 0);
81 assert.equal(yield* store.latestAgentSequence(target), 0);
82
83 yield* seedEvents([eventRow(1, "thread", 1, "legacy-only")]);
84 assert.equal(yield* store.latestApplicationSequence, 0);
85 assert.equal(yield* store.latestAgentSequence(ThreadId.make("legacy-only")), 0);
86
87 const [legacyProject, targetEvent, otherEvent] = yield* seedEvents([
88 eventRow(2, "project", 1),
89 eventRow(3, "thread", 2, target),
90 eventRow(4, "thread", 2, "other"),
91 ]);
92 assert.ok(legacyProject);
93 assert.ok(targetEvent);
94 assert.ok(otherEvent);
95 assert.equal(yield* store.latestApplicationSequence, otherEvent.sequence);
96 assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence);
97 assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence);
98
99 const [projectEvent] = yield* seedEvents([
100 eventRow(5, "project", 2),
101 eventRow(6, "thread", 1, target),
102 eventRow(7, "thread", 3, target),
103 ]);
104 assert.ok(projectEvent);
105 assert.equal(yield* store.latestApplicationSequence, projectEvent.sequence);
106 assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence);
107 assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence);
108 assert.equal(yield* store.latestAgentSequence(ThreadId.make("missing")), 0);
109
110 yield* sql`DELETE FROM orchestration_events WHERE sequence > ${legacyProject.sequence}`;
111 assert.equal(yield* store.latestApplicationSequence, legacyProject.sequence);
112 assert.equal(yield* store.latestAgentSequence(), 0);
113 assert.equal(yield* store.latestAgentSequence(target), 0);
114 }).pipe(Effect.provide(Layer.fresh(layerEventStore))),
115);
116
117it.effect(
118 "preserves mixed application paging, scoped replay, and the catch-up to live boundary",
119 () =>
120 Effect.gen(function* () {
121 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
122 const rows = yield* seedEvents(
123 Array.from({ length: 1_560 }, (_, index) => {
124 const kind = index % 3 === 1 ? "project" : "thread";
125 return eventRow(
126 index,
127 kind,
128 index % 3 === 0 ? 1 : 2,
129 kind === "project" ? `project:${index}` : index % 2 === 0 ? "target" : "other",
130 index % 5 === 0 ? "selected-command" : "other-command",
131 );
132 }),
133 );
134 const afterSequence = rows[25]!.sequence;
135 const throughSequence = rows[1_525]!.sequence;
136 const retained = rows.filter(
137 (row) => row.aggregate_kind === "project" || row.application_event_version === 2,
138 );
139 const replay = yield* store
140 .readApplicationEvents({ afterSequence, throughSequence })
141 .pipe(Stream.runCollect);
142 assert.deepEqual(
143 replay.map((event) => event.sequence),
144 retained
145 .filter((row) => row.sequence > afterSequence && row.sequence <= throughSequence)
146 .map((row) => row.sequence),
147 );
148
149 const scoped = yield* store
150 .readAgentEvents({
151 afterSequence,
152 throughSequence,
153 threadId: ThreadId.make("target"),
154 commandId: CommandId.make("selected-command"),
155 limit: 17,
156 })
157 .pipe(Stream.runCollect);
158 assert.deepEqual(
159 scoped.map((event) => event.sequence),
160 retained
161 .filter(
162 (row) =>
163 row.sequence > afterSequence &&
164 row.sequence <= throughSequence &&
165 row.aggregate_kind === "thread" &&
166 row.stream_id === "target" &&
167 row.command_id === "selected-command",
168 )
169 .slice(0, 17)
170 .map((row) => row.sequence),
171 );
172
173 const catchUp = retained.filter((row) => row.sequence > afterSequence);
174 const highWater = catchUp.at(-1)!.sequence;
175 let liveSequence = 0;
176 const streamed = yield* store.streamApplicationEvents({ afterSequence }).pipe(
177 Stream.tap((event) =>
178 event.sequence !== highWater
179 ? Effect.void
180 : Effect.gen(function* () {
181 const [stored] = yield* store.appendAgentEvents({
182 events: [
183 {
184 id: EventId.make("live-after-catch-up"),
185 type: "provider-session.detached",
186 threadId: ThreadId.make("target"),
187 occurredAt: DateTime.makeUnsafe(occurredAt),
188 payload: {
189 providerSessionId: ProviderSessionId.make("live-session"),
190 detachedAt: DateTime.makeUnsafe(occurredAt),
191 },
192 },
193 ],
194 });
195 assert.ok(stored);
196 liveSequence = stored.sequence;
197 yield* store.publishCommitted([stored]);
198 }),
199 ),
200 Stream.take(catchUp.length + 1),
201 Stream.runCollect,
202 );
203 assert.deepEqual(
204 streamed.map((event) => event.sequence),
205 [...catchUp.map((row) => row.sequence), liveSequence],
206 );
207 assert.isAbove(liveSequence, highWater);
208 }).pipe(Effect.provide(Layer.fresh(layerEventStore))),
209);
210
211it.effect("uses indexed high-water lookups for populated history without OR scans", () =>
212 Effect.gen(function* () {
213 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
214 const sql = yield* SqlClient.SqlClient;
215 yield* runMigrations();
216 yield* sql`
217 WITH RECURSIVE history(n) AS (
218 SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000
219 )
220 INSERT INTO orchestration_events (
221 event_id, aggregate_kind, stream_id, stream_version, event_type,
222 occurred_at, actor_kind, payload_json, metadata_json, application_event_version
223 )
224 SELECT 'history:' || n, 'thread',
225 CASE WHEN n = 1 THEN 'target' ELSE 'other' END,
226 n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}',
227 CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END
228 FROM history
229 `;
230 const statements: Array<string> = [];
231 const tracer = Tracer.make({
232 span(options) {
233 const span = new Tracer.NativeSpan(options);
234 const end = span.end.bind(span);
235 span.end = (endTime, exit) => {
236 end(endTime, exit);
237 const query = span.attributes.get("db.query.text");
238 if (typeof query === "string") statements.push(query);
239 };
240 return span;
241 },
242 });
243 assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000);
244 assert.equal(
245 yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)),
246 1,
247 );
248 assert.equal(statements.length, 2);
249 const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>(
250 `EXPLAIN QUERY PLAN ${statements[0]}`,
251 );
252 const agentPlan = yield* sql.unsafe<{ readonly detail: string }>(
253 `EXPLAIN QUERY PLAN ${statements[1]}`,
254 ["target"],
255 );
256 assert.match(
257 applicationPlan.map((row) => row.detail).join("\n"),
258 /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/,
259 );
260 assert.match(
261 agentPlan.map((row) => row.detail).join("\n"),
262 /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/,
263 );
264 for (const row of [...applicationPlan, ...agentPlan]) {
265 assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/);
266 }
267 }).pipe(
268 Effect.provide(
269 OrchestrationEventStore.layer.pipe(
270 Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })),
271 ),
272 ),
273 ),
274);
apps/server/src/persistence/OrchestrationEventStore.test.ts created+521
......@@ -0,0 +1,521 @@
1import * as NodeV8 from "node:v8";
2
3import {
4 CommandId,
5 EventId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 TurnItemId,
10 type OrchestrationV2DomainEvent,
11} from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
13import * as DateTime from "effect/DateTime";
14import * as Deferred from "effect/Deferred";
15import * as Fiber from "effect/Fiber";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
20import * as SqlClient from "effect/sql/SqlClient";
21import * as Statement from "effect/sql/Statement";
22
23import {
24 LIVE_STREAM_MAX_ITEMS,
25 LiveStreamBufferError,
26} from "../orchestration-v2/LiveStreamBudget.ts";
27import { PersistenceDecodeError } from "./Errors.ts";
28import { toShellApplicationEvent } from "../orchestration-v2/ShellStream.ts";
29import * as OrchestrationEventStore from "./OrchestrationEventStore.ts";
30import * as SqlitePersistence from "./Sqlite.ts";
31const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
32const isLiveStreamBufferError = Schema.is(LiveStreamBufferError);
33
34const layerTest = OrchestrationEventStore.layer.pipe(
35 Layer.provideMerge(SqlitePersistence.layerMemory),
36);
37const layer = it.layer(layerTest);
38
39layer("OrchestrationEventStore", (it) => {
40 it.effect("retains only shell metadata from oversized replay and live application events", () =>
41 Effect.scoped(
42 Effect.gen(function* () {
43 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
44 const afterSequence = yield* store.latestApplicationSequence;
45 const now = yield* DateTime.now;
46 const threadId = ThreadId.make("thread:large-shell-body");
47 const input = "x".repeat(9 * 1024 * 1024);
48 const event: OrchestrationV2DomainEvent = {
49 id: EventId.make("event:large-shell-body:replay"),
50 type: "turn-item.updated",
51 threadId,
52 occurredAt: now,
53 payload: {
54 id: TurnItemId.make("tool:large-shell-body"),
55 type: "command_execution",
56 threadId,
57 runId: null,
58 nodeId: null,
59 providerThreadId: null,
60 providerTurnId: null,
61 nativeItemRef: null,
62 parentItemId: null,
63 ordinal: 1,
64 status: "running",
65 title: "Large command",
66 input,
67 startedAt: now,
68 completedAt: null,
69 updatedAt: now,
70 },
71 };
72 const [replayed] = yield* store.appendAgentEvents({ events: [event] });
73 const pull = yield* Stream.toPull(
74 store.streamProjectedApplicationEvents({
75 afterSequence,
76 project: toShellApplicationEvent,
77 }),
78 );
79 assert.deepEqual(yield* pull, [{ sequence: replayed!.sequence, event: { threadId } }]);
80 const [live] = yield* store.appendAgentEvents({
81 events: [
82 {
83 ...event,
84 id: EventId.make("event:large-shell-body:live"),
85 payload: { ...event.payload, status: "completed", completedAt: now },
86 },
87 ],
88 });
89 yield* store.publishCommitted([live!]);
90 assert.deepEqual(yield* pull, [{ sequence: live!.sequence, event: { threadId } }]);
91 const durable = yield* store
92 .readAgentEvents({ threadId, afterSequence })
93 .pipe(Stream.runCollect);
94 assert.lengthOf(durable, 2);
95 for (const stored of durable) {
96 assert.equal(stored.event.type, "turn-item.updated");
97 if (stored.event.type !== "turn-item.updated") return;
98 assert.equal(stored.event.payload.type, "command_execution");
99 if (stored.event.payload.type !== "command_execution") return;
100 assert.equal(stored.event.payload.input, input);
101 }
102 }),
103 ),
104 );
105
106 it.effect("stores json columns as strings and replays CLI-origin events", () =>
107 Effect.gen(function* () {
108 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
109 const sql = yield* SqlClient.SqlClient;
110 const now = "2026-01-01T00:00:00.000Z";
111
112 const appended = yield* eventStore.appendProjectEvent({
113 type: "project.created",
114 eventId: EventId.make("evt-store-roundtrip"),
115 aggregateKind: "project",
116 aggregateId: ProjectId.make("project-roundtrip"),
117 occurredAt: now,
118 commandId: CommandId.make("cmd-store-roundtrip"),
119 causationEventId: null,
120 correlationId: CommandId.make("cmd-store-roundtrip"),
121 metadata: {
122 adapterKey: "codex",
123 origin: {
124 surface: "cli",
125 },
126 },
127 payload: {
128 projectId: ProjectId.make("project-roundtrip"),
129 title: "Roundtrip Project",
130 workspaceRoot: "/tmp/project-roundtrip",
131 defaultModelSelection: null,
132 scripts: [],
133 createdAt: now,
134 updatedAt: now,
135 },
136 });
137
138 const storedRows = yield* sql<{
139 readonly payloadJson: string;
140 readonly metadataJson: string;
141 }>`
142 SELECT
143 payload_json AS "payloadJson",
144 metadata_json AS "metadataJson"
145 FROM orchestration_events
146 WHERE event_id = ${appended.eventId}
147 `;
148 assert.equal(storedRows.length, 1);
149 assert.equal(typeof storedRows[0]?.payloadJson, "string");
150 assert.equal(typeof storedRows[0]?.metadataJson, "string");
151
152 const replayed = yield* eventStore
153 .readApplicationEvents({
154 afterSequence: appended.sequence - 1,
155 throughSequence: appended.sequence,
156 })
157 .pipe(
158 Stream.runCollect,
159 Effect.map((chunk) => Array.from(chunk)),
160 );
161 assert.equal(replayed.length, 1);
162 const [project] = replayed;
163 if (project === undefined || !("aggregateKind" in project)) {
164 return assert.fail("Expected a project event");
165 }
166 assert.equal(project.type, "project.created");
167 assert.equal(project.metadata.adapterKey, "codex");
168 assert.deepEqual(project.metadata.origin, { surface: "cli" });
169 }),
170 );
171
172 it.effect("fails with PersistenceDecodeError when stored json is invalid", () =>
173 Effect.gen(function* () {
174 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
175 const sql = yield* SqlClient.SqlClient;
176 const now = "2026-01-01T00:00:00.000Z";
177
178 yield* sql`
179 INSERT INTO orchestration_events (
180 event_id,
181 aggregate_kind,
182 stream_id,
183 stream_version,
184 event_type,
185 occurred_at,
186 command_id,
187 causation_event_id,
188 correlation_id,
189 actor_kind,
190 payload_json,
191 metadata_json
192 )
193 VALUES (
194 ${EventId.make("evt-store-invalid-json")},
195 ${"project"},
196 ${ProjectId.make("project-invalid-json")},
197 ${0},
198 ${"project.created"},
199 ${now},
200 ${CommandId.make("cmd-store-invalid-json")},
201 ${null},
202 ${null},
203 ${"server"},
204 ${"{"},
205 ${"{}"}
206 )
207 `;
208
209 const replayResult = yield* Effect.result(
210 Stream.runCollect(
211 eventStore.readApplicationEvents({
212 afterSequence: 0,
213 throughSequence: Number.MAX_SAFE_INTEGER,
214 }),
215 ),
216 );
217 assert.equal(replayResult._tag, "Failure");
218 if (replayResult._tag === "Failure") {
219 assert.ok(isPersistenceDecodeError(replayResult.failure));
220 }
221 }),
222 );
223
224 it.effect("orders project and V2 agent events in the retained application event source", () =>
225 Effect.gen(function* () {
226 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
227 const projectId = ProjectId.make("project-shared-stream");
228 const threadId = ThreadId.make("thread-shared-stream");
229 const providerInstanceId = ProviderInstanceId.make("codex");
230 const occurredAt = DateTime.makeUnsafe("2026-01-02T00:00:00.000Z");
231 const now = DateTime.formatIso(occurredAt);
232 const baselineSequence = yield* eventStore.latestApplicationSequence;
233
234 const projectEvent = yield* eventStore.appendProjectEvent({
235 type: "project.created",
236 eventId: EventId.make("event-project-shared-stream"),
237 aggregateKind: "project",
238 aggregateId: projectId,
239 occurredAt: now,
240 commandId: CommandId.make("command-project-shared-stream"),
241 causationEventId: null,
242 correlationId: CommandId.make("command-project-shared-stream"),
243 metadata: {},
244 payload: {
245 projectId,
246 title: "Shared stream",
247 workspaceRoot: "/tmp/shared-stream",
248 defaultModelSelection: null,
249 scripts: [],
250 createdAt: now,
251 updatedAt: now,
252 },
253 });
254 const [threadEvent] = yield* eventStore.appendAgentEvents({
255 commandId: CommandId.make("command-thread-shared-stream"),
256 events: [
257 {
258 id: EventId.make("event-thread-shared-stream"),
259 type: "thread.created",
260 threadId,
261 providerInstanceId,
262 occurredAt,
263 payload: {
264 id: threadId,
265 projectId,
266 title: "Thread",
267 providerInstanceId,
268 modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
269 runtimeMode: "full-access",
270 interactionMode: "default",
271 branch: null,
272 worktreePath: null,
273 activeProviderThreadId: null,
274 lineage: {
275 rootThreadId: threadId,
276 parentThreadId: null,
277 relationshipToParent: null,
278 },
279 forkedFrom: null,
280 createdBy: "user",
281 creationSource: "web",
282 createdAt: occurredAt,
283 updatedAt: occurredAt,
284 archivedAt: null,
285 settledOverride: null,
286 settledAt: null,
287 lastVisitedAt: null,
288 deletedAt: null,
289 },
290 },
291 ],
292 });
293
294 const applicationEvents = yield* eventStore
295 .streamApplicationEvents({ afterSequence: baselineSequence })
296 .pipe(
297 Stream.take(2),
298 Stream.runCollect,
299 Effect.map((chunk) => Array.from(chunk)),
300 );
301 assert.deepEqual(
302 applicationEvents.map((event) => event.sequence),
303 [projectEvent.sequence, threadEvent!.sequence],
304 );
305 assert.isTrue("aggregateKind" in applicationEvents[0]!);
306 assert.isTrue("event" in applicationEvents[1]!);
307
308 const finiteReplay = yield* eventStore
309 .readApplicationEvents({
310 afterSequence: baselineSequence,
311 throughSequence: threadEvent!.sequence,
312 })
313 .pipe(
314 Stream.runCollect,
315 Effect.map((chunk) => Array.from(chunk)),
316 );
317 assert.deepEqual(
318 finiteReplay.map((event) => event.sequence),
319 [projectEvent.sequence, threadEvent!.sequence],
320 );
321 }),
322 );
323 it.effect("measures only a bounded thread replay before decoding its payloads", () =>
324 Effect.gen(function* () {
325 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
326 const sql = yield* SqlClient.SqlClient;
327 const threadId = ThreadId.make("measured-replay-thread");
328 const baseline = yield* store.latestApplicationSequence;
329 const unicodePayload = '{"text":"🦊"}';
330 const oversizedPayload = "{" + "x".repeat(1_048_576);
331 let streamVersion = 0;
332 const insert = Effect.fn(function* (input: {
333 readonly id: string;
334 readonly payload: string;
335 readonly type?: string;
336 readonly threadId?: string;
337 readonly version?: number;
338 }) {
339 const rows = yield* sql<{ readonly sequence: number }>`
340 INSERT INTO orchestration_events (
341 event_id, aggregate_kind, stream_id, stream_version, event_type,
342 occurred_at, actor_kind, payload_json, metadata_json, application_event_version
343 ) VALUES (
344 ${input.id}, 'thread', ${input.threadId ?? threadId},
345 ${streamVersion++}, ${input.type ?? "thread.metadata-updated"},
346 '2026-09-04T00:00:00.000Z', 'server', ${input.payload}, '{}', ${input.version ?? 2}
347 ) RETURNING sequence
348 `;
349 return rows[0]!.sequence;
350 });
351 const first = yield* insert({
352 id: "measured-replay-1",
353 payload: unicodePayload,
354 type: "thread.created",
355 });
356 yield* insert({
357 id: "measured-unrelated",
358 threadId: "other-replay-thread",
359 payload: oversizedPayload,
360 });
361 yield* insert({ id: "measured-legacy", payload: oversizedPayload, version: 1 });
362 const head = yield* insert({ id: "measured-replay-02", payload: oversizedPayload });
363 const later = yield* insert({ id: "measured-replay-003", payload: "{}" });
364 const range = { threadId, afterSequence: baseline, throughSequence: head, maxEvents: 128 };
365 assert.deepEqual(yield* store.getAgentReplayStats(range), {
366 eventCount: 2,
367 rawPayloadBytes: Buffer.byteLength(unicodePayload) + Buffer.byteLength(oversizedPayload),
368 hasCreateEvent: true,
369 });
370 assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: first }), {
371 eventCount: 1,
372 rawPayloadBytes: Buffer.byteLength(oversizedPayload),
373 hasCreateEvent: false,
374 });
375 assert.equal(
376 (yield* store.getAgentReplayStats({ ...range, throughSequence: later, maxEvents: 1 }))
377 .eventCount,
378 2,
379 );
380 assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: later }), {
381 eventCount: 0,
382 rawPayloadBytes: 0,
383 hasCreateEvent: false,
384 });
385 // The application range skips V1 rows, which a shell resume never replays.
386 assert.deepEqual(
387 yield* store.getReplayStats({ afterSequence: baseline, throughSequence: head }),
388 {
389 eventCount: 3,
390 rawPayloadBytes:
391 Buffer.byteLength(unicodePayload) + 2 * Buffer.byteLength(oversizedPayload),
392 },
393 );
394 }),
395 );
396});
397
398it.effect.each(["high-water", "replay"] as const)(
399 "bounds application live events while the %s query is blocked",
400 (phase) =>
401 Effect.scoped(
402 Effect.gen(function* () {
403 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
404 const now = "2026-01-03T00:00:00.000Z";
405 const projectId = ProjectId.make(`project:blocked-${phase}`);
406 const projectEvent = yield* store.appendProjectEvent({
407 type: "project.created",
408 eventId: EventId.make(`event:blocked-${phase}:created`),
409 aggregateKind: "project",
410 aggregateId: projectId,
411 occurredAt: now,
412 commandId: CommandId.make(`command:blocked-${phase}`),
413 causationEventId: null,
414 correlationId: null,
415 metadata: {},
416 payload: {
417 projectId,
418 title: "Blocked stream",
419 workspaceRoot: "/tmp/blocked-stream",
420 defaultModelSelection: null,
421 scripts: [],
422 createdAt: now,
423 updatedAt: now,
424 },
425 });
426 if (projectEvent.type !== "project.created")
427 return yield* Effect.die("Expected project.created");
428 const readStarted = yield* Deferred.make<void>();
429 const readClosed = yield* Deferred.make<void>();
430 const blockRead: Statement.Transformer = (statement) => {
431 const [query] = statement.compile();
432 if (
433 query.includes("FROM orchestration_events") &&
434 query.includes("MAX(sequence)") === (phase === "high-water")
435 ) {
436 return Deferred.succeed(readStarted, undefined).pipe(
437 Effect.andThen(Effect.never),
438 Effect.ensuring(Deferred.succeed(readClosed, undefined)),
439 Effect.as(statement),
440 );
441 }
442 return Effect.succeed(statement);
443 };
444 const reader = yield* store
445 .streamApplicationEvents({ afterSequence: projectEvent.sequence })
446 .pipe(
447 Stream.provideService(Statement.CurrentTransformer, blockRead),
448 Stream.runDrain,
449 Effect.result,
450 Effect.forkScoped,
451 );
452 yield* Deferred.await(readStarted);
453 yield* store.publishCommitted(
454 Array.from({ length: LIVE_STREAM_MAX_ITEMS + 1 }, (_, index) => ({
455 ...projectEvent,
456 eventId: EventId.make(`event:blocked-${phase}:${index}`),
457 sequence: projectEvent.sequence + index + 1,
458 })),
459 );
460 yield* Deferred.await(readClosed);
461 const result = yield* Fiber.join(reader);
462 assert.equal(result._tag, "Failure");
463 if (result._tag === "Failure") {
464 assert.equal(result.failure._tag, "PersistenceSqlError");
465 assert.isTrue(isLiveStreamBufferError(result.failure.cause));
466 }
467 }),
468 ).pipe(Effect.provide(Layer.fresh(layerTest))),
469);
470
471it.effect("releases consumed application replay pages", () =>
472 Effect.gen(function* () {
473 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
474 const afterSequence = yield* store.latestApplicationSequence;
475 yield* Effect.forEach(
476 Array.from({ length: 1501 }, (_, index) => index),
477 (index) =>
478 store.appendProjectEvent({
479 type: "project.created",
480 eventId: EventId.make(`retention-${index}`),
481 aggregateKind: "project",
482 aggregateId: ProjectId.make(`retention-${index}`),
483 occurredAt: "2026-09-08T00:00:00Z",
484 commandId: null,
485 causationEventId: null,
486 correlationId: null,
487 metadata: {},
488 payload: {
489 projectId: ProjectId.make(`retention-${index}`),
490 title: "Replay",
491 workspaceRoot: "/tmp/replay",
492 defaultModelSelection: null,
493 scripts: [],
494 createdAt: "2026-09-08T00:00:00Z",
495 updatedAt: "2026-09-08T00:00:00Z",
496 },
497 }),
498 { discard: true },
499 );
500 // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC.
501 class ReplayPage {}
502 let count = 0;
503 yield* store
504 .readApplicationEvents({
505 afterSequence,
506 throughSequence: yield* store.latestApplicationSequence,
507 })
508 .pipe(
509 Stream.runForEach((event) =>
510 Effect.sync(() => {
511 if (count % 500 === 0) {
512 Object.assign(event, { replayPage: new ReplayPage() });
513 assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1);
514 }
515 count++;
516 }),
517 ),
518 );
519 assert.equal(count, 1501);
520 }).pipe(Effect.provide(layerTest)),
521);
apps/server/src/persistence/OrchestrationEventStore.ts created+712
......@@ -0,0 +1,712 @@
1/**
2 * Historical name for the shared application event store.
3 *
4 * Owns durable append/replay access for project events and V2 agent-thread
5 * events under one global sequence. It does not reduce events into read models
6 * or apply command validation rules.
7 *
8 * Uses Effect `Context.Service` for dependency injection and exposes typed
9 * persistence/decode errors for event append and replay operations.
10 *
11 * @module OrchestrationEventStore
12 */
13import {
14 ApplicationEventMetadata,
15 ApplicationProjectEvent,
16 type ApplicationStoredEvent,
17 CommandId,
18 EventId,
19 IsoDateTime,
20 NonNegativeInt,
21 OrchestrationV2DomainEventJson,
22 OrchestrationV2StoredEvent,
23 ProjectId,
24 StoredProjectIcon,
25 ThreadId,
26 type OrchestrationV2DomainEvent,
27} from "@t3tools/contracts";
28import * as Context from "effect/Context";
29import * as SqlClient from "effect/sql/SqlClient";
30import * as SqlSchema from "effect/sql/SqlSchema";
31import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";
33import * as PubSub from "effect/PubSub";
34import * as Option from "effect/Option";
35import * as Schema from "effect/Schema";
36import * as Stream from "effect/Stream";
37
38import { replayAndBufferProjectedLiveEvents } from "../orchestration-v2/LiveStreamBudget.ts";
39
40import {
41 toPersistenceDecodeError,
42 toPersistenceSqlError,
43 type OrchestrationEventStoreError,
44} from "./Errors.ts";
45
46/** A project event before the store assigns its sequence. */
47export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event
48 ? Event extends ApplicationProjectEvent
49 ? Omit<Event, "sequence">
50 : never
51 : never;
52
53/** OrchestrationEventStore - Service tag for the shared application event log. */
54export class OrchestrationEventStore extends Context.Service<
55 OrchestrationEventStore,
56 {
57 /** Append one project event to the shared application log. */
58 readonly appendProjectEvent: (
59 event: UnsequencedProjectEvent,
60 ) => Effect.Effect<ApplicationProjectEvent, OrchestrationEventStoreError>;
61
62 /** Append V2 agent events to the same globally ordered application log. */
63 readonly appendAgentEvents: (input: {
64 readonly commandId?: CommandId;
65 readonly events: ReadonlyArray<OrchestrationV2DomainEvent>;
66 }) => Effect.Effect<ReadonlyArray<OrchestrationV2StoredEvent>, OrchestrationEventStoreError>;
67
68 /**
69 * Read only V2 thread events from the application log.
70 *
71 * Reads in fixed-size sequence pages until the filtered range is exhausted;
72 * `limit` caps the total emitted events across pages.
73 */
74 readonly readAgentEvents: (input?: {
75 readonly afterSequence?: number;
76 readonly throughSequence?: number;
77 readonly threadId?: ThreadId;
78 readonly commandId?: CommandId;
79 readonly eventType?: OrchestrationV2DomainEvent["type"];
80 readonly limit?: number;
81 }) => Stream.Stream<OrchestrationV2StoredEvent, OrchestrationEventStoreError>;
82
83 /** Measure one thread's bounded replay without loading or decoding its payloads. */
84 readonly getAgentReplayStats: (input: {
85 readonly threadId: ThreadId;
86 readonly afterSequence: number;
87 readonly throughSequence: number;
88 readonly maxEvents: number;
89 }) => Effect.Effect<
90 {
91 readonly eventCount: number;
92 /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */
93 readonly rawPayloadBytes: number;
94 readonly hasCreateEvent: boolean;
95 },
96 OrchestrationEventStoreError
97 >;
98
99 /**
100 * Measure the retained application-event range `(afterSequence, throughSequence]`
101 * without loading or decoding its payloads.
102 */
103 readonly getReplayStats: (input: {
104 readonly afterSequence: number;
105 readonly throughSequence: number;
106 }) => Effect.Effect<
107 {
108 readonly eventCount: number;
109 /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */
110 readonly rawPayloadBytes: number;
111 },
112 OrchestrationEventStoreError
113 >;
114
115 readonly latestAgentSequence: (
116 threadId?: ThreadId,
117 ) => Effect.Effect<number, OrchestrationEventStoreError>;
118
119 readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>;
120
121 /** Read the finite retained application-event range `(afterSequence, throughSequence]`. */
122 readonly readApplicationEvents: (input: {
123 readonly afterSequence: number;
124 readonly throughSequence: number;
125 }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>;
126
127 /** Publish only after the surrounding event/projection transaction commits. */
128 readonly publishCommitted: (
129 events: ReadonlyArray<ApplicationStoredEvent>,
130 ) => Effect.Effect<void>;
131
132 /** Race-free replay-to-live stream for project and V2 thread events. */
133 readonly streamApplicationEvents: (input?: {
134 readonly afterSequence?: number;
135 }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>;
136 /** Project transport events before bounding replay and the live tail. */
137 readonly streamProjectedApplicationEvents: <A extends { readonly sequence: number }>(input: {
138 readonly afterSequence?: number;
139 readonly project: (event: ApplicationStoredEvent) => A;
140 }) => Stream.Stream<A, OrchestrationEventStoreError>;
141 }
142>()("t3/persistence/OrchestrationEventStore") {}
143
144const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon);
145const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent);
146const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
147const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata);
148const ProjectEventType = Schema.Literals([
149 "project.created",
150 "project.meta-updated",
151 "project.deleted",
152]);
153const ActorKind = Schema.Literals(["client", "server", "provider"]);
154
155const AppendProjectEventRequestSchema = Schema.Struct({
156 eventId: EventId,
157 streamId: ProjectId,
158 type: ProjectEventType,
159 causationEventId: Schema.NullOr(EventId),
160 correlationId: Schema.NullOr(CommandId),
161 actorKind: ActorKind,
162 occurredAt: IsoDateTime,
163 commandId: Schema.NullOr(CommandId),
164 payloadJson: UnknownFromJsonString,
165 metadataJson: EventMetadataFromJsonString,
166});
167
168const ProjectEventPersistedRowSchema = Schema.Struct({
169 sequence: NonNegativeInt,
170 eventId: EventId,
171 type: ProjectEventType,
172 aggregateKind: Schema.Literal("project"),
173 aggregateId: ProjectId,
174 occurredAt: IsoDateTime,
175 commandId: Schema.NullOr(CommandId),
176 causationEventId: Schema.NullOr(EventId),
177 correlationId: Schema.NullOr(CommandId),
178 payload: UnknownFromJsonString,
179 metadata: EventMetadataFromJsonString,
180});
181
182const READ_PAGE_SIZE = 500;
183
184interface ApplicationEventRow {
185 readonly sequence: number;
186 readonly event_id: string;
187 readonly command_id: string | null;
188 readonly aggregate_kind: "project" | "thread";
189 readonly stream_id: string;
190 readonly event_type: string;
191 readonly occurred_at: string;
192 readonly payload_json: string;
193 readonly metadata_json: string;
194 readonly application_event_version: number;
195 readonly causation_event_id: string | null;
196 readonly correlation_id: string | null;
197}
198
199const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson);
200const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson);
201const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent);
202const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
203const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
204
205function metadataForV2Event(event: OrchestrationV2DomainEvent): Record<string, unknown> {
206 return {
207 ...(event.runId === undefined ? {} : { runId: event.runId }),
208 ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
209 ...(event.driver === undefined ? {} : { driver: event.driver }),
210 ...(event.providerInstanceId === undefined
211 ? {}
212 : { providerInstanceId: event.providerInstanceId }),
213 ...(event.rawEventId === undefined ? {} : { rawEventId: event.rawEventId }),
214 };
215}
216
217const rowToV2StoredEvent = Effect.fn("OrchestrationEventStore.rowToV2StoredEvent")(function* (
218 row: ApplicationEventRow,
219) {
220 const payload = yield* decodeJson(row.payload_json);
221 const metadata = yield* decodeJson(row.metadata_json);
222 const values =
223 typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>) : {};
224 const event = yield* decodeV2EventJson({
225 id: row.event_id,
226 threadId: row.stream_id,
227 type: row.event_type,
228 occurredAt: row.occurred_at,
229 payload,
230 ...(values.runId === undefined ? {} : { runId: values.runId }),
231 ...(values.nodeId === undefined ? {} : { nodeId: values.nodeId }),
232 ...(values.driver === undefined ? {} : { driver: values.driver }),
233 ...(values.providerInstanceId === undefined
234 ? {}
235 : { providerInstanceId: values.providerInstanceId }),
236 ...(values.rawEventId === undefined ? {} : { rawEventId: values.rawEventId }),
237 });
238 return yield* decodeV2StoredEvent({
239 sequence: row.sequence,
240 commandId: row.command_id,
241 event,
242 });
243});
244
245const rowToProjectEvent = Effect.fn("OrchestrationEventStore.rowToProjectEvent")(function* (
246 row: ApplicationEventRow,
247) {
248 return yield* decodeProjectEvent({
249 sequence: row.sequence,
250 eventId: row.event_id,
251 type: row.event_type,
252 aggregateKind: row.aggregate_kind,
253 aggregateId: row.stream_id,
254 occurredAt: row.occurred_at,
255 commandId: row.command_id,
256 causationEventId: row.causation_event_id,
257 correlationId: row.correlation_id,
258 payload: yield* decodeJson(row.payload_json),
259 metadata: yield* decodeJson(row.metadata_json),
260 });
261});
262
263function rowToApplicationStoredEvent(
264 row: ApplicationEventRow,
265): Effect.Effect<ApplicationStoredEvent, Schema.SchemaError> {
266 return row.aggregate_kind === "project" ? rowToProjectEvent(row) : rowToV2StoredEvent(row);
267}
268
269function inferActorKind(event: UnsequencedProjectEvent): typeof ActorKind.Type {
270 if (event.commandId !== null && event.commandId.startsWith("provider:")) {
271 return "provider";
272 }
273 if (event.commandId !== null && event.commandId.startsWith("server:")) {
274 return "server";
275 }
276 if (
277 event.metadata.providerTurnId !== undefined ||
278 event.metadata.providerItemId !== undefined ||
279 event.metadata.adapterKey !== undefined
280 ) {
281 return "provider";
282 }
283 if (event.commandId === null) {
284 return "server";
285 }
286 return "client";
287}
288
289function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
290 return (cause: unknown): OrchestrationEventStoreError =>
291 Schema.isSchemaError(cause)
292 ? toPersistenceDecodeError(decodeOperation)(cause)
293 : toPersistenceSqlError(sqlOperation)(cause);
294}
295
296const makeEventStore = Effect.gen(function* () {
297 const sql = yield* SqlClient.SqlClient;
298 const committedEvents = yield* PubSub.unbounded<ApplicationStoredEvent>();
299
300 const appendProjectEventRow = SqlSchema.findOne({
301 Request: AppendProjectEventRequestSchema,
302 Result: ProjectEventPersistedRowSchema,
303 execute: (request) =>
304 sql`
305 INSERT INTO orchestration_events (
306 event_id,
307 aggregate_kind,
308 stream_id,
309 stream_version,
310 event_type,
311 occurred_at,
312 command_id,
313 causation_event_id,
314 correlation_id,
315 actor_kind,
316 payload_json,
317 metadata_json,
318 application_event_version
319 )
320 VALUES (
321 ${request.eventId},
322 'project',
323 ${request.streamId},
324 COALESCE(
325 (
326 SELECT stream_version + 1
327 FROM orchestration_events
328 WHERE aggregate_kind = 'project'
329 AND stream_id = ${request.streamId}
330 ORDER BY stream_version DESC
331 LIMIT 1
332 ),
333 0
334 ),
335 ${request.type},
336 ${request.occurredAt},
337 ${request.commandId},
338 ${request.causationEventId},
339 ${request.correlationId},
340 ${request.actorKind},
341 ${request.payloadJson},
342 ${request.metadataJson},
343 2
344 )
345 RETURNING
346 sequence,
347 event_id AS "eventId",
348 event_type AS "type",
349 aggregate_kind AS "aggregateKind",
350 stream_id AS "aggregateId",
351 occurred_at AS "occurredAt",
352 command_id AS "commandId",
353 causation_event_id AS "causationEventId",
354 correlation_id AS "correlationId",
355 payload_json AS "payload",
356 metadata_json AS "metadata"
357 `,
358 });
359
360 const appendProjectEvent: OrchestrationEventStore["Service"]["appendProjectEvent"] = (event) =>
361 appendProjectEventRow({
362 eventId: event.eventId,
363 streamId: event.aggregateId,
364 type: event.type,
365 causationEventId: event.causationEventId,
366 correlationId: event.correlationId,
367 actorKind: inferActorKind(event),
368 occurredAt: event.occurredAt,
369 commandId: event.commandId,
370 payloadJson:
371 event.type === "project.deleted" || !event.payload.projectIcon
372 ? event.payload
373 : { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) },
374 metadataJson: event.metadata,
375 }).pipe(
376 Effect.flatMap((row) => decodeProjectEvent(row)),
377 Effect.mapError(
378 toPersistenceSqlOrDecodeError(
379 "OrchestrationEventStore.appendProjectEvent:insert",
380 "OrchestrationEventStore.appendProjectEvent:decode",
381 ),
382 ),
383 );
384
385 const readApplicationRows = (input: {
386 readonly afterSequence: number;
387 readonly throughSequence?: number;
388 readonly threadId?: ThreadId;
389 readonly commandId?: CommandId;
390 readonly eventType?: OrchestrationV2DomainEvent["type"];
391 readonly onlyAgentEvents?: boolean;
392 readonly limit: number;
393 }) =>
394 sql<ApplicationEventRow>`
395 SELECT
396 sequence,
397 event_id,
398 command_id,
399 aggregate_kind,
400 stream_id,
401 event_type,
402 occurred_at,
403 payload_json,
404 metadata_json,
405 application_event_version,
406 causation_event_id,
407 correlation_id
408 FROM orchestration_events
409 ${
410 input.onlyAgentEvents === true
411 ? sql``
412 : sql`INDEXED BY idx_orchestration_events_application_high_water`
413 }
414 WHERE sequence > ${input.afterSequence}
415 AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER}
416 AND (
417 ${
418 input.onlyAgentEvents === true
419 ? sql`application_event_version = 2 AND aggregate_kind = 'thread'`
420 : sql`aggregate_kind = 'project'
421 OR (application_event_version = 2 AND aggregate_kind = 'thread')`
422 }
423 )
424 AND ${sql.and([
425 ...(input.threadId === undefined ? [] : [sql`stream_id = ${input.threadId}`]),
426 ...(input.commandId === undefined ? [] : [sql`command_id = ${input.commandId}`]),
427 ...(input.eventType === undefined ? [] : [sql`event_type = ${input.eventType}`]),
428 ])}
429 ORDER BY sequence ASC
430 LIMIT ${input.limit}
431 `;
432
433 const appendAgentEvents: OrchestrationEventStore["Service"]["appendAgentEvents"] = (input) =>
434 Effect.forEach(
435 input.events,
436 (event) =>
437 Effect.gen(function* () {
438 const encoded = yield* encodeV2EventJson(event);
439 const rows = yield* sql<{ readonly sequence: number }>`
440 INSERT INTO orchestration_events (
441 event_id,
442 aggregate_kind,
443 stream_id,
444 stream_version,
445 event_type,
446 occurred_at,
447 command_id,
448 causation_event_id,
449 correlation_id,
450 actor_kind,
451 payload_json,
452 metadata_json,
453 application_event_version
454 )
455 VALUES (
456 ${event.id},
457 'thread',
458 ${event.threadId},
459 COALESCE(
460 (
461 SELECT MAX(stream_version) + 1
462 FROM orchestration_events
463 WHERE aggregate_kind = 'thread' AND stream_id = ${event.threadId}
464 ),
465 0
466 ),
467 ${event.type},
468 ${encoded.occurredAt},
469 ${input.commandId ?? null},
470 NULL,
471 ${input.commandId ?? null},
472 ${event.rawEventId === undefined ? "server" : "provider"},
473 ${yield* encodeJson(encoded.payload)},
474 ${yield* encodeJson(metadataForV2Event(event))},
475 2
476 )
477 RETURNING sequence
478 `;
479 return yield* decodeV2StoredEvent({
480 sequence: rows[0]?.sequence,
481 commandId: input.commandId ?? null,
482 event,
483 });
484 }),
485 { concurrency: 1 },
486 ).pipe(
487 Effect.mapError(
488 toPersistenceSqlOrDecodeError(
489 "OrchestrationEventStore.appendAgentEvents:insert",
490 "OrchestrationEventStore.appendAgentEvents:decode",
491 ),
492 ),
493 );
494
495 const readAgentEvents: OrchestrationEventStore["Service"]["readAgentEvents"] = (input) => {
496 const totalLimit =
497 input?.limit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(input.limit));
498 if (totalLimit === 0) {
499 return Stream.empty;
500 }
501 return Stream.paginate(
502 { cursor: input?.afterSequence ?? 0, remaining: totalLimit },
503 ({ cursor, remaining }) => {
504 const pageLimit = Math.min(remaining, READ_PAGE_SIZE);
505 return readApplicationRows({
506 afterSequence: cursor,
507 ...(input?.throughSequence === undefined
508 ? {}
509 : { throughSequence: input.throughSequence }),
510 ...(input?.threadId === undefined ? {} : { threadId: input.threadId }),
511 ...(input?.commandId === undefined ? {} : { commandId: input.commandId }),
512 ...(input?.eventType === undefined ? {} : { eventType: input.eventType }),
513 onlyAgentEvents: true,
514 limit: pageLimit,
515 }).pipe(
516 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readAgentEvents:query")),
517 Effect.map((rows) => {
518 const last = rows.at(-1);
519 const nextRemaining = remaining - rows.length;
520 return [
521 rows,
522 last === undefined ||
523 rows.length < pageLimit ||
524 nextRemaining <= 0 ||
525 (input?.throughSequence !== undefined && last.sequence >= input.throughSequence)
526 ? Option.none()
527 : Option.some({ cursor: last.sequence, remaining: nextRemaining }),
528 ] as const;
529 }),
530 );
531 },
532 ).pipe(
533 Stream.mapEffect((row) =>
534 rowToV2StoredEvent(row).pipe(
535 Effect.mapError(
536 toPersistenceDecodeError("OrchestrationEventStore.readAgentEvents:decode"),
537 ),
538 ),
539 ),
540 );
541 };
542
543 const getAgentReplayStats: OrchestrationEventStore["Service"]["getAgentReplayStats"] = (input) =>
544 sql<{
545 readonly eventCount: number;
546 readonly rawPayloadBytes: number;
547 readonly hasCreateEvent: number;
548 }>`
549 SELECT
550 COUNT(*) AS "eventCount",
551 COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes",
552 COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent"
553 FROM (
554 SELECT payload_json, event_type
555 FROM orchestration_events
556 WHERE aggregate_kind = 'thread'
557 AND stream_id = ${input.threadId}
558 AND application_event_version = 2
559 AND sequence > ${input.afterSequence}
560 AND sequence <= ${input.throughSequence}
561 ORDER BY sequence ASC
562 LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1}
563 )
564 `.pipe(
565 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")),
566 Effect.map((rows) => ({
567 eventCount: rows[0]?.eventCount ?? 0,
568 rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0,
569 hasCreateEvent: (rows[0]?.hasCreateEvent ?? 0) !== 0,
570 })),
571 );
572
573 const getReplayStats: OrchestrationEventStore["Service"]["getReplayStats"] = (input) =>
574 sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>`
575 SELECT
576 COUNT(*) AS "eventCount",
577 COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes"
578 FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water
579 WHERE sequence > ${input.afterSequence}
580 AND sequence <= ${input.throughSequence}
581 AND (
582 aggregate_kind = 'project'
583 OR (application_event_version = 2 AND aggregate_kind = 'thread')
584 )
585 `.pipe(
586 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")),
587 Effect.map((rows) => ({
588 eventCount: rows[0]?.eventCount ?? 0,
589 rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0,
590 })),
591 );
592
593 const latestAgentSequence: OrchestrationEventStore["Service"]["latestAgentSequence"] = (
594 threadId,
595 ) =>
596 sql<{ readonly sequence: number | null }>`
597 SELECT MAX(sequence) AS sequence
598 FROM orchestration_events
599 ${
600 threadId === undefined
601 ? sql``
602 : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence`
603 }
604 WHERE application_event_version = 2
605 AND aggregate_kind = 'thread'
606 ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`}
607 `.pipe(
608 Effect.map((rows) => rows[0]?.sequence ?? 0),
609 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")),
610 );
611
612 // The OR planner otherwise scans every V2 event instead of seeking the final sequence.
613 const latestApplicationSequence = sql<{ readonly sequence: number | null }>`
614 SELECT MAX(sequence) AS sequence
615 FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water
616 WHERE aggregate_kind = 'project'
617 OR (application_event_version = 2 AND aggregate_kind = 'thread')
618 `.pipe(
619 Effect.map((rows) => rows[0]?.sequence ?? 0),
620 Effect.mapError(
621 toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"),
622 ),
623 );
624
625 const readApplicationEventPage = (input: {
626 readonly afterSequence: number;
627 readonly throughSequence: number;
628 readonly limit: number;
629 }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> =>
630 Stream.fromEffect(
631 readApplicationRows(input).pipe(
632 Effect.mapError(
633 toPersistenceSqlError("OrchestrationEventStore.readApplicationEvents:query"),
634 ),
635 ),
636 ).pipe(
637 Stream.flatMap(Stream.fromIterable),
638 Stream.mapEffect((row) =>
639 rowToApplicationStoredEvent(row).pipe(
640 Effect.mapError(
641 toPersistenceDecodeError("OrchestrationEventStore.readApplicationEvents:decode"),
642 ),
643 ),
644 ),
645 );
646
647 const catchUpApplicationEvents = (input: {
648 readonly afterSequence: number;
649 readonly throughSequence: number;
650 }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> => {
651 return Stream.paginate(input.afterSequence, (afterSequence) =>
652 readApplicationEventPage({
653 afterSequence,
654 throughSequence: input.throughSequence,
655 limit: READ_PAGE_SIZE,
656 }).pipe(
657 Stream.runCollect,
658 Effect.map((events) => {
659 const last = events.at(-1);
660 return [
661 events,
662 last === undefined ||
663 events.length < READ_PAGE_SIZE ||
664 last.sequence >= input.throughSequence
665 ? Option.none()
666 : Option.some(last.sequence),
667 ] as const;
668 }),
669 ),
670 );
671 };
672
673 const streamProjectedApplicationEvents: OrchestrationEventStore["Service"]["streamProjectedApplicationEvents"] =
674 (input) =>
675 replayAndBufferProjectedLiveEvents({
676 subscribe: PubSub.subscribe(committedEvents),
677 latestSequence: latestApplicationSequence,
678 afterSequence: input.afterSequence ?? 0,
679 project: input.project,
680 replay: (throughSequence) =>
681 catchUpApplicationEvents({
682 afterSequence: input?.afterSequence ?? 0,
683 throughSequence,
684 }),
685 }).pipe(
686 Stream.catchTag("LiveStreamBufferError", (cause) =>
687 Stream.fail(
688 toPersistenceSqlError("OrchestrationEventStore.streamApplicationEvents:buffer")(cause),
689 ),
690 ),
691 );
692
693 const streamApplicationEvents: OrchestrationEventStore["Service"]["streamApplicationEvents"] = (
694 input,
695 ) => streamProjectedApplicationEvents({ ...input, project: (event) => event });
696
697 return {
698 appendProjectEvent,
699 appendAgentEvents,
700 readAgentEvents,
701 getAgentReplayStats,
702 getReplayStats,
703 latestAgentSequence,
704 latestApplicationSequence,
705 readApplicationEvents: catchUpApplicationEvents,
706 publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid),
707 streamApplicationEvents,
708 streamProjectedApplicationEvents,
709 } satisfies OrchestrationEventStore["Service"];
710});
711
712export const layer = Layer.effect(OrchestrationEventStore, makeEventStore);
apps/server/src/persistence/RepositoryErrorCorrelation.test.ts+1-1
......@@ -8,7 +8,7 @@ import * as SqlClient from "effect/sql/SqlClient";
88import * as AuthPairingLinks from "./AuthPairingLinks.ts";
99import * as AuthSessions from "./AuthSessions.ts";
1010import * as PersistenceErrors from "./Errors.ts";
11import * as SqlitePersistence from "./Layers/Sqlite.ts";
11import * as SqlitePersistence from "./Sqlite.ts";
1212import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts";
1313
1414const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts deleted-68
......@@ -1,68 +0,0 @@
1/**
2 * OrchestrationCommandReceiptRepository - Repository interface for command receipts.
3 *
4 * Owns persistence operations for deduplication and status tracking of
5 * orchestration command handling.
6 *
7 * @module OrchestrationCommandReceiptRepository
8 */
9import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as Context from "effect/Context";
13import type * as Effect from "effect/Effect";
14
15import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts";
16
17export const OrchestrationCommandReceipt = Schema.Struct({
18 commandId: CommandId,
19 aggregateKind: Schema.Literals(["project", "thread"]),
20 aggregateId: Schema.Union([ProjectId, ThreadId]),
21 commandType: Schema.String,
22 acceptedAt: IsoDateTime,
23 resultSequence: NonNegativeInt,
24 status: Schema.Literals(["accepted", "rejected"]),
25 error: Schema.NullOr(Schema.String),
26});
27export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type;
28
29export const GetByCommandIdInput = Schema.Struct({
30 commandId: CommandId,
31});
32export type GetByCommandIdInput = typeof GetByCommandIdInput.Type;
33
34/**
35 * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts.
36 */
37export interface OrchestrationCommandReceiptRepositoryShape {
38 readonly insertIfAbsent: (
39 receipt: OrchestrationCommandReceipt,
40 ) => Effect.Effect<boolean, OrchestrationCommandReceiptRepositoryError>;
41
42 /**
43 * Insert or replace a command receipt row.
44 *
45 * Upserts by `commandId` for idempotent command-result tracking.
46 */
47 readonly upsert: (
48 receipt: OrchestrationCommandReceipt,
49 ) => Effect.Effect<void, OrchestrationCommandReceiptRepositoryError>;
50
51 /**
52 * Read a command receipt by command id.
53 */
54 readonly getByCommandId: (
55 input: GetByCommandIdInput,
56 ) => Effect.Effect<
57 Option.Option<OrchestrationCommandReceipt>,
58 OrchestrationCommandReceiptRepositoryError
59 >;
60}
61
62/**
63 * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence.
64 */
65export class OrchestrationCommandReceiptRepository extends Context.Service<
66 OrchestrationCommandReceiptRepository,
67 OrchestrationCommandReceiptRepositoryShape
68>()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {}
apps/server/src/persistence/Services/OrchestrationEventStore.ts deleted-126
......@@ -1,126 +0,0 @@
1/**
2 * Historical name for the shared application event store.
3 *
4 * Owns durable append/replay access for project events and V2 agent-thread
5 * events under one global sequence. It does not reduce events into read models
6 * or apply command validation rules.
7 *
8 * Uses Effect `Context.Service` for dependency injection and exposes typed
9 * persistence/decode errors for event append and replay operations.
10 *
11 * @module OrchestrationEventStore
12 */
13import type {
14 ApplicationProjectEvent,
15 ApplicationStoredEvent,
16 CommandId,
17 OrchestrationV2DomainEvent,
18 OrchestrationV2StoredEvent,
19 ThreadId,
20} from "@t3tools/contracts";
21import * as Context from "effect/Context";
22import type * as Effect from "effect/Effect";
23import type * as Stream from "effect/Stream";
24
25import type { OrchestrationEventStoreError } from "../Errors.ts";
26
27/** A project event before the store assigns its sequence. */
28export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event
29 ? Event extends ApplicationProjectEvent
30 ? Omit<Event, "sequence">
31 : never
32 : never;
33
34/**
35 * OrchestrationEventStoreShape - Service API for orchestration event persistence.
36 */
37export interface OrchestrationEventStoreShape {
38 /** Append one project event to the shared application log. */
39 readonly appendProjectEvent: (
40 event: UnsequencedProjectEvent,
41 ) => Effect.Effect<ApplicationProjectEvent, OrchestrationEventStoreError>;
42
43 /** Append V2 agent events to the same globally ordered application log. */
44 readonly appendAgentEvents: (input: {
45 readonly commandId?: CommandId;
46 readonly events: ReadonlyArray<OrchestrationV2DomainEvent>;
47 }) => Effect.Effect<ReadonlyArray<OrchestrationV2StoredEvent>, OrchestrationEventStoreError>;
48
49 /**
50 * Read only V2 thread events from the application log.
51 *
52 * Reads in fixed-size sequence pages until the filtered range is exhausted;
53 * `limit` caps the total emitted events across pages.
54 */
55 readonly readAgentEvents: (input?: {
56 readonly afterSequence?: number;
57 readonly throughSequence?: number;
58 readonly threadId?: ThreadId;
59 readonly commandId?: CommandId;
60 readonly eventType?: OrchestrationV2DomainEvent["type"];
61 readonly limit?: number;
62 }) => Stream.Stream<OrchestrationV2StoredEvent, OrchestrationEventStoreError>;
63
64 /** Measure one thread's bounded replay without loading or decoding its payloads. */
65 readonly getAgentReplayStats: (input: {
66 readonly threadId: ThreadId;
67 readonly afterSequence: number;
68 readonly throughSequence: number;
69 readonly maxEvents: number;
70 }) => Effect.Effect<
71 {
72 readonly eventCount: number;
73 /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */
74 readonly rawPayloadBytes: number;
75 readonly hasCreateEvent: boolean;
76 },
77 OrchestrationEventStoreError
78 >;
79
80 /**
81 * Measure the retained application-event range `(afterSequence, throughSequence]`
82 * without loading or decoding its payloads.
83 */
84 readonly getReplayStats: (input: {
85 readonly afterSequence: number;
86 readonly throughSequence: number;
87 }) => Effect.Effect<
88 {
89 readonly eventCount: number;
90 /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */
91 readonly rawPayloadBytes: number;
92 },
93 OrchestrationEventStoreError
94 >;
95
96 readonly latestAgentSequence: (
97 threadId?: ThreadId,
98 ) => Effect.Effect<number, OrchestrationEventStoreError>;
99
100 readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>;
101
102 /** Read the finite retained application-event range `(afterSequence, throughSequence]`. */
103 readonly readApplicationEvents: (input: {
104 readonly afterSequence: number;
105 readonly throughSequence: number;
106 }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>;
107
108 /** Publish only after the surrounding event/projection transaction commits. */
109 readonly publishCommitted: (events: ReadonlyArray<ApplicationStoredEvent>) => Effect.Effect<void>;
110
111 /** Race-free replay-to-live stream for project and V2 thread events. */
112 readonly streamApplicationEvents: (input?: {
113 readonly afterSequence?: number;
114 }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>;
115 /** Project transport events before bounding replay and the live tail. */
116 readonly streamProjectedApplicationEvents: <A extends { readonly sequence: number }>(input: {
117 readonly afterSequence?: number;
118 readonly project: (event: ApplicationStoredEvent) => A;
119 }) => Stream.Stream<A, OrchestrationEventStoreError>;
120}
121
122/** OrchestrationEventStore - Service tag for the shared application event log. */
123export class OrchestrationEventStore extends Context.Service<
124 OrchestrationEventStore,
125 OrchestrationEventStoreShape
126>()("t3/persistence/Services/OrchestrationEventStore") {}
apps/server/src/persistence/Sqlite.test.ts created+93
......@@ -0,0 +1,93 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as SqlClient from "effect/sql/SqlClient";
12
13import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts";
14import * as SqlitePersistence from "./Sqlite.ts";
15
16const lockHolderSource = `
17const { DatabaseSync } = require("node:sqlite");
18const db = new DatabaseSync(process.argv[1]);
19db.exec("BEGIN IMMEDIATE");
20process.stdout.write("locked\\n");
21setTimeout(() => {
22 db.exec("COMMIT");
23 db.close();
24}, Number(process.argv[2]));
25`;
26
27const spawnWriteLockHolder = (dbPath: string, holdMs: number) =>
28 Effect.promise(
29 () =>
30 new Promise<void>((resolve, reject) => {
31 const holder = NodeChildProcess.spawn(
32 process.execPath,
33 ["-e", lockHolderSource, dbPath, String(holdMs)],
34 { stdio: ["ignore", "pipe", "ignore"] },
35 );
36 holder.stdout.once("data", () => resolve());
37 holder.on("error", reject);
38 holder.on("exit", () =>
39 reject(new Error("lock holder exited before acquiring the write lock")),
40 );
41 }),
42 );
43
44it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => {
45 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-"));
46 const dbPath = NodePath.join(tempDir, "state.sqlite");
47
48 return Effect.gen(function* () {
49 const sql = yield* SqlClient.SqlClient;
50 yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`;
51 yield* spawnWriteLockHolder(dbPath, 300);
52 yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`;
53 const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`;
54 assert.deepEqual([...rows], [{ id: 1 }]);
55 }).pipe(
56 Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))),
57 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
58 );
59});
60
61it.effect("shrinks the WAL file back to the size limit after a large write", () => {
62 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-"));
63 const dbPath = NodePath.join(tempDir, "state.sqlite");
64 const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size;
65 // About 25% more 4 KB rows than the limit holds, in one transaction.
66 const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000);
67
68 return Effect.gen(function* () {
69 const sql = yield* SqlClient.SqlClient;
70 yield* sql`CREATE TABLE wal_probe(payload BLOB)`;
71 yield* sql`
72 WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount})
73 INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n
74 `;
75 assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES);
76
77 // The auto-checkpoint after the large commit copied every frame into the
78 // database, so the next commit restarts the WAL and cuts the file back.
79 yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`;
80 assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES);
81 }).pipe(
82 Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))),
83 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
84 );
85});
86
87it.effect("applies busy_timeout in the shared persistence setup", () =>
88 Effect.gen(function* () {
89 const sql = yield* SqlClient.SqlClient;
90 const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`;
91 assert.equal(rows[0]?.timeout, 5000);
92 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
93);
apps/server/src/persistence/Sqlite.ts created+57
......@@ -0,0 +1,57 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as SqlClient from "effect/sql/SqlClient";
6import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
7
8import { runMigrations } from "./Migrations.ts";
9import { initializeV2Database } from "./initializeV2Database.ts";
10import * as ServerConfig from "../config.ts";
11
12// Size the -wal file is cut back to on the first commit after a WAL reset.
13export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024;
14
15const layerSetup = Layer.effectDiscard(
16 Effect.gen(function* () {
17 const sql = yield* SqlClient.SqlClient;
18 // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY.
19 yield* sql`PRAGMA busy_timeout = 5000;`;
20 yield* sql`PRAGMA foreign_keys = ON;`;
21 yield* sql`PRAGMA journal_mode = WAL;`;
22 // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its
23 // largest size until the last connection closes.
24 yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`);
25 yield* runMigrations();
26 }),
27);
28
29export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) {
30 const fs = yield* FileSystem.FileSystem;
31 const path = yield* Path.Path;
32 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
33
34 return Layer.provideMerge(
35 layerSetup,
36 NodeSqliteClient.layer({
37 filename: dbPath,
38 spanAttributes: {
39 "db.name": path.basename(dbPath),
40 "service.name": "t3code-server",
41 },
42 }),
43 );
44}, Layer.unwrap);
45
46export const layerMemory = Layer.provideMerge(
47 layerSetup,
48 NodeSqliteClient.layer({ filename: ":memory:" }),
49);
50
51export const layerConfig = Layer.unwrap(
52 Effect.gen(function* () {
53 const { dbPath } = yield* ServerConfig.ServerConfig;
54 yield* initializeV2Database(dbPath);
55 return layerFromPath(dbPath);
56 }),
57);
apps/server/src/persistence/initializeV2Database.test.ts+1-1
......@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";
1313import * as SqlClient from "effect/sql/SqlClient";
1414
1515import * as ServerConfig from "../config.ts";
16import * as SqlitePersistence from "./Layers/Sqlite.ts";
16import * as SqlitePersistence from "./Sqlite.ts";
1717import { runMigrations } from "./Migrations.ts";
1818import { initializeV2Database } from "./initializeV2Database.ts";
1919import * as EventStore from "../orchestration-v2/EventStore.ts";
apps/server/src/project/ManagedProjectFolders.test.ts+1-1
......@@ -13,7 +13,7 @@ import * as Scope from "effect/Scope";
1313import * as ServerConfig from "../config.ts";
1414import * as GitWorkflow from "../git/GitWorkflowService.ts";
1515import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
16import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
16import * as SqlitePersistence from "../persistence/Sqlite.ts";
1717import * as ProcessRunner from "../processRunner.ts";
1818import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1919import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
apps/server/src/project/ProjectService.deletion.test.ts+1-1
......@@ -27,7 +27,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
2727import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
2828import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
2929import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
30import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";
3131import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
3232import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
3333import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
apps/server/src/project/ProjectService.test.ts+1-1
......@@ -18,7 +18,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
1818import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
1919import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
2020import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
21import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
21import * as SqlitePersistence from "../persistence/Sqlite.ts";
2222import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2323import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
2424import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
apps/server/src/provider/AcpRegistryCatalog.ts created+22
......@@ -0,0 +1,22 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Path from "effect/Path";
4
5import * as ServerConfig from "../config.ts";
6import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts";
7import * as AcpRegistryRuntimeCoordinator from "./acp/AcpRegistryRuntimeCoordinator.ts";
8
9/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */
10export const layer = Layer.merge(
11 Layer.unwrap(
12 Effect.gen(function* () {
13 const config = yield* ServerConfig.ServerConfig;
14 const path = yield* Path.Path;
15 return AcpRegistrySupport.AcpRegistryCatalog.layer({
16 cacheDir: config.providerStatusCacheDir,
17 toolsDir: path.join(config.baseDir, "tools"),
18 });
19 }),
20 ),
21 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer,
22);
apps/server/src/provider/AntigravityAuth.ts+1-1
......@@ -28,7 +28,7 @@ import {
2828 forwardAntigravityCallback,
2929 validateAntigravityCallbackUrl,
3030} from "./antigravityCallback.ts";
31import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";
31import type { ProviderAuthController } from "./ProviderAuthService.ts";
3232
3333const AUTH_TIMEOUT_MS = 300_000;
3434const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again.";
apps/server/src/provider/AntigravityProvider.test.ts created+661
......@@ -0,0 +1,661 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 ANTIGRAVITY_DEFAULT_MODEL,
4 AntigravitySettings,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ProviderSetupError,
8} from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
17import * as EffectAcpErrors from "effect-acp/errors";
18import type * as EffectAcpSchema from "effect-acp/compat";
19
20import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
21import * as ServerSettings from "../serverSettings.ts";
22import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
23import {
24 buildAntigravityModelsFromSession,
25 makeAntigravityProvider,
26} from "./AntigravityProvider.ts";
27
28const decodeSettings = Schema.decodeSync(AntigravitySettings);
29const instanceId = ProviderInstanceId.make("antigravity-test");
30const driver = ProviderDriverKind.make("antigravity");
31
32const initializeResult = {
33 protocolVersion: 1,
34 agentCapabilities: {
35 loadSession: true,
36 promptCapabilities: { image: true, audio: true, embeddedContext: true },
37 sessionCapabilities: { list: {}, resume: {} },
38 },
39 authMethods: [{ id: "oauth-personal", name: "Log in with Google" }],
40 agentInfo: {
41 name: "antigravity-acp",
42 title: "Google Antigravity",
43 version: "agy_acp_server_20260818_01_RC01",
44 },
45} satisfies EffectAcpSchema.InitializeResponse;
46
47const modelOptions = [
48 { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" },
49 { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" },
50 { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" },
51 { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" },
52 { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" },
53 { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" },
54 { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" },
55 { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" },
56 { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" },
57 { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
58 { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
59];
60
61const modelConfig = {
62 id: "model",
63 name: "Model",
64 category: "model",
65 type: "select",
66 currentValue: "gemini-3.7-flash-high",
67 options: modelOptions,
68} satisfies EffectAcpSchema.SessionConfigOption;
69
70const sessionSetupResult = {
71 sessionId: "session-1",
72 models: {
73 currentModelId: "gemini-3.7-flash-high",
74 availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })),
75 },
76 configOptions: [
77 modelConfig,
78 {
79 id: "mode",
80 name: "Session Mode",
81 category: "mode",
82 type: "select",
83 currentValue: "default",
84 options: [
85 { value: "default", name: "Default" },
86 { value: "yolo", name: "YOLO" },
87 ],
88 },
89 ],
90} satisfies EffectAcpSchema.NewSessionResponse;
91
92const started = {
93 sessionId: "session-1",
94 initializeResult,
95 sessionSetupResult,
96 modelConfigId: "model",
97} satisfies AcpSessionRuntimeStartResult;
98
99const commands = [
100 { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } },
101 { name: "logout", description: "Sign out of Google" },
102] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>;
103
104const expectedCommands = [
105 { name: "plan", description: "Create a plan", input: { hint: "What to plan" } },
106 { name: "logout", description: "Sign out of Google" },
107];
108
109const layerTest = Layer.merge(
110 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
111 shouldRunScopeWork: () => Effect.succeed(false),
112 }),
113 ServerSettings.layerTest(),
114);
115
116type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError;
117
118const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* (
119 options: { readonly enabled?: boolean; readonly safe?: boolean } = {},
120) {
121 const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>();
122 const probeCalls = yield* Ref.make(0);
123 const safetyCalls = yield* Ref.make(0);
124 const probe = yield* Ref.make<Effect.Effect<EffectAcpSchema.InitializeResponse, ProbeError>>(
125 Deferred.await(initialProbe),
126 );
127 const safety = yield* Ref.make<Effect.Effect<boolean>>(Effect.succeed(options.safe ?? true));
128 const provider = yield* makeAntigravityProvider(
129 decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }),
130 {
131 stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }),
132 probe: Ref.update(probeCalls, (count) => count + 1).pipe(
133 Effect.andThen(Ref.get(probe)),
134 Effect.flatten,
135 ),
136 supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe(
137 Effect.andThen(Ref.get(safety)),
138 Effect.flatten,
139 ),
140 },
141 );
142 const initialUpdate = yield* Stream.toPull(
143 provider.snapshot.streamChanges.pipe(
144 Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"),
145 ),
146 );
147 const initialize = Deferred.succeed(initialProbe, initializeResult).pipe(
148 Effect.andThen(initialUpdate),
149 Effect.asVoid,
150 );
151 return {
152 provider,
153 probe,
154 probeCalls,
155 safety,
156 safetyCalls,
157 initialProbe,
158 initialUpdate,
159 initialize,
160 };
161});
162
163describe("Antigravity model catalog", () => {
164 it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => {
165 const models = buildAntigravityModelsFromSession(sessionSetupResult);
166 expect(models.map((model) => [model.slug, model.name])).toEqual(
167 modelOptions.map((option) => [option.value, option.name]),
168 );
169 expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([
170 "gemini-3.7-flash-high",
171 ]);
172 expect(
173 models
174 .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))
175 .map((model) => model.slug),
176 ).toEqual(["gemini-3.7-flash-high"]);
177 expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true);
178 expect(models.every((model) => !model.isCustom)).toBe(true);
179 });
180
181 it("uses legacy session models only when model config is absent", () => {
182 const fromLegacy = buildAntigravityModelsFromSession({
183 models: sessionSetupResult.models,
184 });
185 expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
186 expect(
187 buildAntigravityModelsFromSession({
188 ...sessionSetupResult,
189 configOptions: [{ ...modelConfig, options: [] }],
190 }),
191 ).toEqual([]);
192 });
193
194 it("flattens native option groups without combining distinct model IDs", () => {
195 const models = buildAntigravityModelsFromSession({
196 configOptions: [
197 {
198 ...modelConfig,
199 currentValue: "gemini-pro-agent",
200 options: [
201 { groupId: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] },
202 { groupId: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] },
203 ],
204 },
205 ],
206 });
207 expect(models.map((model) => model.slug)).toEqual([
208 "gemini-3.7-flash-high",
209 "gemini-3.7-flash-medium",
210 "gemini-pro-agent",
211 ]);
212 expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent");
213 expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe(
214 "gemini-pro-agent",
215 );
216 });
217});
218
219it.layer(layerTest)("Antigravity provider snapshots", (it) => {
220 it.effect("does not probe or run helper safety checks while disabled", () =>
221 Effect.scoped(
222 Effect.gen(function* () {
223 const harness = yield* makeHarness({ enabled: false });
224 const snapshot = yield* harness.provider.snapshot.refresh;
225 expect(snapshot).toMatchObject({
226 enabled: false,
227 installed: false,
228 status: "disabled",
229 auth: { status: "unknown" },
230 models: [],
231 setup: { canAuthenticate: true, canInstall: true },
232 showInteractionModeToggle: false,
233 supportsConversationRollback: false,
234 supportsTextGeneration: false,
235 });
236 expect(yield* Ref.get(harness.probeCalls)).toBe(0);
237 expect(yield* Ref.get(harness.safetyCalls)).toBe(0);
238 }),
239 ),
240 );
241
242 it.effect("records explicit sign-in while disabled without starting a health probe", () =>
243 Effect.scoped(
244 Effect.gen(function* () {
245 const harness = yield* makeHarness({ enabled: false });
246 const signedIn = yield* Stream.toPull(
247 harness.provider.snapshot.streamChanges.pipe(
248 Stream.filter(
249 (snapshot) =>
250 snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0,
251 ),
252 ),
253 );
254 yield* harness.provider.onSessionStarted(started);
255 yield* harness.provider.onAvailableCommands(commands);
256 const [snapshot] = yield* signedIn;
257 expect(snapshot).toMatchObject({
258 enabled: false,
259 installed: true,
260 status: "disabled",
261 auth: { status: "authenticated", type: "oauth-personal" },
262 workspaceSnapshots: [],
263 });
264 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
265 expect(snapshot.slashCommands).toEqual(expectedCommands);
266 expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models);
267 expect(yield* Ref.get(harness.probeCalls)).toBe(0);
268 }),
269 ),
270 );
271
272 it.effect("publishes the configured sign-in method before any account is checked", () =>
273 Effect.scoped(
274 Effect.gen(function* () {
275 const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), {
276 stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }),
277 probe: Effect.succeed(initializeResult),
278 supportsTextGeneration: Effect.succeed(true),
279 auth: { type: "gemini-api-key", label: "Gemini API key" },
280 });
281 expect((yield* provider.snapshot.getSnapshot).auth).toEqual({
282 status: "unknown",
283 type: "gemini-api-key",
284 });
285 }),
286 ),
287 );
288
289 it.effect("treats initialize as installation proof, not account or model discovery", () =>
290 Effect.scoped(
291 Effect.gen(function* () {
292 const harness = yield* makeHarness();
293 expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({
294 installed: false,
295 status: "warning",
296 auth: { status: "unknown" },
297 models: [],
298 });
299 yield* harness.initialize;
300 const snapshot = yield* harness.provider.snapshot.getSnapshot;
301 expect(snapshot).toMatchObject({
302 installed: true,
303 status: "warning",
304 version: "agy_acp_server_20260818_01_RC01",
305 auth: { status: "unknown" },
306 models: [],
307 });
308 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
309 }),
310 ),
311 );
312
313 it.effect("publishes session metadata and native commands without another health probe", () =>
314 Effect.scoped(
315 Effect.gen(function* () {
316 const harness = yield* makeHarness();
317 yield* harness.initialize;
318 const nextReady = yield* Stream.toPull(
319 harness.provider.snapshot.streamChanges.pipe(
320 Stream.filter(
321 (snapshot) =>
322 snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0,
323 ),
324 ),
325 );
326 yield* harness.provider.onSessionStarted(started, "/workspace");
327 yield* harness.provider.onAvailableCommands(commands, "/workspace");
328 const [snapshot] = yield* nextReady;
329 expect(snapshot).toMatchObject({
330 status: "ready",
331 auth: { status: "authenticated", type: "oauth-personal" },
332 supportsTextGeneration: true,
333 });
334 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
335 expect(snapshot.slashCommands).toEqual(expectedCommands);
336 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual(
337 expectedCommands,
338 );
339 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
340 }),
341 ),
342 );
343
344 it.effect("does not retain a disposable sign-in workspace", () =>
345 Effect.scoped(
346 Effect.gen(function* () {
347 const harness = yield* makeHarness();
348 yield* harness.initialize;
349 yield* harness.provider.onSessionStarted(started);
350 yield* harness.provider.onAvailableCommands(commands);
351 const snapshot = yield* harness.provider.snapshot.getSnapshot;
352 expect(snapshot.models).toHaveLength(11);
353 expect(snapshot.slashCommands).toEqual(expectedCommands);
354 expect(snapshot.workspaceSnapshots).toEqual([]);
355 }),
356 ),
357 );
358
359 it.effect("clears all account metadata on sign-out and authentication failure", () =>
360 Effect.scoped(
361 Effect.gen(function* () {
362 const harness = yield* makeHarness();
363 yield* harness.initialize;
364 for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) {
365 yield* harness.provider.onSessionStarted(started, "/workspace");
366 yield* harness.provider.onAvailableCommands(commands, "/workspace");
367 yield* clear;
368 expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({
369 installed: true,
370 status: "warning",
371 auth: { status: "unauthenticated" },
372 models: [],
373 slashCommands: [],
374 skills: [],
375 workspaceSnapshots: [],
376 supportsTextGeneration: false,
377 });
378 yield* harness.provider.onAvailableCommands(commands, "/workspace");
379 yield* harness.provider.onConfigOptionsUpdated([modelConfig]);
380 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]);
381 expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]);
382 }
383 const refreshed = yield* harness.provider.snapshot.refresh;
384 expect(refreshed.auth.status).toBe("unauthenticated");
385 expect(refreshed.supportsTextGeneration).toBe(false);
386 }),
387 ),
388 );
389
390 it.effect("replaces live model choices and accepts an empty catalog", () =>
391 Effect.scoped(
392 Effect.gen(function* () {
393 const harness = yield* makeHarness();
394 yield* harness.initialize;
395 yield* harness.provider.onSessionStarted(started, "/workspace");
396 yield* harness.provider.onAvailableCommands(commands, "/workspace");
397 const before = yield* harness.provider.snapshot.getSnapshot;
398 const configOptions = [
399 {
400 ...modelConfig,
401 currentValue: "gemini-3.8-flash-high",
402 options: modelOptions.slice(0, 3),
403 },
404 ];
405 const nextSnapshot = yield* Stream.toPull(
406 harness.provider.snapshot.streamChanges.pipe(
407 Stream.filter((snapshot) => snapshot.models.length === 3),
408 ),
409 );
410 yield* harness.provider.onConfigOptionsUpdated(configOptions);
411 expect((yield* nextSnapshot)[0]).toMatchObject({
412 models: buildAntigravityModelsFromSession({ configOptions }),
413 auth: before.auth,
414 workspaceSnapshots: before.workspaceSnapshots,
415 slashCommands: expectedCommands,
416 });
417 yield* harness.provider.onConfigOptionsUpdated([]);
418 expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]);
419 }),
420 ),
421 );
422
423 it.effect("replaces one account's catalog instead of combining accounts", () =>
424 Effect.scoped(
425 Effect.gen(function* () {
426 const harness = yield* makeHarness();
427 yield* harness.initialize;
428 yield* harness.provider.onSessionStarted(started);
429 yield* harness.provider.onSignedOut;
430 yield* harness.provider.onSessionStarted({
431 ...started,
432 sessionSetupResult: {
433 sessionId: "new-account-session",
434 configOptions: [
435 { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] },
436 ],
437 },
438 });
439 expect(
440 (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug),
441 ).toEqual(["gemini-pro-agent"]);
442 }),
443 ),
444 );
445
446 it.effect("retains known account metadata when a local health check fails", () =>
447 Effect.scoped(
448 Effect.gen(function* () {
449 const harness = yield* makeHarness();
450 yield* harness.initialize;
451 yield* harness.provider.onSessionStarted(started, "/workspace");
452 yield* harness.provider.onAvailableCommands(commands, "/workspace");
453 yield* Ref.set(
454 harness.probe,
455 Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")),
456 );
457 const snapshot = yield* harness.provider.snapshot.refresh;
458 expect(snapshot).toMatchObject({
459 installed: true,
460 status: "error",
461 auth: { status: "authenticated" },
462 });
463 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
464 expect(snapshot.slashCommands).toEqual(expectedCommands);
465 expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace");
466 }),
467 ),
468 );
469
470 it.effect("allows a slow packaged runtime health check to finish", () =>
471 Effect.scoped(
472 Effect.gen(function* () {
473 const harness = yield* makeHarness();
474 yield* harness.initialize;
475 const entered = yield* Deferred.make<void>();
476 const initialized = yield* Deferred.make<EffectAcpSchema.InitializeResponse>();
477 yield* Ref.set(
478 harness.probe,
479 Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))),
480 );
481
482 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
483 yield* Deferred.await(entered);
484 yield* TestClock.adjust("47 seconds");
485 yield* Deferred.succeed(initialized, initializeResult);
486 const snapshot = yield* Fiber.join(refresh);
487
488 expect(snapshot).toMatchObject({
489 installed: true,
490 status: "warning",
491 auth: { status: "unknown" },
492 });
493 }),
494 ),
495 );
496
497 it.effect("closes a stalled health probe at its deadline", () =>
498 Effect.scoped(
499 Effect.gen(function* () {
500 const harness = yield* makeHarness();
501 yield* harness.initialize;
502 const entered = yield* Deferred.make<void>();
503 const closed = yield* Deferred.make<void>();
504 yield* Ref.set(
505 harness.probe,
506 Deferred.succeed(entered, undefined).pipe(
507 Effect.andThen(Effect.never),
508 Effect.ensuring(Deferred.succeed(closed, undefined)),
509 ),
510 );
511 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
512 yield* Deferred.await(entered);
513 yield* TestClock.adjust("90 seconds");
514 const snapshot = yield* Fiber.join(refresh);
515 yield* Deferred.await(closed);
516 expect(snapshot.status).toBe("error");
517 expect(snapshot.message).toContain("90 seconds");
518 }),
519 ),
520 );
521
522 it.effect("distinguishes missing executables from a failed installed executable", () =>
523 Effect.scoped(
524 Effect.gen(function* () {
525 const harness = yield* makeHarness();
526 yield* harness.initialize;
527 const failures = [
528 {
529 error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }),
530 installed: false,
531 },
532 {
533 error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }),
534 installed: true,
535 },
536 {
537 error: new ProviderSetupError({
538 instanceId,
539 operation: "resolve",
540 detail: "Antigravity is not installed.",
541 }),
542 installed: false,
543 },
544 ];
545 for (const { error, installed } of failures) {
546 yield* harness.provider.onSessionStarted(started, "/workspace");
547 yield* harness.provider.onAvailableCommands(commands, "/workspace");
548 yield* Ref.set(harness.probe, Effect.fail(error));
549 const snapshot = yield* harness.provider.snapshot.refresh;
550 expect(snapshot).toMatchObject({
551 installed,
552 status: "error",
553 auth: { status: "authenticated" },
554 });
555 expect(snapshot.models).toHaveLength(installed ? 11 : 0);
556 expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0);
557 expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0);
558 expect(snapshot.supportsTextGeneration).toBe(installed);
559 }
560 }),
561 ),
562 );
563
564 it.effect("does not let an old health result restore a signed-out account", () =>
565 Effect.scoped(
566 Effect.gen(function* () {
567 const harness = yield* makeHarness();
568 yield* harness.initialize;
569 yield* harness.provider.onSessionStarted(started);
570 const releaseProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse>();
571 const probeEntered = yield* Deferred.make<void>();
572 yield* Ref.set(
573 harness.probe,
574 Deferred.succeed(probeEntered, undefined).pipe(
575 Effect.andThen(Deferred.await(releaseProbe)),
576 ),
577 );
578 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
579 yield* Deferred.await(probeEntered);
580 yield* harness.provider.onSignedOut;
581 yield* Deferred.succeed(releaseProbe, initializeResult);
582 const snapshot = yield* Fiber.join(refresh);
583 expect(snapshot).toMatchObject({
584 auth: { status: "unauthenticated" },
585 models: [],
586 supportsTextGeneration: false,
587 });
588 }),
589 ),
590 );
591
592 it.effect("exposes helper support only when the supplied safety check allows it", () =>
593 Effect.scoped(
594 Effect.gen(function* () {
595 const harness = yield* makeHarness({ safe: false });
596 yield* harness.initialize;
597 yield* harness.provider.onSessionStarted(started);
598 expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false);
599 yield* Ref.set(harness.safety, Effect.succeed(true));
600 yield* harness.provider.snapshot.refresh;
601 expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true);
602 }),
603 ),
604 );
605
606 it.effect("keeps discovered workspace skills through session and command updates", () =>
607 Effect.scoped(
608 Effect.gen(function* () {
609 const harness = yield* makeHarness();
610 yield* harness.initialize;
611 const skills = [
612 {
613 name: "deploy",
614 description: "Ship it",
615 path: "/workspace/.agent/skills/deploy",
616 enabled: true,
617 },
618 ];
619 const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills);
620 expect(discovered.skills).toEqual(skills);
621 yield* harness.provider.onSessionStarted(started, "/workspace");
622 yield* harness.provider.onAvailableCommands(commands, "/workspace");
623 const after = yield* harness.provider.snapshot.getSnapshot;
624 expect(
625 after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills,
626 ).toEqual(skills);
627 expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills);
628
629 const rescanned = [
630 ...skills,
631 { name: "review", path: "/workspace/.agent/skills/review", enabled: true },
632 ];
633 yield* harness.provider.snapshotForCwd("/workspace", rescanned);
634 yield* harness.provider.onSessionStarted(started, "/workspace");
635 expect(
636 (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find(
637 (entry) => entry.cwd === "/workspace",
638 )?.skills,
639 ).toEqual(rescanned);
640 }),
641 ),
642 );
643
644 it.effect("bounds workspace metadata without starting sessions for workspace lookup", () =>
645 Effect.scoped(
646 Effect.gen(function* () {
647 const harness = yield* makeHarness();
648 yield* harness.initialize;
649 yield* harness.provider.onSessionStarted(started);
650 for (let index = 0; index < 35; index++) {
651 yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`);
652 }
653 const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34");
654 expect(snapshot.workspaceSnapshots).toHaveLength(32);
655 expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3");
656 expect(snapshot.slashCommands).toEqual(expectedCommands);
657 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
658 }),
659 ),
660 );
661});
apps/server/src/provider/AntigravityProvider.ts created+415
......@@ -0,0 +1,415 @@
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 type AntigravitySettings,
5 type ProviderSetupError,
6 type ServerProvider,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Predicate from "effect/Predicate";
15import * as Result from "effect/Result";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import type * as EffectAcpErrors from "effect-acp/errors";
19import type * as EffectAcpSchema from "effect-acp/compat";
20
21import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
22import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
23import {
24 makeManualOnlyProviderMaintenanceCapabilities,
25 type ProviderMaintenanceCapabilities,
26} from "./providerMaintenance.ts";
27import {
28 buildServerProvider,
29 isCommandMissingCause,
30 type ServerProviderDraft,
31} from "./providerSnapshot.ts";
32
33const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
34const MAX_WORKSPACE_SNAPSHOTS = 32;
35const HEALTH_CHECK_TIMEOUT = "90 seconds";
36const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";
37const AUTH_UNCHECKED_MESSAGE =
38 "Antigravity is installed. Google account access is not checked yet.";
39
40type SessionSetupResult = Pick<
41 AcpSessionRuntimeStartResult["sessionSetupResult"],
42 "configOptions" | "models"
43>;
44
45/** Keep the native model IDs, including model-specific thinking levels. */
46export function buildAntigravityModelsFromSession(
47 setup: SessionSetupResult,
48): ReadonlyArray<ServerProviderModel> {
49 const config = setup.configOptions?.find(
50 (option) => option.id === "model" || option.category === "model",
51 );
52 const currentValue =
53 config?.type === "select" ? config.currentValue : setup.models?.currentModelId;
54 const entries =
55 config?.type === "select"
56 ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options))
57 : config === undefined
58 ? (setup.models?.availableModels.map((model) => ({
59 value: model.modelId,
60 name: model.name,
61 })) ?? [])
62 : [];
63 const seen = new Set<string>();
64 return entries.flatMap((entry): ServerProviderModel[] => {
65 if (!entry.value.trim() || seen.has(entry.value)) return [];
66 seen.add(entry.value);
67 return [
68 {
69 slug: entry.value,
70 name: entry.name.trim() ? entry.name : entry.value,
71 isCustom: false,
72 ...(entry.value === currentValue
73 ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }
74 : {}),
75 capabilities: EMPTY_MODEL_CAPABILITIES,
76 },
77 ];
78 });
79}
80
81function nativeCommands(
82 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
83): ReadonlyArray<ServerProviderSlashCommand> {
84 const seen = new Set<string>();
85 return commands.flatMap((command): ServerProviderSlashCommand[] => {
86 if (!command.name.trim() || seen.has(command.name)) return [];
87 seen.add(command.name);
88 const description = command.description.trim();
89 const hint = command.input?.hint.trim();
90 return [
91 {
92 name: command.name,
93 ...(description ? { description } : {}),
94 ...(hint ? { input: { hint } } : {}),
95 },
96 ];
97 });
98}
99
100function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean {
101 if (error._tag === "AcpSpawnError") {
102 return (
103 isCommandMissingCause(error.cause) ||
104 (Predicate.isObject(error.cause) && error.cause.code === "ENOENT")
105 );
106 }
107 return (
108 error._tag === "ProviderSetupError" &&
109 error.operation === "resolve" &&
110 /not installed|missing|incomplete|does not publish/i.test(error.detail)
111 );
112}
113
114interface AntigravityProviderState {
115 readonly draft: ServerProviderDraft;
116 readonly authRevision: number;
117}
118
119interface AntigravityProviderOptions {
120 readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect<ServerProvider>;
121 readonly probe: Effect.Effect<
122 EffectAcpSchema.InitializeResponse,
123 EffectAcpErrors.AcpError | ProviderSetupError
124 >;
125 readonly supportsTextGeneration: Effect.Effect<boolean>;
126 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
127 /** Auth type and label published once a session authenticates. */
128 readonly auth?: { readonly type: string; readonly label: string };
129}
130
131/** Health uses initialize only. Session callbacks supply account-specific metadata. */
132export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* (
133 settings: AntigravitySettings,
134 options: AntigravityProviderOptions,
135) {
136 const checkedAt = DateTime.formatIso(yield* DateTime.now);
137 const initialDraft = {
138 ...buildServerProvider({
139 presentation: { displayName: "Antigravity", showInteractionModeToggle: false },
140 enabled: settings.enabled,
141 checkedAt,
142 models: [],
143 probe: {
144 installed: false,
145 version: null,
146 status: "warning",
147 // The configured method rides along so the registry can tell a saved
148 // account for this method from one left by a previous configuration.
149 auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) },
150 message: settings.enabled
151 ? "Checking Antigravity availability."
152 : "Antigravity is disabled in T3 Code settings.",
153 },
154 }),
155 setup: { canAuthenticate: true, canInstall: true },
156 supportsConversationRollback: false,
157 supportsTextGeneration: false,
158 workspaceSnapshots: [],
159 } satisfies ServerProviderDraft;
160 const metadata = yield* SubscriptionRef.make<AntigravityProviderState>({
161 draft: initialDraft,
162 authRevision: 0,
163 });
164 // Skills the driver discovered on disk per workspace. Session callbacks
165 // rewrite the workspace entry with native commands and must keep these, or
166 // the registry drops the suggestions and never re-reads the workspace.
167 const discoveredSkills = new Map<string, ServerProvider["skills"]>();
168 const getSnapshot = SubscriptionRef.get(metadata).pipe(
169 Effect.flatMap((state) => options.stampIdentity(state.draft)),
170 );
171
172 const checkProvider = Effect.fn("checkAntigravityProvider")(function* () {
173 if (!settings.enabled) return yield* getSnapshot;
174 const before = yield* SubscriptionRef.get(metadata);
175 const result = yield* options.probe.pipe(
176 Effect.timeoutOption(HEALTH_CHECK_TIMEOUT),
177 Effect.result,
178 );
179 const initialized =
180 Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined;
181 const failure = Result.isFailure(result) ? result.failure : undefined;
182 const missingInstallation = failure !== undefined && isMissingInstallation(failure);
183 const errorMessage =
184 initialized !== undefined
185 ? undefined
186 : failure?._tag === "ProviderSetupError"
187 ? failure.detail.trim() || "Antigravity could not complete its local health check."
188 : missingInstallation
189 ? "Antigravity is not installed or its executable could not be found."
190 : failure
191 ? "Antigravity could not complete its local health check."
192 : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`;
193 const supportsTextGeneration =
194 initialized !== undefined ? yield* options.supportsTextGeneration : false;
195 const updatedAt = DateTime.formatIso(yield* DateTime.now);
196 const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => {
197 if (state.authRevision !== before.authRevision) return state;
198 const { message: _previousMessage, ...draft } = state.draft;
199 const authenticated = draft.auth.status === "authenticated";
200 const message =
201 errorMessage ??
202 (authenticated
203 ? undefined
204 : draft.auth.status === "unauthenticated"
205 ? SIGN_IN_MESSAGE
206 : AUTH_UNCHECKED_MESSAGE);
207 return {
208 ...state,
209 draft: {
210 ...draft,
211 installed: !missingInstallation,
212 version: initialized?.agentInfo?.version || draft.version,
213 status: errorMessage ? "error" : authenticated ? "ready" : "warning",
214 checkedAt: updatedAt,
215 ...(missingInstallation
216 ? {
217 models: [],
218 slashCommands: [],
219 skills: [],
220 workspaceSnapshots: [],
221 supportsTextGeneration: false,
222 }
223 : {}),
224 ...(initialized !== undefined
225 ? {
226 supportsTextGeneration:
227 supportsTextGeneration && draft.auth.status !== "unauthenticated",
228 }
229 : {}),
230 ...(message ? { message } : {}),
231 },
232 } satisfies AntigravityProviderState;
233 });
234 return yield* options.stampIdentity(next.draft);
235 });
236
237 const maintenanceCapabilities =
238 options.maintenanceCapabilities ??
239 makeManualOnlyProviderMaintenanceCapabilities({
240 provider: ProviderDriverKind.make("antigravity"),
241 packageName: null,
242 });
243 const managed = yield* makeManagedServerProvider({
244 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
245 getSettings: Effect.succeed(settings),
246 streamSettings: Stream.empty,
247 haveSettingsChanged: () => false,
248 initialSnapshot: () => getSnapshot,
249 checkProvider: checkProvider(),
250 enrichSnapshot: ({ publishSnapshot }) =>
251 SubscriptionRef.changes(metadata).pipe(
252 Stream.runForEach((state) =>
253 options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)),
254 ),
255 ),
256 });
257
258 const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* (
259 started: AcpSessionRuntimeStartResult,
260 cwd?: string,
261 ) {
262 const before = yield* SubscriptionRef.get(metadata);
263 const supportsTextGeneration = yield* options.supportsTextGeneration;
264 const updatedAt = DateTime.formatIso(yield* DateTime.now);
265 yield* SubscriptionRef.update(metadata, (state) => {
266 if (
267 state.authRevision !== before.authRevision &&
268 state.draft.auth.status === "unauthenticated"
269 ) {
270 return state;
271 }
272 const { message: _previousMessage, ...draft } = state.draft;
273 const workspaces = draft.workspaceSnapshots ?? [];
274 const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined;
275 return {
276 authRevision: state.authRevision + 1,
277 draft: {
278 ...draft,
279 installed: true,
280 status: settings.enabled ? "ready" : "disabled",
281 version: started.initializeResult.agentInfo?.version || draft.version,
282 auth: {
283 status: "authenticated",
284 type: options.auth?.type ?? "oauth-personal",
285 label: options.auth?.label ?? "Google account",
286 },
287 checkedAt: updatedAt,
288 models: buildAntigravityModelsFromSession(started.sessionSetupResult),
289 supportsTextGeneration,
290 ...(cwd
291 ? {
292 workspaceSnapshots: [
293 ...workspaces.filter((entry) => entry.cwd !== cwd),
294 {
295 cwd,
296 checkedAt: updatedAt,
297 slashCommands: workspace?.slashCommands ?? draft.slashCommands,
298 skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [],
299 },
300 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
301 }
302 : {}),
303 },
304 } satisfies AntigravityProviderState;
305 });
306 });
307
308 const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* (
309 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
310 ) {
311 const models = buildAntigravityModelsFromSession({ configOptions });
312 yield* SubscriptionRef.update(metadata, (state) => {
313 if (state.draft.auth.status !== "authenticated") return state;
314 return { ...state, draft: { ...state.draft, models } };
315 });
316 });
317
318 const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* (
319 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
320 cwd?: string,
321 ) {
322 const slashCommands = nativeCommands(commands);
323 const updatedAt = DateTime.formatIso(yield* DateTime.now);
324 yield* SubscriptionRef.update(metadata, (state) => {
325 if (state.draft.auth.status === "unauthenticated") return state;
326 return {
327 ...state,
328 draft: {
329 ...state.draft,
330 slashCommands,
331 ...(cwd
332 ? {
333 workspaceSnapshots: [
334 ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd),
335 {
336 cwd,
337 checkedAt: updatedAt,
338 slashCommands,
339 skills:
340 state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ??
341 discoveredSkills.get(cwd) ??
342 [],
343 },
344 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
345 }
346 : {}),
347 },
348 };
349 });
350 });
351
352 const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () {
353 const updatedAt = DateTime.formatIso(yield* DateTime.now);
354 yield* SubscriptionRef.update(
355 metadata,
356 (state) =>
357 ({
358 authRevision: state.authRevision + 1,
359 draft: {
360 ...state.draft,
361 auth: { status: "unauthenticated" },
362 status: settings.enabled ? "warning" : "disabled",
363 message: SIGN_IN_MESSAGE,
364 checkedAt: updatedAt,
365 models: [],
366 slashCommands: [],
367 skills: [],
368 workspaceSnapshots: [],
369 supportsTextGeneration: false,
370 },
371 }) satisfies AntigravityProviderState,
372 );
373 discoveredSkills.clear();
374 });
375
376 const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* (
377 cwd: string,
378 skills?: ServerProvider["skills"],
379 ) {
380 if (skills) {
381 discoveredSkills.set(cwd, skills);
382 // A rescan replaces the stored entry's skills. Session callbacks and
383 // health checks republish that entry, so it must not keep old ones.
384 yield* SubscriptionRef.update(metadata, (state) =>
385 state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd)
386 ? {
387 ...state,
388 draft: {
389 ...state.draft,
390 workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) =>
391 entry.cwd === cwd ? { ...entry, skills } : entry,
392 ),
393 },
394 }
395 : state,
396 );
397 }
398 const snapshot = yield* getSnapshot;
399 const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd);
400 const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? [];
401 return workspace
402 ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills }
403 : { ...snapshot, skills: resolvedSkills };
404 });
405
406 return {
407 snapshot: { ...managed, getSnapshot },
408 onSessionStarted,
409 onConfigOptionsUpdated,
410 onAvailableCommands,
411 onSignedOut: clearAccountMetadata(),
412 onAuthRequired: clearAccountMetadata(),
413 snapshotForCwd,
414 };
415});
apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts created+355
......@@ -0,0 +1,355 @@
1// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
2import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3import { vi } from "vite-plus/test";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as TestClock from "effect/testing/TestClock";
7import {
8 ClaudeSettings,
9 ProviderDriverKind,
10 ProviderInstanceId,
11 type ServerProvider,
12} from "@t3tools/contracts";
13import * as NodeFSP from "node:fs/promises";
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { assert, it } from "@effect/vitest";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20
21import {
22 buildClaudeCapabilitiesProbeQueryOptions,
23 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
24 probeClaudeCapabilities,
25 probeClaudeWorkspaceSnapshot,
26} from "./ClaudeProvider.ts";
27import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts";
28
29vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
30
31const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
32
33it("isolates Claude capability probes without dropping workspace setting sources", () => {
34 const abortController = new AbortController();
35 const options = buildClaudeCapabilitiesProbeQueryOptions({
36 executablePath: "/usr/bin/claude",
37 abortController,
38 environment: {
39 HOME: "/home/user",
40 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
41 FORCE_CODE_TERMINAL: "1",
42 },
43 cwd: "/workspace/project",
44 });
45
46 assert.deepEqual(options.mcpServers, {});
47 assert.equal(options.strictMcpConfig, true);
48 assert.equal(options.cwd, "/workspace/project");
49 assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]);
50 assert.deepEqual(options.settings, { disableAllHooks: true });
51 assert.deepEqual(options.allowedTools, []);
52 assert.equal(options.persistSession, false);
53 assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude");
54 assert.equal(options.abortController, abortController);
55 assert.equal(options.env?.HOME, "/home/user");
56 assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
57 assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined);
58 assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0");
59 assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1");
60});
61
62it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
63 it.effect(
64 "discovers commands and skills separately for each cwd without replacing machine metadata",
65 () =>
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" });
70 const configDir = path.join(tempDir, "claude-home");
71 const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")];
72 for (const cwd of workspaces) {
73 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
74 yield* fs.makeDirectory(skillDir, { recursive: true });
75 yield* fs.writeFileString(
76 path.join(skillDir, "SKILL.md"),
77 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
78 );
79 }
80 const machineSnapshot = {
81 instanceId: ProviderInstanceId.make("claude"),
82 driver: ProviderDriverKind.make("claudeAgent"),
83 enabled: true,
84 installed: true,
85 status: "ready",
86 auth: { status: "authenticated", email: "machine@example.com" },
87 checkedAt: "2026-03-25T00:00:00.000Z",
88 version: "2.1.288",
89 models: [],
90 slashCommands: [{ name: "server-cwd-only" }],
91 skills: [],
92 } satisfies ServerProvider;
93 let usageCalls = 0;
94 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
95 assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir);
96 assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance");
97 return {
98 initializationResult: async () => ({
99 account: { email: "workspace@example.com" },
100 commands: [
101 {
102 name: options?.cwd === workspaces[0] ? "start-session" : "other-project",
103 description: "Project command",
104 argumentHint: "[topic]",
105 },
106 { name: "nested:review", description: "Review changes", argumentHint: "" },
107 { name: "NESTED:review", description: "", argumentHint: "[path]" },
108 { name: "compact", description: "Provider compact", argumentHint: "" },
109 { name: "user-command", description: "Existing user command", argumentHint: "" },
110 ],
111 }),
112 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => {
113 usageCalls++;
114 return { rate_limits_available: false, rate_limits: null };
115 },
116 } as ReturnType<typeof ClaudeSdk.query>;
117 });
118 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
119 for (const [index, cwd] of workspaces.entries()) {
120 const scoped = yield* probeClaudeWorkspaceSnapshot(
121 decodeClaudeSettings({ homePath: configDir }),
122 machineSnapshot,
123 cwd,
124 { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" },
125 );
126 assert.deepEqual(scoped, {
127 ...machineSnapshot,
128 slashCommandsPending: false,
129 slashCommands: [
130 COMPACT_SLASH_COMMAND,
131 {
132 name: index === 0 ? "start-session" : "other-project",
133 description: "Project command",
134 input: { hint: "[topic]" },
135 },
136 { name: "nested:review", description: "Review changes", input: { hint: "[path]" } },
137 { name: "user-command", description: "Existing user command" },
138 ],
139 skills: [
140 {
141 name: "existing-skill",
142 path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"),
143 enabled: true,
144 scope: "project",
145 description: "Existing project skill",
146 },
147 ],
148 });
149 }
150 assert.deepEqual(
151 query.mock.calls.map(([input]) => input.options?.cwd),
152 workspaces,
153 );
154 assert.equal(usageCalls, 0);
155 }).pipe(Effect.scoped),
156 );
157
158 it.effect("keeps readable skills during failed command discovery and recovers on retry", () =>
159 Effect.gen(function* () {
160 const fs = yield* FileSystem.FileSystem;
161 const path = yield* Path.Path;
162 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" });
163 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
164 yield* fs.makeDirectory(skillDir, { recursive: true });
165 yield* fs.writeFileString(
166 path.join(skillDir, "SKILL.md"),
167 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
168 );
169 const machineSnapshot = {
170 instanceId: ProviderInstanceId.make("claude"),
171 driver: ProviderDriverKind.make("claudeAgent"),
172 enabled: true,
173 installed: true,
174 status: "ready",
175 auth: { status: "authenticated" },
176 checkedAt: "2026-03-25T00:00:00.000Z",
177 version: "2.1.288",
178 models: [],
179 slashCommands: [{ name: "server-cwd-only" }],
180 skills: [],
181 } satisfies ServerProvider;
182 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
183 () =>
184 ({
185 initializationResult: () =>
186 Promise.reject<ClaudeSdk.SDKControlInitializeResponse>(
187 new Error("Initialization failed"),
188 ),
189 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
190 rate_limits_available: false,
191 rate_limits: null,
192 }),
193 }) as ReturnType<typeof ClaudeSdk.query>,
194 );
195 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
196 const settings = decodeClaudeSettings({ homePath: cwd });
197 const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
198 assert.deepEqual(failed, {
199 ...machineSnapshot,
200 slashCommands: [COMPACT_SLASH_COMMAND],
201 slashCommandsPending: true,
202 skills: [
203 {
204 name: "existing-skill",
205 path: path.join(skillDir, "SKILL.md"),
206 enabled: true,
207 scope: "project",
208 description: "Existing project skill",
209 },
210 ],
211 });
212 query.mockImplementation(
213 () =>
214 ({
215 initializationResult: async () => ({
216 commands: [{ name: "recovered", description: "", argumentHint: "" }],
217 }),
218 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
219 rate_limits_available: false,
220 rate_limits: null,
221 }),
222 }) as ReturnType<typeof ClaudeSdk.query>,
223 );
224 const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
225 assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]);
226 assert.equal(recovered.status, "ready");
227 assert.equal(recovered.slashCommandsPending, false);
228 assert.deepEqual(recovered.skills, failed.skills);
229 const disabled = yield* probeClaudeWorkspaceSnapshot(
230 { ...settings, enabled: false },
231 machineSnapshot,
232 cwd,
233 );
234 assert.equal(disabled, machineSnapshot);
235 assert.equal(query.mock.calls.length, 2);
236 }).pipe(Effect.scoped),
237 );
238
239 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
240 Effect.gen(function* () {
241 const fs = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" });
244 const executablePath = yield* path.fromFileUrl(
245 new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url),
246 );
247 const invocationPath = path.join(tempDir, "invocation.json");
248 // The probe aborts the SDK without awaiting the child's exit, and on
249 // Windows a directory that is still some process's cwd cannot be
250 // removed. Keep the workspace outside the scoped directory and let it
251 // go with a retrying removal once the child has gone.
252 const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" });
253 // Node's own retry rather than an Effect schedule: it.effect runs on a
254 // TestClock, so a scheduled retry would wait for time nobody advances.
255 // If the child still holds the directory after that, an empty temp
256 // directory is left behind rather than failing the test for it.
257 yield* Effect.addFinalizer(() =>
258 Effect.promise(() =>
259 NodeFSP.rm(workspaceCwd, {
260 recursive: true,
261 force: true,
262 maxRetries: 20,
263 retryDelay: 250,
264 }).catch(() => undefined),
265 ),
266 );
267
268 const capabilities = yield* probeClaudeCapabilities(
269 decodeClaudeSettings({ binaryPath: executablePath }),
270 {
271 ...process.env,
272 T3_PROBE_INVOCATION_PATH: invocationPath,
273 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
274 },
275 workspaceCwd,
276 );
277
278 assert.deepEqual(capabilities, {
279 email: "dev@example.com",
280 subscriptionType: "pro",
281 tokenSource: "oauth",
282 apiProvider: undefined,
283 slashCommands: [
284 {
285 name: "review",
286 description: "Review changes",
287 input: { hint: "[path]" },
288 },
289 ],
290 usage: {
291 rate_limits_available: true,
292 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
293 },
294 });
295
296 // @effect-diagnostics-next-line preferSchemaOverJson:off
297 const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as {
298 readonly args: ReadonlyArray<string>;
299 readonly cwd: string;
300 readonly connectorEnv: string;
301 readonly mcpConfig: unknown;
302 };
303 assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd));
304 assert.equal(invocation.connectorEnv, "false");
305 assert.equal(invocation.args.includes("--strict-mcp-config"), true);
306 assert.equal(invocation.args.includes("--mcp-config"), false);
307 assert.equal(invocation.mcpConfig, undefined);
308
309 assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true);
310
311 const settingsFlagIndex = invocation.args.indexOf("--settings");
312 assert.notEqual(settingsFlagIndex, -1);
313 // @effect-diagnostics-next-line preferSchemaOverJson:off
314 const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as {
315 readonly disableAllHooks?: boolean;
316 };
317 assert.equal(flagSettings.disableAllHooks, true);
318 }).pipe(Effect.scoped),
319 );
320});
321
322it.effect("preserves initialized capabilities when optional usage times out", () =>
323 Effect.gen(function* () {
324 const usageStarted = yield* Deferred.make<void>();
325 let abortSignal: AbortSignal | undefined;
326 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
327 abortSignal = options?.abortController?.signal;
328 return {
329 initializationResult: async () => ({
330 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
331 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
332 }),
333 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => {
334 Deferred.doneUnsafe(usageStarted, Effect.void);
335 return new Promise(() => {});
336 },
337 } as ReturnType<typeof ClaudeSdk.query>;
338 });
339 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
340 const probe = yield* probeClaudeCapabilities(
341 decodeClaudeSettings({ binaryPath: "claude" }),
342 ).pipe(Effect.forkChild);
343 yield* Deferred.await(usageStarted);
344 yield* TestClock.adjust("4 seconds");
345 const capabilities = yield* Fiber.join(probe);
346 assert.equal(capabilities?.email, "dev@example.com");
347 assert.equal(capabilities?.subscriptionType, "pro");
348 assert.equal(capabilities?.tokenSource, "oauth");
349 assert.deepEqual(capabilities?.slashCommands, [
350 { name: "review", description: "Review changes", input: { hint: "[path]" } },
351 ]);
352 assert.equal(capabilities?.usage, undefined);
353 assert.equal(abortSignal?.aborted, true);
354 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
355);
apps/server/src/provider/ClaudeProvider.ts created+675
......@@ -0,0 +1,675 @@
1import {
2 type ClaudeSettings,
3 type ModelCapabilities,
4 type ServerProvider,
5 type ServerProviderSlashCommand,
6 type ServerProviderResetCredits,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Ref from "effect/Ref";
14import * as Result from "effect/Result";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16import { createModelCapabilities } from "@t3tools/shared/model";
17import { resolveSpawnCommand } from "@t3tools/shared/shell";
18import {
19 query as claudeQuery,
20 type Options as ClaudeQueryOptions,
21 type SlashCommand as ClaudeSlashCommand,
22 type SDKControlGetUsageResponse,
23 type SDKUserMessage,
24 type SettingSource,
25} from "@anthropic-ai/claude-agent-sdk";
26
27import {
28 buildServerProvider,
29 COMPACT_SLASH_COMMAND,
30 DEFAULT_TIMEOUT_MS,
31 isCommandMissingCause,
32 parseGenericCliVersion,
33 providerModelsFromSettings,
34 spawnAndCollect,
35 type ServerProviderDraft,
36} from "./providerSnapshot.ts";
37import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts";
38import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts";
39import { discoverClaudeSkills } from "./Drivers/ClaudeSkills.ts";
40import type { ProviderWorkspaceSnapshot } from "./ProviderDriver.ts";
41import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts";
42import {
43 type ClaudeScopedLimitNames,
44 claudeUsageResponseToLimits,
45 recordClaudeUsageResponse,
46} from "./claudeUsageLimits.ts";
47import {
48 BUNDLED_CLAUDE_MODEL_CATALOG,
49 type ClaudeModelCatalog,
50 formatClaudeVersionUpgradeMessage,
51 resolveClaudeModelsForVersion,
52} from "./ClaudeModelCatalog.ts";
53
54const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
55 optionDescriptors: [],
56});
57
58const CLAUDE_PRESENTATION = {
59 displayName: "Claude",
60 showInteractionModeToggle: true,
61 reportsContextWindow: true,
62} as const;
63function toTitleCaseWords(value: string): string {
64 const parts: Array<string> = [];
65 for (const part of value.split(/[\s_-]+/g)) {
66 if (part.length > 0) {
67 parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase());
68 }
69 }
70 return parts.join(" ");
71}
72
73function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined {
74 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
75 if (!normalized) return undefined;
76
77 switch (normalized) {
78 case "claudemaxsubscription":
79 return "Max";
80 case "claudemax5xsubscription":
81 return "Max 5x";
82 case "claudemax20xsubscription":
83 return "Max 20x";
84 case "claudeenterprisesubscription":
85 return "Enterprise";
86 case "claudeteamsubscription":
87 return "Team";
88 case "claudeprosubscription":
89 return "Pro";
90 case "claudefreesubscription":
91 return "Free";
92 case "max":
93 case "maxplan":
94 return "Max";
95 case "max5":
96 return "Max 5x";
97 case "max20":
98 return "Max 20x";
99 case "enterprise":
100 return "Enterprise";
101 case "team":
102 return "Team";
103 case "pro":
104 return "Pro";
105 case "free":
106 return "Free";
107 default:
108 return toTitleCaseWords(subscriptionType!);
109 }
110}
111
112function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined {
113 const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, "");
114 if (!normalized) return undefined;
115 if (
116 normalized === "apikey" ||
117 normalized === "anthropicapikey" ||
118 normalized === "anthropicauthtoken"
119 ) {
120 return "apiKey";
121 }
122 return undefined;
123}
124
125function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string {
126 const subscriptionLabel =
127 claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType);
128 const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, "");
129
130 if (normalized.startsWith("claude") && normalized.endsWith("subscription")) {
131 return subscriptionLabel;
132 }
133 if (normalized.startsWith("claude")) {
134 return `${subscriptionLabel} Subscription`;
135 }
136 if (normalized.endsWith("subscription")) {
137 return `Claude ${subscriptionLabel}`;
138 }
139 return `Claude ${subscriptionLabel} Subscription`;
140}
141
142function claudeAuthMetadata(input: {
143 readonly subscriptionType: string | undefined;
144 readonly authMethod: string | undefined;
145}): { readonly type: string; readonly label: string } | undefined {
146 if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") {
147 return {
148 type: "apiKey",
149 label: "Claude API Key",
150 };
151 }
152
153 if (input.subscriptionType) {
154 return {
155 type: input.subscriptionType,
156 label: formatClaudeSubscriptionAuthLabel(input.subscriptionType),
157 };
158 }
159
160 return undefined;
161}
162
163function apiProviderAuthMetadata(
164 apiProvider: string | undefined,
165): { readonly type: string; readonly label: string } | undefined {
166 return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined;
167}
168
169// ── SDK capability probe ────────────────────────────────────────────
170
171// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the
172// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning
173// account info. The previous 8s budget expired mid-init, so the probe returned
174// `undefined` and left the provider unverified and unselectable in the picker.
175const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;
176
177/**
178 * Keep workspace-scoped command discovery intact while isolating the periodic
179 * health check from configured MCP servers.
180 */
181export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [
182 "user",
183 "project",
184 "local",
185] as const satisfies ReadonlyArray<SettingSource>;
186
187/** Build the exact SDK options used by the periodic Claude capability probe. */
188export function buildClaudeCapabilitiesProbeQueryOptions(input: {
189 readonly executablePath: string;
190 readonly abortController: AbortController;
191 readonly environment: NodeJS.ProcessEnv;
192 readonly cwd: string | undefined;
193}): ClaudeQueryOptions {
194 return {
195 persistSession: false,
196 pathToClaudeCodeExecutable: input.executablePath,
197 abortController: input.abortController,
198 settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES],
199 // The probe keeps filesystem setting sources for slash-command discovery,
200 // but must not run the user's hooks: it fires every few minutes, so
201 // SessionStart hooks would run on every health check.
202 settings: { disableAllHooks: true },
203 allowedTools: [],
204 // Ignore MCP definitions from every filesystem setting source above. The
205 // SDK combines this empty explicit map with --strict-mcp-config.
206 mcpServers: {},
207 strictMcpConfig: true,
208 env: {
209 ...input.environment,
210 // Connected claude.ai MCP servers are discovered outside filesystem
211 // config; disable them independently for this health check.
212 ENABLE_CLAUDEAI_MCP_SERVERS: "false",
213 // This is a noninteractive health check, so IDE discovery cannot add any
214 // useful capability data. Skipping it also avoids Claude spawning a
215 // Windows `tasklist | findstr` process tree on every periodic refresh.
216 FORCE_CODE_TERMINAL: undefined,
217 CLAUDE_CODE_AUTO_CONNECT_IDE: "0",
218 CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1",
219 },
220 ...(input.cwd ? { cwd: input.cwd } : {}),
221 stderr: () => {},
222 };
223}
224
225function nonEmptyProbeString(value: string): string | undefined {
226 const candidate = value.trim();
227 return candidate ? candidate : undefined;
228}
229
230type ClaudeCapabilitiesProbe = {
231 readonly email: string | undefined;
232 readonly subscriptionType: string | undefined;
233 readonly tokenSource: string | undefined;
234 /**
235 * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth
236 * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`)
237 * the subscription/token fields are absent and auth is external AWS creds.
238 */
239 readonly apiProvider: string | undefined;
240 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
241 /**
242 * Subscription windows from the SDK's `get_usage` control request, or
243 * `undefined` when the request itself failed. Absent windows on an
244 * otherwise successful response mean the account has none (API key).
245 */
246 readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
247};
248
249function parseClaudeInitializationCommands(
250 commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
251): ReadonlyArray<ServerProviderSlashCommand> {
252 return dedupeSlashCommands(
253 (commands ?? []).flatMap((command) => {
254 const name = nonEmptyProbeString(command.name);
255 if (!name) {
256 return [];
257 }
258
259 const description = nonEmptyProbeString(command.description);
260 const argumentHint = nonEmptyProbeString(command.argumentHint);
261
262 return [
263 {
264 name,
265 ...(description ? { description } : {}),
266 ...(argumentHint ? { input: { hint: argumentHint } } : {}),
267 } satisfies ServerProviderSlashCommand,
268 ];
269 }),
270 );
271}
272
273function dedupeSlashCommands(
274 commands: ReadonlyArray<ServerProviderSlashCommand>,
275): ReadonlyArray<ServerProviderSlashCommand> {
276 const commandsByName = new Map<string, ServerProviderSlashCommand>();
277
278 for (const command of commands) {
279 const name = nonEmptyProbeString(command.name);
280 if (!name) {
281 continue;
282 }
283
284 const key = name.toLowerCase();
285 const existing = commandsByName.get(key);
286 if (!existing) {
287 commandsByName.set(key, {
288 ...command,
289 name,
290 });
291 continue;
292 }
293
294 commandsByName.set(key, {
295 ...existing,
296 ...(existing.description
297 ? {}
298 : command.description
299 ? { description: command.description }
300 : {}),
301 ...(existing.input?.hint
302 ? {}
303 : command.input?.hint
304 ? { input: { hint: command.input.hint } }
305 : {}),
306 });
307 }
308
309 return [...commandsByName.values()];
310}
311
312function waitForAbortSignal(signal: AbortSignal): Promise<void> {
313 if (signal.aborted) {
314 return Promise.resolve();
315 }
316 return new Promise((resolve) => {
317 signal.addEventListener("abort", () => resolve(), { once: true });
318 });
319}
320
321/**
322 * Probe account information by spawning a lightweight Claude Agent SDK
323 * session and reading the initialization result.
324 *
325 * We pass a never-yielding AsyncIterable as the prompt so that no user
326 * message is ever written to the subprocess stdin. This means the Claude
327 * Code subprocess completes its local initialization IPC (returning
328 * account info and slash commands) but never starts an API request to
329 * Anthropic. We read the init data and then abort the subprocess.
330 *
331 * This is used as a fallback when `claude auth status` does not include
332 * subscription type information.
333 */
334const probeClaudeCapabilities = (
335 claudeSettings: ClaudeSettings,
336 environment?: NodeJS.ProcessEnv,
337 cwd?: string,
338 includeUsage = true,
339) => {
340 const abort = new AbortController();
341 return Effect.gen(function* () {
342 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
343 const executablePath = yield* resolveClaudeSdkExecutablePath(
344 claudeSettings.binaryPath,
345 claudeEnvironment,
346 );
347 return yield* Effect.tryPromise(async () => {
348 const q = claudeQuery({
349 // Never yield — we only need initialization data, not a conversation.
350 // This prevents any prompt from reaching the Anthropic API.
351 // oxlint-disable-next-line require-yield
352 prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
353 await waitForAbortSignal(abort.signal);
354 })(),
355 options: buildClaudeCapabilitiesProbeQueryOptions({
356 executablePath,
357 abortController: abort,
358 environment: claudeEnvironment,
359 cwd,
360 }),
361 });
362 const init = await q.initializationResult();
363 return { q, init };
364 });
365 }).pipe(
366 Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS),
367 Effect.flatMap(({ q, init }) =>
368 Effect.gen(function* () {
369 // Usage has its own deadline so a slow optional request cannot discard initialization.
370 const usageResult = includeUsage
371 ? yield* Effect.tryPromise(() =>
372 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
373 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
374 : undefined;
375 const usage =
376 usageResult && Result.isSuccess(usageResult)
377 ? {
378 rate_limits_available: usageResult.success.rate_limits_available,
379 rate_limits: usageResult.success.rate_limits,
380 }
381 : undefined;
382 const account = init.account as
383 | {
384 readonly email?: string;
385 readonly subscriptionType?: string;
386 readonly tokenSource?: string;
387 readonly apiProvider?: string;
388 }
389 | undefined;
390 return {
391 email: account?.email,
392 subscriptionType: account?.subscriptionType,
393 tokenSource: account?.tokenSource,
394 apiProvider: account?.apiProvider,
395 slashCommands: parseClaudeInitializationCommands(init.commands),
396 ...(usage ? { usage } : {}),
397 } satisfies ClaudeCapabilitiesProbe;
398 }),
399 ),
400 Effect.ensuring(
401 Effect.sync(() => {
402 if (!abort.signal.aborted) abort.abort();
403 }),
404 ),
405 Effect.result,
406 Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)),
407 );
408};
409
410const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
411 claudeSettings: ClaudeSettings,
412 args: ReadonlyArray<string>,
413 environment?: NodeJS.ProcessEnv,
414) {
415 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
416 const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, {
417 env: claudeEnvironment,
418 });
419 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
420 env: claudeEnvironment,
421 shell: spawnCommand.shell,
422 });
423 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
424});
425
426/** Read commands from the same cwd Claude uses for a workspace session. */
427export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* (
428 claudeSettings: ClaudeSettings,
429 machineSnapshot: ServerProvider,
430 cwd: string,
431 environment?: NodeJS.ProcessEnv,
432): Effect.fn.Return<ProviderWorkspaceSnapshot, never, FileSystem.FileSystem | Path.Path> {
433 if (!claudeSettings.enabled) return machineSnapshot;
434 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment);
435 const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false);
436 return {
437 ...machineSnapshot,
438 skills,
439 slashCommands: dedupeSlashCommands([
440 COMPACT_SLASH_COMMAND,
441 ...(capabilities?.slashCommands ?? []),
442 ]),
443 slashCommandsPending: !capabilities,
444 };
445});
446
447export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
448 claudeSettings: ClaudeSettings,
449 resolveCapabilities?: (
450 claudeSettings: ClaudeSettings,
451 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
452 environment?: NodeJS.ProcessEnv,
453 cwd?: string,
454 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
455 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
456 scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,
457 /** Banked resets for a subscription login, given the CLI version for the user agent. */
458 resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>,
459): Effect.fn.Return<
460 ServerProviderDraft,
461 never,
462 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
463> {
464 const resolvedEnvironment = environment ?? process.env;
465 const checkedAt = DateTime.formatIso(yield* DateTime.now);
466 const allModels = providerModelsFromSettings(
467 modelCatalog.models.map((entry) => entry.model),
468 claudeSettings.customModels,
469 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
470 );
471
472 if (!claudeSettings.enabled) {
473 return buildServerProvider({
474 presentation: CLAUDE_PRESENTATION,
475 enabled: false,
476 checkedAt,
477 models: allModels,
478 probe: {
479 installed: false,
480 version: null,
481 status: "warning",
482 auth: { status: "unknown" },
483 message: "Claude is disabled in T3 Code settings.",
484 },
485 });
486 }
487
488 const versionProbe = yield* runClaudeCommand(
489 claudeSettings,
490 ["--version"],
491 resolvedEnvironment,
492 ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result);
493
494 if (Result.isFailure(versionProbe)) {
495 const error = versionProbe.failure;
496 yield* Effect.logWarning("Claude Agent CLI health check failed.", {
497 errorTag: error._tag,
498 });
499 return buildServerProvider({
500 presentation: CLAUDE_PRESENTATION,
501 enabled: claudeSettings.enabled,
502 checkedAt,
503 models: allModels,
504 probe: {
505 installed: !isCommandMissingCause(error),
506 version: null,
507 status: "error",
508 auth: { status: "unknown" },
509 message: isCommandMissingCause(error)
510 ? "Claude Agent CLI (`claude`) was not found on PATH."
511 : "Failed to execute Claude Agent CLI health check.",
512 },
513 });
514 }
515
516 if (Option.isNone(versionProbe.success)) {
517 return buildServerProvider({
518 presentation: CLAUDE_PRESENTATION,
519 enabled: claudeSettings.enabled,
520 checkedAt,
521 models: allModels,
522 probe: {
523 installed: true,
524 version: null,
525 status: "error",
526 auth: { status: "unknown" },
527 message:
528 "Claude Agent CLI is installed but failed to run. Timed out while running command.",
529 },
530 });
531 }
532
533 const version = versionProbe.success.value;
534 const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
535 if (version.code !== 0) {
536 yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", {
537 exitCode: version.code,
538 stdoutLength: version.stdout.length,
539 stderrLength: version.stderr.length,
540 });
541 return buildServerProvider({
542 presentation: CLAUDE_PRESENTATION,
543 enabled: claudeSettings.enabled,
544 checkedAt,
545 models: allModels,
546 probe: {
547 installed: true,
548 version: parsedVersion,
549 status: "error",
550 auth: { status: "unknown" },
551 message: "Claude Agent CLI is installed but failed to run.",
552 },
553 });
554 }
555
556 const models = providerModelsFromSettings(
557 resolveClaudeModelsForVersion(modelCatalog, parsedVersion),
558 claudeSettings.customModels,
559 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
560 );
561 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);
562
563 const capabilities = resolveCapabilities
564 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
565 : undefined;
566 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
567 const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
568 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
569
570 if (!capabilities) {
571 return buildServerProvider({
572 presentation: CLAUDE_PRESENTATION,
573 enabled: claudeSettings.enabled,
574 checkedAt,
575 models,
576 slashCommands: dedupedSlashCommands,
577 skills,
578 probe: {
579 installed: true,
580 version: parsedVersion,
581 status: "warning",
582 auth: { status: "unknown" },
583 message: "Could not verify Claude authentication status from initialization result.",
584 },
585 });
586 }
587
588 const authMetadata =
589 claudeAuthMetadata({
590 subscriptionType: capabilities.subscriptionType,
591 authMethod: capabilities.tokenSource,
592 }) ?? apiProviderAuthMetadata(capabilities.apiProvider);
593 const usageLimits = !capabilities.usage
594 ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
595 : scopedLimitNames
596 ? yield* recordClaudeUsageResponse(scopedLimitNames, {
597 response: capabilities.usage,
598 checkedAt,
599 })
600 : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits;
601 const resetCredits =
602 resolveResetCredits &&
603 capabilities.subscriptionType &&
604 !usageLimits.unavailable &&
605 parsedVersion
606 ? yield* resolveResetCredits(parsedVersion)
607 : undefined;
608 return buildServerProvider({
609 presentation: CLAUDE_PRESENTATION,
610 enabled: claudeSettings.enabled,
611 checkedAt,
612 models,
613 slashCommands: dedupedSlashCommands,
614 skills,
615 probe: {
616 installed: true,
617 version: parsedVersion,
618 status: "ready",
619 auth: {
620 status: "authenticated",
621 ...(capabilities.email ? { email: capabilities.email } : {}),
622 ...(authMetadata ? authMetadata : {}),
623 },
624 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
625 usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits,
626 },
627 });
628});
629
630const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
631
632export const makePendingClaudeProvider = (
633 claudeSettings: ClaudeSettings,
634 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
635): Effect.Effect<ServerProviderDraft> =>
636 Effect.gen(function* () {
637 const checkedAt = yield* nowIso;
638 const models = providerModelsFromSettings(
639 modelCatalog.models.map((entry) => entry.model),
640 claudeSettings.customModels,
641 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
642 );
643
644 if (!claudeSettings.enabled) {
645 return buildServerProvider({
646 presentation: CLAUDE_PRESENTATION,
647 enabled: false,
648 checkedAt,
649 models,
650 probe: {
651 installed: false,
652 version: null,
653 status: "warning",
654 auth: { status: "unknown" },
655 message: "Claude is disabled in T3 Code settings.",
656 },
657 });
658 }
659
660 return buildServerProvider({
661 presentation: CLAUDE_PRESENTATION,
662 enabled: true,
663 checkedAt,
664 models,
665 probe: {
666 installed: false,
667 version: null,
668 status: "warning",
669 auth: { status: "unknown" },
670 message: "Claude provider status has not been checked in this session yet.",
671 },
672 });
673 });
674
675export { probeClaudeCapabilities };
apps/server/src/provider/CodexManagedRuntime.test.ts+1-1
......@@ -17,7 +17,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1717import * as CodexInstallation from "./CodexInstallation.ts";
1818import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts";
1919import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts";
20import { codexAppServerArgs } from "./codexLaunchArgs.ts";
2121import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts";
2222
2323const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
apps/server/src/provider/CodexProvider.test.ts created+173
......@@ -0,0 +1,173 @@
1import { assert, it } from "@effect/vitest";
2
3import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
4
5it("maps current Codex model capability fields", () => {
6 const capabilities = mapCodexModelCapabilities({
7 additionalSpeedTiers: [],
8 defaultReasoningEffort: "super-high",
9 description: "Test model",
10 displayName: "GPT Test",
11 hidden: false,
12 id: "gpt-test",
13 isDefault: true,
14 model: "gpt-test",
15 defaultServiceTier: "flex",
16 serviceTiers: [
17 {
18 id: "priority",
19 name: "Fast",
20 description: "Lower latency responses.",
21 },
22 {
23 id: "flex",
24 name: "Flex",
25 description: "Lower-cost asynchronous routing.",
26 },
27 ],
28 supportedReasoningEfforts: [
29 {
30 description: "Maximum reasoning",
31 reasoningEffort: "super-high",
32 },
33 ],
34 });
35
36 assert.deepStrictEqual(capabilities.optionDescriptors, [
37 {
38 id: "reasoningEffort",
39 label: "Reasoning",
40 type: "select",
41 options: [{ id: "super-high", label: "super-high", isDefault: true }],
42 currentValue: "super-high",
43 },
44 {
45 id: "serviceTier",
46 label: "Service Tier",
47 type: "select",
48 options: [
49 { id: "default", label: "Standard" },
50 {
51 id: "priority",
52 label: "Fast",
53 description: "Lower latency responses.",
54 },
55 {
56 id: "flex",
57 label: "Flex",
58 description: "Lower-cost asynchronous routing.",
59 isDefault: true,
60 },
61 ],
62 currentValue: "flex",
63 },
64 ]);
65});
66
67it("uses standard routing when the catalog has no default service tier", () => {
68 const capabilities = mapCodexModelCapabilities({
69 additionalSpeedTiers: ["fast"],
70 defaultReasoningEffort: "medium",
71 defaultServiceTier: null,
72 description: "Test model",
73 displayName: "GPT Test",
74 hidden: false,
75 id: "gpt-test",
76 isDefault: true,
77 model: "gpt-test",
78 serviceTiers: [
79 {
80 id: "priority",
81 name: "Fast",
82 description: "1.5x speed, increased usage",
83 },
84 {
85 id: "ultrafast",
86 name: "Ultrafast",
87 description: "The fastest available responses for latency-sensitive work.",
88 },
89 ],
90 supportedReasoningEfforts: [],
91 });
92
93 assert.deepStrictEqual(capabilities.optionDescriptors, [
94 {
95 id: "serviceTier",
96 label: "Service Tier",
97 type: "select",
98 options: [
99 { id: "default", label: "Standard", isDefault: true },
100 {
101 id: "priority",
102 label: "Fast",
103 description: "1.5x speed, increased usage",
104 },
105 {
106 id: "ultrafast",
107 label: "Ultrafast",
108 description: "Even faster, more expensive",
109 },
110 ],
111 currentValue: "default",
112 },
113 ]);
114});
115
116it("marks the most preferred available model as default", () => {
117 const models = applyPreferredCodexDefaultModel([
118 { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null },
119 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null },
120 ]);
121
122 assert.deepStrictEqual(
123 models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })),
124 [
125 { slug: "gpt-5.6-terra", isDefault: true },
126 { slug: "gpt-5.4", isDefault: undefined },
127 ],
128 );
129});
130
131it("prefers sol over terra when both are available", () => {
132 const models = applyPreferredCodexDefaultModel([
133 { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null },
134 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
135 ]);
136
137 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol");
138});
139
140it("ranks qualified Codex models while preserving their wire ids", () => {
141 const models = applyPreferredCodexDefaultModel([
142 {
143 slug: "openai.gpt-5.6-luna",
144 name: "Luna",
145 isCustom: false,
146 isDefault: true,
147 capabilities: null,
148 },
149 { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null },
150 ]);
151 assert.deepStrictEqual(
152 models.filter((model) => model.isDefault).map((model) => model.slug),
153 ["openai.gpt-5.6-sol"],
154 );
155});
156
157it("keeps Codex's own default when no preferred model is available", () => {
158 const models = applyPreferredCodexDefaultModel([
159 { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null },
160 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null },
161 ]);
162
163 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4");
164});
165
166it("ignores custom models that shadow a preferred slug", () => {
167 const models = applyPreferredCodexDefaultModel([
168 { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null },
169 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null },
170 ]);
171
172 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4");
173});
apps/server/src/provider/CodexProvider.ts created+723
......@@ -0,0 +1,723 @@
1import * as DateTime from "effect/DateTime";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import * as Scope from "effect/Scope";
9import * as Types from "effect/Types";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import * as CodexClient from "effect-codex-app-server/client";
13import * as CodexSchema from "effect-codex-app-server/schema";
14import * as CodexErrors from "effect-codex-app-server/errors";
15
16import type {
17 CodexSettings,
18 CustomModelSetting,
19 ServerProvider,
20 ServerProviderState,
21 ModelCapabilities,
22 ProviderOptionDescriptor,
23 ServerProviderModel,
24 ServerProviderSkill,
25} from "@t3tools/contracts";
26import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
27
28import {
29 codexModelFamily,
30 createModelCapabilities,
31 formatCodexModelName,
32 readCustomModelEntries,
33} from "@t3tools/shared/model";
34import { resolveSpawnCommand } from "@t3tools/shared/shell";
35import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
36import {
37 AUTH_PROBE_TIMEOUT_MS,
38 buildServerProvider,
39 COMPACT_SLASH_COMMAND,
40 type ServerProviderDraft,
41} from "./providerSnapshot.ts";
42import { expandHomePath } from "../pathExpansion.ts";
43import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts";
44import {
45 codexRateLimitsFailureMessage,
46 codexRateLimitsToLimits,
47 type CodexRateLimitSnapshot,
48 type CodexResetCreditsSummary,
49} from "./codexUsageLimits.ts";
50import packageJson from "../../package.json" with { type: "json" };
51const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
52const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
53
54type CodexRateLimitsProbe =
55 | {
56 readonly snapshot: CodexRateLimitSnapshot;
57 readonly rateLimitsByLimitId?:
58 | Readonly<Record<string, CodexRateLimitSnapshot>>
59 | null
60 | undefined;
61 readonly resetCredits: CodexResetCreditsSummary | null | undefined;
62 }
63 | { readonly failure: string };
64
65const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
66
67const CODEX_PRESENTATION = {
68 displayName: "Codex",
69 showInteractionModeToggle: true,
70 reportsContextWindow: true,
71} as const;
72
73export interface CodexAppServerProviderSnapshot {
74 readonly account: CodexSchema.V2GetAccountResponse;
75 readonly rateLimits?: CodexRateLimitsProbe;
76 readonly version: string | undefined;
77 readonly models: ReadonlyArray<ServerProviderModel>;
78 readonly skills: ReadonlyArray<ServerProviderSkill>;
79}
80
81const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
82 none: "None",
83 minimal: "Minimal",
84 low: "Low",
85 medium: "Medium",
86 high: "High",
87 xhigh: "Extra High",
88 max: "Max",
89 ultra: "Ultra",
90};
91
92const DEFAULT_SERVICE_TIER_ID = "default";
93
94/** Shorter copy for tiers whose catalog description wraps in the traits menu. */
95const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = {
96 ultrafast: "Even faster, more expensive",
97};
98
99function reasoningEffortLabel(reasoningEffort: string): string {
100 return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
101}
102
103function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) {
104 if (!account) return undefined;
105 if (account.type === "apiKey") return "OpenAI API Key";
106 if (account.type === "amazonBedrock") return "Amazon Bedrock";
107 if (account.type !== "chatgpt") return undefined;
108 return codexPlanLabel(account.planType);
109}
110
111/** Shared with usage-limit sources, which report the same `planType` slugs. */
112export function codexPlanLabel(planType: string | null | undefined): string | undefined {
113 switch (planType) {
114 case "free":
115 return "ChatGPT Free Subscription";
116 case "go":
117 return "ChatGPT Go Subscription";
118 case "plus":
119 return "ChatGPT Plus Subscription";
120 case "pro":
121 return "ChatGPT Pro 20x Subscription";
122 case "prolite":
123 return "ChatGPT Pro 5x Subscription";
124 case "promax":
125 return "ChatGPT Pro Max Subscription";
126 case "team":
127 return "ChatGPT Team Subscription";
128 case "self_serve_business_prolite":
129 case "self_serve_business_usage_based":
130 case "business":
131 return "ChatGPT Business Subscription";
132 case "ent26":
133 case "enterprise_cbp_automation":
134 case "enterprise_cbp_usage_based":
135 case "enterprise":
136 return "ChatGPT Enterprise Subscription";
137 case "edu":
138 case "edu_plus":
139 case "edu_pro":
140 return "ChatGPT Edu Subscription";
141 case "unknown":
142 return "ChatGPT Subscription";
143 default:
144 return undefined;
145 }
146}
147
148function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) {
149 if (!account || account.type !== "chatgpt") return undefined;
150 return account.email;
151}
152
153export function mapCodexModelCapabilities(
154 model: CodexSchema.V2ModelListResponse__Model,
155): ModelCapabilities {
156 const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) =>
157 reasoningEffort ===
158 (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort)
159 ? {
160 id: reasoningEffort,
161 label: reasoningEffortLabel(reasoningEffort),
162 isDefault: true,
163 }
164 : {
165 id: reasoningEffort,
166 label: reasoningEffortLabel(reasoningEffort),
167 },
168 );
169 const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id;
170 const serviceTiers =
171 model.serviceTiers && model.serviceTiers.length > 0
172 ? model.serviceTiers
173 : (model.additionalSpeedTiers ?? []).map((id) => ({
174 id,
175 name: id === "fast" ? "Fast" : id,
176 description: "",
177 }));
178 const catalogDefaultServiceTier = serviceTiers.some(
179 (tier) => tier.id === model.defaultServiceTier,
180 )
181 ? model.defaultServiceTier
182 : null;
183 const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID;
184 const optionDescriptors: ProviderOptionDescriptor[] = [];
185
186 if (reasoningOptions.length > 0) {
187 optionDescriptors.push({
188 id: "reasoningEffort",
189 label: "Reasoning",
190 type: "select",
191 options: reasoningOptions,
192 ...(defaultReasoning ? { currentValue: defaultReasoning } : {}),
193 });
194 }
195 if (serviceTiers.length > 0) {
196 optionDescriptors.push({
197 id: "serviceTier",
198 label: "Service Tier",
199 type: "select",
200 options: [
201 {
202 id: DEFAULT_SERVICE_TIER_ID,
203 label: "Standard",
204 ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}),
205 },
206 ...serviceTiers.map((tier) => {
207 const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description;
208 return {
209 id: tier.id,
210 label: tier.name,
211 ...(description ? { description } : {}),
212 ...(defaultServiceTier === tier.id ? { isDefault: true } : {}),
213 };
214 }),
215 ],
216 currentValue: defaultServiceTier,
217 });
218 }
219
220 return createModelCapabilities({
221 optionDescriptors,
222 });
223}
224
225function parseCodexModelListResponse(
226 response: CodexSchema.V2ModelListResponse,
227): ReadonlyArray<ServerProviderModel> {
228 return response.data.map((model) => ({
229 slug: model.model,
230 name: formatCodexModelName(model.displayName),
231 isCustom: false,
232 ...(model.isDefault ? { isDefault: true } : {}),
233 capabilities: mapCodexModelCapabilities(model),
234 }));
235}
236
237/**
238 * Prefer our own default-model ranking when one of the preferred slugs is in
239 * the live catalog; otherwise keep whatever Codex itself flagged as default.
240 */
241export function applyPreferredCodexDefaultModel(
242 models: ReadonlyArray<ServerProviderModel>,
243): ReadonlyArray<ServerProviderModel> {
244 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) =>
245 models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug),
246 )[0]?.slug;
247 if (!preferredSlug) {
248 return models;
249 }
250 return models.map((model) => {
251 if (model.slug === preferredSlug) {
252 return model.isDefault ? model : { ...model, isDefault: true };
253 }
254 if (!model.isDefault) {
255 return model;
256 }
257 const { isDefault: _isDefault, ...rest } = model;
258 return rest;
259 });
260}
261
262/**
263 * Codex has no static default capability set, so a bare custom slug borrows
264 * the first built-in's descriptors; an entry with its own capabilities keeps
265 * them.
266 */
267function appendCustomCodexModels(
268 models: ReadonlyArray<ServerProviderModel>,
269 customModels: ReadonlyArray<CustomModelSetting>,
270): ReadonlyArray<ServerProviderModel> {
271 if (customModels.length === 0) {
272 return models;
273 }
274
275 const seen = new Set(models.map((model) => model.slug));
276 const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
277 const customEntries: ServerProviderModel[] = [];
278 for (const entry of readCustomModelEntries(customModels)) {
279 if (seen.has(entry.slug)) {
280 continue;
281 }
282 seen.add(entry.slug);
283 customEntries.push({
284 slug: entry.slug,
285 name: entry.name,
286 isCustom: true,
287 capabilities: entry.capabilities ?? fallbackCapabilities,
288 });
289 }
290 return customEntries.length === 0 ? models : [...models, ...customEntries];
291}
292
293function parseCodexSkillsListResponse(
294 response: CodexSchema.V2SkillsListResponse,
295 cwd: string,
296): ReadonlyArray<ServerProviderSkill> {
297 const matchingEntry = response.data.find((entry) => entry.cwd === cwd);
298 const skills = matchingEntry
299 ? matchingEntry.skills
300 : response.data.flatMap((entry) => entry.skills);
301
302 return skills.map((skill) => {
303 const shortDescription =
304 skill.shortDescription ?? skill.interface?.shortDescription ?? undefined;
305
306 const parsedSkill: Types.Mutable<ServerProviderSkill> = {
307 name: skill.name,
308 path: skill.path,
309 enabled: skill.enabled,
310 };
311
312 if (skill.description) {
313 parsedSkill.description = skill.description;
314 }
315 if (skill.scope) {
316 parsedSkill.scope = skill.scope;
317 }
318 if (skill.interface?.displayName) {
319 parsedSkill.displayName = skill.interface.displayName;
320 }
321 if (shortDescription) {
322 parsedSkill.shortDescription = shortDescription;
323 }
324
325 return parsedSkill;
326 });
327}
328
329const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
330 client: CodexClient.CodexAppServerClient["Service"],
331) {
332 const models: ServerProviderModel[] = [];
333 let cursor: string | null | undefined = undefined;
334
335 do {
336 const response: CodexSchema.V2ModelListResponse = yield* client.request(
337 "model/list",
338 cursor ? { cursor } : {},
339 );
340 models.push(...parseCodexModelListResponse(response));
341 cursor = response.nextCursor;
342 } while (cursor);
343
344 return models;
345});
346
347export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
348 return {
349 clientInfo: {
350 name: "T3 Code",
351 title: "T3 Code",
352 version: packageJson.version,
353 },
354 capabilities: {
355 experimentalApi: true,
356 },
357 };
358}
359
360/**
361 * Spawns a short-lived `codex app-server`, runs the initialize handshake, and
362 * hands the caller a connected client. Scoped: the process is killed when
363 * the caller's scope closes. Shared by the status probe, the skills probe,
364 * and account-level requests such as reset-credit redemption.
365 */
366export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: {
367 readonly binaryPath: string;
368 readonly homePath?: string | undefined;
369 readonly launchArgs?: string | undefined;
370 readonly cwd: string;
371 readonly environment?: NodeJS.ProcessEnv | undefined;
372}) {
373 // `~` is not shell-expanded when env vars are set via `child_process.spawn`,
374 // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
375 // "CODEX_HOME points to '~/.codex_work', but that path does not exist".
376 // Expand here for parity with `CodexTextGeneration`.
377 const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
378 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
379 const environment = {
380 ...input.environment,
381 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
382 };
383 const spawnCommand = yield* resolveSpawnCommand(
384 input.binaryPath,
385 codexAppServerArgs(input.launchArgs),
386 { env: environment, extendEnv: true },
387 );
388 const child = yield* spawner
389 .spawn(
390 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
391 cwd: input.cwd,
392 env: environment,
393 extendEnv: true,
394 forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
395 shell: spawnCommand.shell,
396 }),
397 )
398 .pipe(
399 Effect.mapError(
400 (cause) =>
401 new CodexErrors.CodexAppServerSpawnError({
402 command: `${input.binaryPath} app-server`,
403 cause,
404 }),
405 ),
406 );
407 const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
408 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
409 Effect.provide(clientContext),
410 );
411 const initialize = yield* client.request("initialize", buildCodexInitializeParams());
412 yield* client.notify("initialized", undefined);
413 return { client, initialize };
414});
415
416const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
417 readonly binaryPath: string;
418 readonly homePath?: string;
419 readonly launchArgs?: string;
420 readonly cwd: string;
421 readonly customModels?: ReadonlyArray<CustomModelSetting>;
422 readonly environment?: NodeJS.ProcessEnv;
423 readonly skipNativeUsage?: boolean;
424}) {
425 const { client, initialize } = yield* withCodexAppServerClient(input);
426
427 // Extract the version string after the first '/' in userAgent, up to the next space or the end
428 const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
429 const version = versionMatch ? versionMatch[1] : undefined;
430
431 const accountResponse = yield* client.request("account/read", {});
432 if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
433 return {
434 account: accountResponse,
435 version,
436 models: appendCustomCodexModels([], input.customModels ?? []),
437 skills: [],
438 } satisfies CodexAppServerProviderSnapshot;
439 }
440
441 const [skillsResponse, models, rateLimits] = yield* Effect.all(
442 [
443 client.request("skills/list", {
444 cwds: [input.cwd],
445 }),
446 requestAllCodexModels(client),
447 // Usage is an enrichment: a failure or a slow answer degrades to "no
448 // usage this probe" rather than costing the account and models.
449 input.skipNativeUsage
450 ? Effect.succeed(undefined)
451 : client.request("account/rateLimits/read", null).pipe(
452 Effect.map((response): CodexRateLimitsProbe => ({
453 snapshot: response.rateLimits,
454 rateLimitsByLimitId: response.rateLimitsByLimitId,
455 resetCredits: response.rateLimitResetCredits,
456 })),
457 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
458 Effect.map(
459 Option.getOrElse((): CodexRateLimitsProbe => ({
460 failure: "Codex did not answer the usage request.",
461 })),
462 ),
463 Effect.catch((error) =>
464 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
465 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
466 ),
467 ),
468 ),
469 ],
470 { concurrency: "unbounded" },
471 );
472
473 return {
474 account: accountResponse,
475 ...(rateLimits ? { rateLimits } : {}),
476 version,
477 models: applyPreferredCodexDefaultModel(
478 appendCustomCodexModels(models, input.customModels ?? []),
479 ),
480 skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
481 } satisfies CodexAppServerProviderSnapshot;
482});
483
484export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: {
485 readonly binaryPath: string;
486 readonly homePath?: string;
487 readonly launchArgs?: string;
488 readonly cwd: string;
489 readonly environment?: NodeJS.ProcessEnv;
490}) {
491 const { client } = yield* withCodexAppServerClient(input);
492 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
493 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
494});
495
496const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
497 appendCustomCodexModels([], codexSettings.customModels);
498
499const makePendingCodexProvider = (
500 codexSettings: CodexSettings,
501): Effect.Effect<ServerProviderDraft> =>
502 Effect.gen(function* () {
503 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
504 const models = emptyCodexModelsFromSettings(codexSettings);
505
506 if (!codexSettings.enabled) {
507 return buildServerProvider({
508 presentation: CODEX_PRESENTATION,
509 enabled: false,
510 checkedAt,
511 models,
512 skills: [],
513 probe: {
514 installed: false,
515 version: null,
516 status: "warning",
517 auth: { status: "unknown" },
518 message: "Codex is disabled in T3 Code settings.",
519 },
520 });
521 }
522
523 return buildServerProvider({
524 presentation: CODEX_PRESENTATION,
525 enabled: true,
526 checkedAt,
527 models,
528 skills: [],
529 probe: {
530 installed: false,
531 version: null,
532 status: "warning",
533 auth: { status: "unknown" },
534 message: "Codex provider status has not been checked in this session yet.",
535 },
536 });
537 });
538
539function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): {
540 readonly status: Exclude<ServerProviderState, "disabled">;
541 readonly auth: ServerProvider["auth"];
542 readonly message?: string;
543} {
544 const authLabel = codexAccountAuthLabel(account.account);
545 const authEmail = codexAccountEmail(account.account);
546 const auth = {
547 status: account.account ? ("authenticated" as const) : ("unknown" as const),
548 ...(account.account?.type ? { type: account.account?.type } : {}),
549 ...(authLabel ? { label: authLabel } : {}),
550 ...(authEmail ? { email: authEmail } : {}),
551 } satisfies ServerProvider["auth"];
552
553 if (account.account) {
554 return { status: "ready", auth };
555 }
556
557 if (account.requiresOpenaiAuth) {
558 return {
559 status: "error",
560 auth: { status: "unauthenticated" },
561 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
562 };
563 }
564
565 return { status: "ready", auth };
566}
567
568export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
569 codexSettings: CodexSettings,
570 probe: (input: {
571 readonly binaryPath: string;
572 readonly homePath?: string;
573 readonly launchArgs?: string;
574 readonly cwd: string;
575 readonly customModels: ReadonlyArray<CustomModelSetting>;
576 readonly environment?: NodeJS.ProcessEnv;
577 readonly skipNativeUsage?: boolean;
578 }) => Effect.Effect<
579 CodexAppServerProviderSnapshot,
580 CodexErrors.CodexAppServerError,
581 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
582 > = probeCodexAppServerProvider,
583 environment?: NodeJS.ProcessEnv,
584 managedAuth?: ServerProvider["auth"],
585): Effect.fn.Return<
586 ServerProviderDraft,
587 ServerSettingsError,
588 ChildProcessSpawner.ChildProcessSpawner
589> {
590 const resolvedEnvironment = environment ?? process.env;
591 const checkedAt = DateTime.formatIso(yield* DateTime.now);
592 const emptyModels = emptyCodexModelsFromSettings(codexSettings);
593
594 if (!codexSettings.enabled) {
595 return buildServerProvider({
596 presentation: CODEX_PRESENTATION,
597 enabled: false,
598 checkedAt,
599 models: emptyModels,
600 skills: [],
601 probe: {
602 installed: false,
603 version: null,
604 status: "warning",
605 auth: { status: "unknown" },
606 message: "Codex is disabled in T3 Code settings.",
607 },
608 });
609 }
610
611 const probeResult = yield* probe({
612 binaryPath: codexSettings.binaryPath,
613 homePath: codexSettings.homePath,
614 launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
615 cwd: process.cwd(),
616 customModels: codexSettings.customModels,
617 environment: resolvedEnvironment,
618 ...(managedAuth ? { skipNativeUsage: true } : {}),
619 }).pipe(
620 Effect.scoped,
621 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
622 Effect.result,
623 );
624
625 if (Result.isFailure(probeResult)) {
626 const error = probeResult.failure;
627 const installed = !isCodexAppServerSpawnError(error);
628 return buildServerProvider({
629 presentation: CODEX_PRESENTATION,
630 enabled: codexSettings.enabled,
631 checkedAt,
632 models: emptyModels,
633 skills: [],
634 probe: {
635 installed,
636 version: null,
637 status: "error",
638 auth: { status: "unknown" },
639 message: installed
640 ? `Codex app-server provider probe failed: ${error.message}.`
641 : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` +
642 (codexSettings.binaryPath === "codex"
643 ? " Installing ChatGPT or Codex desktop may not add codex to PATH."
644 : " Make sure the configured executable exists and can be run."),
645 },
646 });
647 }
648
649 if (Option.isNone(probeResult.success)) {
650 return buildServerProvider({
651 presentation: CODEX_PRESENTATION,
652 enabled: codexSettings.enabled,
653 checkedAt,
654 models: emptyModels,
655 skills: [],
656 probe: {
657 installed: true,
658 version: null,
659 status: "error",
660 auth: { status: "unknown" },
661 message: "Timed out while checking Codex app-server provider status.",
662 },
663 });
664 }
665
666 const snapshot = probeResult.success.value;
667 const accountStatus = managedAuth
668 ? { status: "ready" as const, auth: managedAuth, message: undefined }
669 : accountProbeStatus(snapshot.account);
670 const usageLimits =
671 snapshot.account.account?.type === "apiKey"
672 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
673 : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits
674 ? makeUnavailableUsageLimits({
675 checkedAt,
676 reason: "probeFailed",
677 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),
678 })
679 : codexRateLimitsToLimits({
680 snapshot: snapshot.rateLimits.snapshot,
681 rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId,
682 resetCredits: snapshot.rateLimits.resetCredits,
683 checkedAt,
684 });
685
686 return buildServerProvider({
687 presentation: CODEX_PRESENTATION,
688 enabled: codexSettings.enabled,
689 checkedAt,
690 models: snapshot.models,
691 skills: snapshot.skills,
692 slashCommands: [
693 COMPACT_SLASH_COMMAND,
694 {
695 name: "goal",
696 description: "Set a goal Codex keeps working toward until it is done",
697 input: { hint: "Objective, or pause, resume, clear" },
698 },
699 {
700 name: "feedback",
701 description: "Send this thread and Codex logs to OpenAI",
702 input: { hint: "Describe the issue (optional)" },
703 },
704 ],
705 probe: {
706 installed: true,
707 version: snapshot.version ?? null,
708 status: accountStatus.status,
709 auth: accountStatus.auth,
710 ...(accountStatus.message ? { message: accountStatus.message } : {}),
711 ...(managedAuth ? {} : { usageLimits }),
712 },
713 });
714});
715
716// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of
717// the per-instance-driver refactor. `CodexDriver.create()` builds a managed
718// snapshot per instance (each with its own `CodexSettings`) and hands the
719// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`.
720//
721// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are
722// re-exported for use by `CodexDriver`.
723export { makePendingCodexProvider };
apps/server/src/provider/CursorAuth.ts+1-1
......@@ -17,7 +17,7 @@ import * as Stream from "effect/Stream";
1717import * as SubscriptionRef from "effect/SubscriptionRef";
1818
1919import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts";
20import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";
20import type { ProviderAuthController } from "./ProviderAuthService.ts";
2121
2222const AUTH_TIMEOUT_MS = 300_000;
2323
apps/server/src/provider/CursorProvider.test.ts created+252
......@@ -0,0 +1,252 @@
1import type { SDKModel } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import type { CursorSettings } from "@t3tools/contracts";
6import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts";
7import { createModelCapabilities } from "@t3tools/shared/model";
8
9import {
10 buildCursorCapabilitiesFromSdkModel,
11 buildCursorDiscoveredModelsFromSdk,
12 buildCursorProviderSnapshot,
13 buildInitialCursorProviderSnapshot,
14 checkCursorProviderStatus,
15} from "./CursorProvider.ts";
16import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
17
18const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema);
19
20function selectDescriptor(
21 id: string,
22 label: string,
23 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
24) {
25 return {
26 id,
27 label,
28 type: "select" as const,
29 options: [...options],
30 ...(options.find((option) => option.isDefault)?.id
31 ? { currentValue: options.find((option) => option.isDefault)?.id }
32 : {}),
33 };
34}
35
36function booleanDescriptor(id: string, label: string, currentValue?: boolean) {
37 return {
38 id,
39 label,
40 type: "boolean" as const,
41 ...(typeof currentValue === "boolean" ? { currentValue } : {}),
42 };
43}
44
45const baseCursorSettings: CursorSettings = decodeCursorSettings({
46 enabled: true,
47 customModels: [],
48});
49
50const sdkParameterizedModel = {
51 id: "claude-opus-4-8",
52 displayName: "Opus 4.8",
53 parameters: [
54 {
55 id: "thinking",
56 displayName: "Thinking",
57 values: [{ value: "false" }, { value: "true" }],
58 },
59 {
60 id: "context",
61 displayName: "Context",
62 values: [
63 { value: "300k", displayName: "300K" },
64 { value: "1m", displayName: "1M" },
65 ],
66 },
67 {
68 id: "effort",
69 displayName: "Effort",
70 values: [
71 { value: "low", displayName: "Low" },
72 { value: "high", displayName: "High" },
73 ],
74 },
75 {
76 id: "fast",
77 displayName: "Fast",
78 values: [{ value: "false" }, { value: "true", displayName: "Fast" }],
79 },
80 ],
81 variants: [
82 {
83 displayName: "Opus 4.8",
84 isDefault: true,
85 params: [
86 { id: "thinking", value: "true" },
87 { id: "context", value: "1m" },
88 { id: "effort", value: "high" },
89 { id: "fast", value: "false" },
90 ],
91 },
92 ],
93} satisfies SDKModel;
94
95describe("buildInitialCursorProviderSnapshot", () => {
96 it.effect("uses SDK-specific pending status copy", () =>
97 Effect.gen(function* () {
98 const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings);
99
100 expect(provider).toMatchObject({
101 status: "warning",
102 message: "Checking Cursor SDK availability...",
103 });
104 }),
105 );
106});
107
108describe("buildCursorProviderSnapshot", () => {
109 it("downgrades ready status to warning when SDK model discovery returns no models", () => {
110 expect(
111 buildCursorProviderSnapshot({
112 checkedAt: "2026-01-01T00:00:00.000Z",
113 cursorSettings: baseCursorSettings,
114 parsed: {
115 version: null,
116 status: "ready",
117 auth: { status: "authenticated", type: "api-key", label: "Cursor API key" },
118 },
119 discoveryWarning: "Cursor SDK model discovery returned no built-in models.",
120 }),
121 ).toMatchObject({
122 status: "warning",
123 message: "Cursor SDK model discovery returned no built-in models.",
124 models: [],
125 supportsConversationRollback: false,
126 });
127 });
128});
129
130describe("Cursor SDK model discovery", () => {
131 it("maps native SDK parameter ids and default variant values to model capabilities", () => {
132 expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual(
133 createModelCapabilities({
134 optionDescriptors: [
135 selectDescriptor("effort", "Effort", [
136 { id: "low", label: "Low" },
137 { id: "high", label: "High", isDefault: true },
138 ]),
139 selectDescriptor("contextWindow", "Context", [
140 { id: "300k", label: "300K" },
141 { id: "1m", label: "1M", isDefault: true },
142 ]),
143 booleanDescriptor("fastMode", "Fast", false),
144 booleanDescriptor("thinking", "Thinking", true),
145 ],
146 }),
147 );
148 });
149
150 it("filters invalid and duplicate SDK model entries", () => {
151 expect(
152 buildCursorDiscoveredModelsFromSdk([
153 sdkParameterizedModel,
154 { ...sdkParameterizedModel, displayName: "Duplicate" },
155 { id: "", displayName: "Invalid" },
156 ]),
157 ).toEqual([
158 {
159 slug: "claude-opus-4-8",
160 name: "Opus 4.8",
161 isCustom: false,
162 capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel),
163 },
164 ]);
165 });
166});
167
168describe("checkCursorProviderStatus", () => {
169 it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () =>
170 Effect.gen(function* () {
171 const provider = yield* checkCursorProviderStatus(
172 {
173 ...baseCursorSettings,
174 customModels: ["internal/cursor-model"],
175 },
176 { CURSOR_API_KEY: "test-cursor-key" },
177 ).pipe(
178 Effect.provide(
179 CursorSdkCatalog.layerTest((apiKey) => {
180 expect(apiKey).toBe("test-cursor-key");
181 return Effect.succeed({
182 user: {
183 apiKeyName: "test-key",
184 userEmail: "cursor@example.com",
185 createdAt: "2026-01-01T00:00:00.000Z",
186 },
187 models: [sdkParameterizedModel],
188 });
189 }),
190 ),
191 );
192
193 expect(provider).toMatchObject({
194 status: "ready",
195 auth: {
196 status: "authenticated",
197 type: "api-key",
198 label: "Cursor API key (test-key)",
199 email: "cursor@example.com",
200 },
201 models: [
202 { slug: "claude-opus-4-8", isCustom: false },
203 { slug: "internal/cursor-model", isCustom: true },
204 ],
205 });
206 }),
207 );
208
209 it.effect("surfaces SDK authentication failures", () =>
210 Effect.gen(function* () {
211 const provider = yield* checkCursorProviderStatus(baseCursorSettings, {
212 CURSOR_API_KEY: "invalid-test-key",
213 }).pipe(
214 Effect.provide(
215 CursorSdkCatalog.layerTest(() =>
216 Effect.fail(
217 new CursorSdkCatalog.CursorSdkCatalogError({
218 authenticationFailure: true,
219 cause: new Error("unauthorized"),
220 }),
221 ),
222 ),
223 ),
224 );
225
226 expect(provider).toMatchObject({
227 status: "error",
228 auth: { status: "unauthenticated" },
229 message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.",
230 });
231 }),
232 );
233
234 it.effect("requires a Cursor API key without probing any external Cursor binary", () =>
235 Effect.gen(function* () {
236 const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe(
237 Effect.provide(
238 CursorSdkCatalog.layerTest(() =>
239 Effect.die("SDK catalog must not be used without CURSOR_API_KEY"),
240 ),
241 ),
242 );
243
244 expect(provider).toMatchObject({
245 installed: true,
246 status: "error",
247 auth: { status: "unauthenticated" },
248 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
249 });
250 }),
251 );
252});
apps/server/src/provider/CursorProvider.ts created+350
......@@ -0,0 +1,350 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import type {
3 CursorSettings,
4 ModelCapabilities,
5 ProviderOptionDescriptor,
6 ServerProviderAuth,
7 ServerProviderModel,
8 ServerProviderState,
9} from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Result from "effect/Result";
15
16import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts";
17import {
18 buildBooleanOptionDescriptor,
19 buildSelectOptionDescriptor,
20 buildServerProvider,
21 providerModelsFromSettings,
22 type ServerProviderDraft,
23} from "./providerSnapshot.ts";
24import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
25
26const CURSOR_PRESENTATION = {
27 displayName: "Cursor",
28 supportsConversationRollback: false,
29 showInteractionModeToggle: true,
30} as const;
31const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
32 optionDescriptors: [],
33});
34
35const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000;
36
37export function buildInitialCursorProviderSnapshot(
38 cursorSettings: CursorSettings,
39): Effect.Effect<ServerProviderDraft> {
40 return Effect.gen(function* () {
41 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
42 const models = getCursorFallbackModels(cursorSettings);
43
44 if (!cursorSettings.enabled) {
45 return buildServerProvider({
46 presentation: CURSOR_PRESENTATION,
47 enabled: false,
48 checkedAt,
49 models,
50 probe: {
51 installed: false,
52 version: null,
53 status: "warning",
54 auth: { status: "unknown" },
55 message: "Cursor is disabled in T3 Code settings.",
56 },
57 });
58 }
59
60 return buildServerProvider({
61 presentation: CURSOR_PRESENTATION,
62 enabled: true,
63 checkedAt,
64 models,
65 probe: {
66 installed: true,
67 version: null,
68 status: "warning",
69 auth: { status: "unknown" },
70 message: "Checking Cursor SDK availability...",
71 },
72 });
73 });
74}
75
76function getCursorFallbackModels(
77 cursorSettings: Pick<CursorSettings, "customModels">,
78): ReadonlyArray<ServerProviderModel> {
79 return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES);
80}
81
82function toTitleCaseWords(value: string): string {
83 const parts: Array<string> = [];
84 for (const part of value.split(/[\s_-]+/g)) {
85 if (part.length > 0) {
86 parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
87 }
88 }
89 return parts.join(" ");
90}
91
92function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined {
93 return model.variants
94 ?.find((variant) => variant.isDefault)
95 ?.params.find((parameter) => parameter.id === parameterId)?.value;
96}
97
98export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities {
99 const seen = new Set<string>();
100 const optionDescriptors: Array<ProviderOptionDescriptor> = [];
101 const parameters = (model.parameters ?? [])
102 .map((parameter, index) => ({ parameter, index }))
103 .toSorted(
104 (left, right) =>
105 cursorSdkParameterPriority(left.parameter.id) -
106 cursorSdkParameterPriority(right.parameter.id) || left.index - right.index,
107 );
108 for (const { parameter } of parameters) {
109 const nativeId = parameter.id.trim();
110 const id = cursorSdkProviderOptionId(nativeId);
111 if (!nativeId || !id || seen.has(id)) {
112 continue;
113 }
114 seen.add(id);
115
116 const values = parameter.values.flatMap((entry) => {
117 const value = entry.value.trim();
118 if (!value) {
119 return [];
120 }
121 return [
122 {
123 value,
124 label: entry.displayName?.trim() || value,
125 },
126 ];
127 });
128 if (values.length === 0) {
129 continue;
130 }
131
132 const label = parameter.displayName?.trim() || toTitleCaseWords(id);
133 const defaultValue = cursorSdkDefaultParameterValue(model, nativeId);
134 const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase()));
135 if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) {
136 if (defaultValue === "true" || defaultValue === "false") {
137 optionDescriptors.push(
138 buildBooleanOptionDescriptor({
139 id,
140 label,
141 currentValue: defaultValue === "true",
142 }),
143 );
144 } else {
145 optionDescriptors.push(buildBooleanOptionDescriptor({ id, label }));
146 }
147 continue;
148 }
149
150 optionDescriptors.push(
151 buildSelectOptionDescriptor({
152 id,
153 label,
154 options: values.map((entry) => ({
155 ...entry,
156 ...(entry.value === defaultValue ? { isDefault: true } : {}),
157 })),
158 }),
159 );
160 }
161
162 return createModelCapabilities({ optionDescriptors });
163}
164
165export function buildCursorDiscoveredModelsFromSdk(
166 models: ReadonlyArray<SDKModel>,
167): ReadonlyArray<ServerProviderModel> {
168 const seen = new Set<string>();
169 return models.flatMap((model) => {
170 const slug = model.id.trim();
171 const name = model.displayName.trim();
172 if (!slug || !name || seen.has(slug)) {
173 return [];
174 }
175 seen.add(slug);
176 return [
177 {
178 slug,
179 name,
180 isCustom: false,
181 capabilities: buildCursorCapabilitiesFromSdkModel(model),
182 } satisfies ServerProviderModel,
183 ];
184 });
185}
186
187function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth {
188 const email = user.userEmail?.trim();
189 const apiKeyName = user.apiKeyName.trim();
190 return {
191 status: "authenticated",
192 type,
193 label:
194 type === "browser"
195 ? "Cursor account"
196 : apiKeyName
197 ? `Cursor API key (${apiKeyName})`
198 : "Cursor API key",
199 ...(email ? { email } : {}),
200 };
201}
202
203interface CursorProviderProbeResult {
204 readonly version: string | null;
205 readonly status: Exclude<ServerProviderState, "disabled">;
206 readonly auth: ServerProviderAuth;
207 readonly message?: string;
208}
209
210function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
211 const parts: Array<string> = [];
212 for (const message of messages) {
213 const trimmed = message?.trim();
214 if (trimmed) {
215 parts.push(trimmed);
216 }
217 }
218 return parts.length > 0 ? parts.join(" ") : undefined;
219}
220
221export function buildCursorProviderSnapshot(input: {
222 readonly checkedAt: string;
223 readonly cursorSettings: CursorSettings;
224 readonly parsed: CursorProviderProbeResult;
225 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
226 readonly discoveryWarning?: string;
227}): ServerProviderDraft {
228 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
229 return buildServerProvider({
230 presentation: CURSOR_PRESENTATION,
231 enabled: input.cursorSettings.enabled,
232 checkedAt: input.checkedAt,
233 models: providerModelsFromSettings(
234 input.discoveredModels ?? [],
235 input.cursorSettings.customModels,
236 EMPTY_CAPABILITIES,
237 ),
238 probe: {
239 installed: true,
240 version: input.parsed.version,
241 status:
242 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
243 auth: input.parsed.auth,
244 ...(message ? { message } : {}),
245 },
246 });
247}
248
249export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
250 cursorSettings: CursorSettings,
251 environment?: NodeJS.ProcessEnv,
252 authenticationType: "api-key" | "browser" = "api-key",
253): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> {
254 const checkedAt = DateTime.formatIso(yield* DateTime.now);
255 const fallbackModels = getCursorFallbackModels(cursorSettings);
256
257 if (!cursorSettings.enabled) {
258 return buildServerProvider({
259 presentation: CURSOR_PRESENTATION,
260 enabled: false,
261 checkedAt,
262 models: fallbackModels,
263 probe: {
264 installed: false,
265 version: null,
266 status: "warning",
267 auth: { status: "unknown" },
268 message: "Cursor is disabled in T3 Code settings.",
269 },
270 });
271 }
272
273 const sdkApiKey = environment?.CURSOR_API_KEY?.trim();
274 if (!sdkApiKey) {
275 return buildServerProvider({
276 presentation: CURSOR_PRESENTATION,
277 enabled: cursorSettings.enabled,
278 checkedAt,
279 models: fallbackModels,
280 probe: {
281 installed: true,
282 version: null,
283 status: "error",
284 auth: { status: "unauthenticated" },
285 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
286 },
287 });
288 }
289
290 const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog;
291 const catalogResult = yield* sdkCatalog
292 .read(sdkApiKey)
293 .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result);
294
295 if (Result.isFailure(catalogResult)) {
296 yield* Effect.logWarning("Cursor SDK catalog probe failed", {
297 cause: catalogResult.failure.cause,
298 });
299 const authenticationFailure = catalogResult.failure.authenticationFailure;
300 return buildServerProvider({
301 presentation: CURSOR_PRESENTATION,
302 enabled: cursorSettings.enabled,
303 checkedAt,
304 models: fallbackModels,
305 probe: {
306 installed: true,
307 version: null,
308 status: "error",
309 auth: { status: authenticationFailure ? "unauthenticated" : "unknown" },
310 message: authenticationFailure
311 ? authenticationType === "browser"
312 ? "Cursor sign-in expired or was rejected. Sign in again in provider settings."
313 : "Cursor SDK authentication failed. Check CURSOR_API_KEY."
314 : "Cursor SDK catalog request failed. Check server logs for details.",
315 },
316 });
317 }
318
319 if (Option.isNone(catalogResult.success)) {
320 return buildServerProvider({
321 presentation: CURSOR_PRESENTATION,
322 enabled: cursorSettings.enabled,
323 checkedAt,
324 models: fallbackModels,
325 probe: {
326 installed: true,
327 version: null,
328 status: "error",
329 auth: { status: "unknown" },
330 message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`,
331 },
332 });
333 }
334
335 const snapshot = catalogResult.success.value;
336 const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models);
337 return buildCursorProviderSnapshot({
338 checkedAt,
339 cursorSettings,
340 parsed: {
341 version: null,
342 status: "ready",
343 auth: cursorSdkAuth(snapshot.user, authenticationType),
344 },
345 discoveredModels,
346 ...(discoveredModels.length === 0
347 ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." }
348 : {}),
349 });
350});
apps/server/src/provider/CursorSdkCatalog.test.ts created+66
......@@ -0,0 +1,66 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5
6import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
7
8const user = {
9 apiKeyName: "test-key",
10 userEmail: "cursor@example.com",
11 createdAt: "2026-01-01T00:00:00.000Z",
12} satisfies SDKUser;
13
14const model = {
15 id: "claude-opus-4-8",
16 displayName: "Opus 4.8",
17 parameters: [],
18} satisfies SDKModel;
19
20const catalogError = () =>
21 new CursorSdkCatalog.CursorSdkCatalogError({
22 authenticationFailure: false,
23 cause: new Error("catalog unavailable"),
24 });
25
26describe("CursorSdkCatalog", () => {
27 it.effect("keeps the user probe fresh while caching models by API key", () =>
28 Effect.gen(function* () {
29 let userCalls = 0;
30 let modelCalls = 0;
31 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
32 readUser: () => Effect.sync(() => ((userCalls += 1), user)),
33 readModels: () => Effect.sync(() => ((modelCalls += 1), [model])),
34 });
35
36 yield* catalog.read("first-key");
37 yield* catalog.read("first-key");
38 yield* catalog.read("second-key");
39
40 expect(userCalls).toBe(3);
41 expect(modelCalls).toBe(2);
42 }),
43 );
44
45 it.effect("does not cache failed or empty model discovery", () =>
46 Effect.gen(function* () {
47 let modelCalls = 0;
48 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
49 readUser: () => Effect.succeed(user),
50 readModels: () =>
51 Effect.suspend(() => {
52 modelCalls += 1;
53 if (modelCalls === 1) return Effect.fail(catalogError());
54 if (modelCalls === 2) return Effect.succeed([]);
55 return Effect.succeed([model]);
56 }),
57 });
58
59 expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true);
60 expect((yield* catalog.read("test-key")).models).toEqual([]);
61 expect((yield* catalog.read("test-key")).models).toEqual([model]);
62 expect((yield* catalog.read("test-key")).models).toEqual([model]);
63 expect(modelCalls).toBe(3);
64 }),
65 );
66});
apps/server/src/provider/CursorSdkCatalog.ts created+92
......@@ -0,0 +1,92 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts";
11
12export interface CursorSdkCatalogSnapshot {
13 readonly user: SDKUser;
14 readonly models: ReadonlyArray<SDKModel>;
15}
16
17export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()(
18 "CursorSdkCatalogError",
19 {
20 authenticationFailure: Schema.Boolean,
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return this.authenticationFailure
26 ? "Cursor SDK authentication failed."
27 : "Cursor SDK catalog request failed.";
28 }
29}
30
31export interface CursorSdkCatalogShape {
32 readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
33}
34
35export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()(
36 "t3/provider/CursorSdkCatalog",
37) {}
38
39function isAuthenticationFailure(cause: unknown): boolean {
40 return (
41 cause instanceof AuthenticationError ||
42 (cause instanceof CursorSdkError && cause.status === 401)
43 );
44}
45
46export interface CursorSdkCatalogProbes {
47 readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
48 readonly readModels: (
49 apiKey: string,
50 ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>;
51}
52
53const sdkRequest = <A>(tryRequest: () => Promise<A>) =>
54 Effect.tryPromise({
55 try: tryRequest,
56 catch: (cause) =>
57 new CursorSdkCatalogError({
58 authenticationFailure: isAuthenticationFailure(cause),
59 cause,
60 }),
61 });
62
63const liveProbes: CursorSdkCatalogProbes = {
64 readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })),
65 readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })),
66};
67
68export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
69 probes: CursorSdkCatalogProbes = liveProbes,
70) {
71 const modelCache = yield* Cache.makeWith(probes.readModels, {
72 capacity: 32,
73 timeToLive: (exit) =>
74 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
75 });
76 return CursorSdkCatalog.of({
77 read: (apiKey) =>
78 Effect.all(
79 {
80 user: probes.readUser(apiKey),
81 models: Cache.get(modelCache, apiKey),
82 },
83 { concurrency: "unbounded" },
84 ),
85 });
86});
87
88export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog());
89
90export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> {
91 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));
92}
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+1-1
......@@ -29,7 +29,7 @@ import {
2929 ANTIGRAVITY_AUTH_STDOUT_PREFIX,
3030 resolveAntigravityInstanceDirectories,
3131} from "../antigravityAuthSupport.ts";
32import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
32import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
3333import * as ModelManifest from "../ModelManifest.ts";
3434import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
3535import { AntigravityDriver } from "./AntigravityDriver.ts";
apps/server/src/provider/Drivers/AntigravityDriver.ts+2-2
......@@ -52,8 +52,8 @@ import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderCo
5252import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts";
5353import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
5454import { ProviderDriverError } from "../Errors.ts";
55import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts";
56import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
55import { makeAntigravityProvider } from "../AntigravityProvider.ts";
56import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
5757import * as ModelManifest from "../ModelManifest.ts";
5858import {
5959 defaultProviderContinuationIdentity,
apps/server/src/provider/Drivers/ClaudeDriver.ts+5-5
......@@ -27,22 +27,22 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat
2727import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2828import * as ServerConfig from "../../config.ts";
2929import { expandHomePath } from "../../pathExpansion.ts";
30import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
30import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
3131import {
3232 createClaudeAdapterV2,
3333 type ClaudeAdapterV2DriverEnv,
3434} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
3535import * as ServerSettings from "../../serverSettings.ts";
3636import { ProviderDriverError } from "../Errors.ts";
37import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
38import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts";
39import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
37import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";
38import * as ClaudeResetCredits from "../claudeResetCredits.ts";
39import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
4040import {
4141 checkClaudeProviderStatus,
4242 makePendingClaudeProvider,
4343 probeClaudeCapabilities,
4444 probeClaudeWorkspaceSnapshot,
45} from "../Layers/ClaudeProvider.ts";
45} from "../ClaudeProvider.ts";
4646import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4747import * as ModelManifest from "../ModelManifest.ts";
4848import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
apps/server/src/provider/Drivers/CodexDriver.test.ts+2-2
......@@ -25,8 +25,8 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
2525import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2626import * as ServerConfig from "../../config.ts";
2727import * as ServerSettings from "../../serverSettings.ts";
28import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
29import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
28import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
29import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
3030import * as ModelManifest from "../ModelManifest.ts";
3131import {
3232 createProviderVersionAdvisory,
apps/server/src/provider/Drivers/CodexDriver.ts+4-4
......@@ -34,21 +34,21 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio
3434import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
3535import * as ServerConfig from "../../config.ts";
3636import { expandHomePath } from "../../pathExpansion.ts";
37import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
37import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
3838import {
3939 createCodexAdapterV2,
4040 type CodexAdapterV2DriverEnv,
4141} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
4242import * as ServerSettings from "../../serverSettings.ts";
4343import { ProviderDriverError } from "../Errors.ts";
44import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
44import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
4545import {
4646 checkCodexProviderStatus,
4747 makePendingCodexProvider,
4848 probeCodexSkillsForCwd,
4949 withCodexAppServerClient,
50} from "../Layers/CodexProvider.ts";
51import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
50} from "../CodexProvider.ts";
51import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts";
5252import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
5353import * as ModelManifest from "../ModelManifest.ts";
5454import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
apps/server/src/provider/Drivers/CodexManagedProvider.ts+1-1
......@@ -13,7 +13,7 @@ import {
1313 checkCodexProviderStatus,
1414 makePendingCodexProvider,
1515 probeCodexSkillsForCwd,
16} from "../Layers/CodexProvider.ts";
16} from "../CodexProvider.ts";
1717import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
1818import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
1919import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts";
apps/server/src/provider/Drivers/CursorDriver.test.ts+1-1
......@@ -14,7 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1414import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1515import * as ServerConfig from "../../config.ts";
1616import * as ServerSettings from "../../serverSettings.ts";
17import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
17import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
1818import { CursorDriver } from "./CursorDriver.ts";
1919import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts";
2020import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
apps/server/src/provider/Drivers/CursorDriver.ts+3-3
......@@ -13,7 +13,7 @@ import * as FileSystem from "effect/FileSystem";
1313import * as Path from "effect/Path";
1414import * as Schema from "effect/Schema";
1515import { HttpClient } from "effect/http";
16import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts";
16import { readCursorUsageLimits } from "../cursorUsageLimits.ts";
1717
1818import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1919import * as ServerConfig from "../../config.ts";
......@@ -27,8 +27,8 @@ import { ProviderDriverError } from "../Errors.ts";
2727import {
2828 buildInitialCursorProviderSnapshot,
2929 checkCursorProviderStatus,
30} from "../Layers/CursorProvider.ts";
31import * as CursorSdkCatalog from "../Layers/CursorSdkCatalog.ts";
30} from "../CursorProvider.ts";
31import * as CursorSdkCatalog from "../CursorSdkCatalog.ts";
3232import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3333import {
3434 defaultProviderContinuationIdentity,
apps/server/src/provider/Drivers/GrokDriver.test.ts+1-1
......@@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1212import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1313import * as ServerConfig from "../../config.ts";
1414import * as ServerSettings from "../../serverSettings.ts";
15import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
15import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
1616import { GrokDriver } from "./GrokDriver.ts";
1717
1818import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
apps/server/src/provider/Drivers/GrokDriver.ts+3-3
......@@ -9,7 +9,7 @@ import { ChildProcessSpawner } from "effect/process";
99
1010import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1111import * as ServerConfig from "../../config.ts";
12import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
12import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
1313import * as ServerSettings from "../../serverSettings.ts";
1414import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
1515import {
......@@ -21,8 +21,8 @@ import {
2121 buildInitialGrokProviderSnapshot,
2222 checkGrokProviderStatus,
2323 enrichGrokSnapshot,
24} from "../Layers/GrokProvider.ts";
25import { readGrokAccount } from "../Layers/grokUsageLimits.ts";
24} from "../GrokProvider.ts";
25import { readGrokAccount } from "../grokUsageLimits.ts";
2626import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
2727import {
2828 defaultProviderContinuationIdentity,
apps/server/src/provider/Drivers/OpenCodeDriver.test.ts+1-1
......@@ -18,7 +18,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1818import * as ServerConfig from "../../config.ts";
1919import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
2020import * as ServerSettings from "../../serverSettings.ts";
21import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";
21import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
2222import * as ProviderMaintenance from "../providerMaintenance.ts";
2323import * as OpenCodeRuntime from "../opencodeRuntime.ts";
2424import {
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-2
......@@ -34,7 +34,7 @@ import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdap
3434import * as ServerSettings from "../../serverSettings.ts";
3535import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";
3636import { ProviderDriverError } from "../Errors.ts";
37import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts";
37import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts";
3838import {
3939 checkOpenCodeProviderStatus,
4040 loadOpenCode2Workspace,
......@@ -44,7 +44,7 @@ import {
4444 openCode2SkillsToServerProviderSkills,
4545 openCodeSkillsToServerProviderSkills,
4646 openCodeCommandsToServerProviderSlashCommands,
47} from "../Layers/OpenCodeProvider.ts";
47} from "../OpenCodeProvider.ts";
4848import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4949import * as OpenCodeRuntime from "../opencodeRuntime.ts";
5050import {
apps/server/src/provider/Drivers/PiDriver.ts+1-1
......@@ -27,7 +27,7 @@ import {
2727 buildInitialPiProviderSnapshot,
2828 checkPiProviderStatus,
2929 enrichPiSnapshot,
30} from "../Layers/PiProvider.ts";
30} from "../PiProvider.ts";
3131import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3232import {
3333 defaultProviderContinuationIdentity,
apps/server/src/provider/EventNdjsonLogger.test.ts created+856
......@@ -0,0 +1,856 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import { ThreadId } from "@t3tools/contracts";
7import { assert, describe, it } from "@effect/vitest";
8import * as Clock from "effect/Clock";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Logger from "effect/Logger";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14
15import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
16import {
17 makeEventNdjsonLogger,
18 makeEventNdjsonLogStore,
19 type PendingRecord,
20 writeBatchedMessages,
21} from "./EventNdjsonLogger.ts";
22
23const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
24const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
25
26function ownedLogPath(basePath: string, segment: string): string {
27 const basename = NodePath.basename(basePath);
28 const extension = NodePath.extname(basename);
29 const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename;
30 return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`);
31}
32
33function parseLogLine(line: string) {
34 const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line);
35 assert.notEqual(match, null);
36 if (!match) {
37 throw new Error(`invalid log line: ${line}`);
38 }
39 const observedAt = match[1];
40 const stream = match[2];
41 const payload = match[3];
42 if (!observedAt || !stream || payload === undefined) {
43 throw new Error(`invalid log line: ${line}`);
44 }
45 return {
46 observedAt,
47 stream,
48 payload,
49 };
50}
51
52describe("EventNdjsonLogger", () => {
53 it.effect("summarizes circular events without exposing their contents in diagnostics", () => {
54 const messages: Array<unknown> = [];
55 const logCapture = Logger.make<unknown, void>(({ message }) => {
56 if (Array.isArray(message)) {
57 messages.push(...message);
58 } else {
59 messages.push(message);
60 }
61 });
62 const secret = "secret-circular-event-value";
63
64 return Effect.gen(function* () {
65 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
66 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
67 const circular: Record<string, unknown> = { secret };
68 circular.self = circular;
69
70 try {
71 const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" });
72 assert.exists(logger);
73 if (!logger) return;
74 yield* logger.write(circular, ThreadId.make("thread-1"));
75 yield* logger.close();
76
77 const serialized = encodeUnknownJson(messages);
78 assert.notInclude(serialized, secret);
79 const line = parseLogLine(
80 NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(),
81 );
82 assert.equal(line.payload, '{"truncated":true}');
83 } finally {
84 NodeFS.rmSync(tempDir, { recursive: true, force: true });
85 }
86 }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false })));
87 });
88
89 it.effect("writes effect-style lines to thread-scoped files", () =>
90 Effect.gen(function* () {
91 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
92 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
93
94 try {
95 const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" });
96 assert.notEqual(logger, undefined);
97 if (!logger) {
98 return;
99 }
100
101 yield* logger.write(
102 { threadId: "provider-thread-1", id: "evt-1" },
103 ThreadId.make("thread-1"),
104 );
105 yield* logger.write(
106 { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" },
107 ThreadId.make("thread-2"),
108 );
109 yield* logger.close();
110
111 const threadOnePath = ownedLogPath(basePath, "thread-1");
112 const threadTwoPath = ownedLogPath(basePath, "thread-2");
113 assert.equal(NodeFS.existsSync(threadOnePath), true);
114 assert.equal(NodeFS.existsSync(threadTwoPath), true);
115
116 const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim());
117 const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim());
118
119 assert.equal(Number.isNaN(Date.parse(first.observedAt)), false);
120 assert.equal(first.stream, "NTIVE");
121 assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}');
122
123 assert.equal(Number.isNaN(Date.parse(second.observedAt)), false);
124 assert.equal(second.stream, "NTIVE");
125 assert.equal(
126 second.payload,
127 '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}',
128 );
129 } finally {
130 NodeFS.rmSync(tempDir, { recursive: true, force: true });
131 }
132 }),
133 );
134
135 it.effect(
136 "falls back to a global segment when orchestration thread id is missing or invalid",
137 () =>
138 Effect.gen(function* () {
139 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
140 const basePath = NodePath.join(tempDir, "provider-canonical.ndjson");
141
142 try {
143 const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" });
144 assert.notEqual(logger, undefined);
145 if (!logger) {
146 return;
147 }
148
149 yield* logger.write({ id: "evt-no-thread" }, null);
150 yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId);
151 yield* logger.close();
152
153 const globalPath = ownedLogPath(basePath, "_global");
154 assert.equal(NodeFS.existsSync(globalPath), true);
155 const lines = NodeFS.readFileSync(globalPath, "utf8")
156 .trim()
157 .split("\n")
158 .map((line) => parseLogLine(line));
159 assert.equal(lines.length, 2);
160 assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false);
161 assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false);
162 assert.equal(lines[0]?.stream, "ORCH");
163 assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}');
164 assert.equal(lines[1]?.stream, "ORCH");
165 assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}');
166 } finally {
167 NodeFS.rmSync(tempDir, { recursive: true, force: true });
168 }
169 }),
170 );
171
172 it.effect("shares one thread writer across native and canonical streams", () =>
173 Effect.gen(function* () {
174 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
175 const basePath = NodePath.join(tempDir, "events.log");
176
177 try {
178 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
179 const native = store.logger("native");
180 const canonical = store.logger("canonical");
181 const threadId = ThreadId.make("thread-shared");
182
183 yield* native.write({ id: "native-event" }, threadId);
184 yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId);
185 yield* store.close();
186
187 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8")
188 .trim()
189 .split("\n")
190 .map(parseLogLine);
191
192 assert.deepEqual(
193 lines.map(({ stream, payload }) => ({ stream, payload })),
194 [
195 { stream: "NTIVE", payload: '{"id":"native-event"}' },
196 {
197 stream: "CANON",
198 payload: '{"type":"item.completed","id":"canonical-event"}',
199 },
200 ],
201 );
202 } finally {
203 NodeFS.rmSync(tempDir, { recursive: true, force: true });
204 }
205 }),
206 );
207
208 it.effect("keeps shared store views non-owning when one adapter closes", () =>
209 Effect.gen(function* () {
210 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
211 const basePath = NodePath.join(tempDir, "events.log");
212
213 try {
214 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
215 const native = store.logger("native");
216 const canonical = store.logger("canonical");
217 const threadId = ThreadId.make("thread-shared-close");
218
219 yield* native.write({ id: "before-close" }, threadId);
220 yield* native.close();
221 yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId);
222 yield* store.close();
223
224 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8")
225 .trim()
226 .split("\n")
227 .map(parseLogLine);
228
229 assert.deepEqual(
230 lines.map(({ stream, payload }) => ({ stream, payload })),
231 [
232 { stream: "NTIVE", payload: '{"id":"before-close"}' },
233 {
234 stream: "CANON",
235 payload: '{"type":"item.completed","id":"after-close"}',
236 },
237 ],
238 );
239 } finally {
240 NodeFS.rmSync(tempDir, { recursive: true, force: true });
241 }
242 }),
243 );
244
245 it.effect("flushes an active batch without a permanent polling loop", () =>
246 Effect.gen(function* () {
247 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
248 const basePath = NodePath.join(tempDir, "events.log");
249 const threadPath = ownedLogPath(basePath, "thread-batched");
250
251 try {
252 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 });
253 yield* store
254 .logger("native")
255 .write({ id: "batched-event" }, ThreadId.make("thread-batched"));
256
257 assert.equal(NodeFS.existsSync(threadPath), false);
258 yield* TestClock.adjust(1_000);
259 assert.equal(NodeFS.existsSync(threadPath), true);
260 yield* store.close();
261 } finally {
262 NodeFS.rmSync(tempDir, { recursive: true, force: true });
263 }
264 }),
265 );
266
267 it.effect("does not strand a later batch after an interrupted write", () =>
268 Effect.gen(function* () {
269 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
270 const basePath = NodePath.join(tempDir, "events.log");
271 const threadPath = ownedLogPath(basePath, "thread-interrupted");
272
273 try {
274 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 });
275 const logger = store.logger("native");
276 const interruptedWrite = yield* logger
277 .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted"))
278 .pipe(Effect.forkChild);
279 yield* Effect.yieldNow;
280 yield* Fiber.interrupt(interruptedWrite);
281 yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted"));
282
283 yield* TestClock.adjust(1_000);
284
285 assert.equal(NodeFS.existsSync(threadPath), true);
286 assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}');
287 yield* store.close();
288 } finally {
289 NodeFS.rmSync(tempDir, { recursive: true, force: true });
290 }
291 }),
292 );
293
294 it.effect("drops transient provider events before serialization", () =>
295 Effect.gen(function* () {
296 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
297 const basePath = NodePath.join(tempDir, "events.log");
298
299 try {
300 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
301 const canonical = store.logger("canonical");
302 const native = store.logger("native");
303 const threadId = ThreadId.make("thread-filtered");
304 const circularDelta: Record<string, unknown> = { type: "content.delta" };
305 circularDelta["self"] = circularDelta;
306
307 yield* canonical.write(circularDelta, threadId);
308 yield* canonical.write({ type: "item.completed", id: "final" }, threadId);
309 yield* native.write({ type: "content.delta", id: "native-delta" }, threadId);
310 yield* native.write(
311 { method: "item/agentMessage/delta", payload: circularDelta },
312 threadId,
313 );
314 yield* native.write(
315 { method: "thread/realtime/outputAudio/delta", payload: circularDelta },
316 threadId,
317 );
318 yield* native.write(
319 { method: "thread/realtime/transcript/delta", payload: circularDelta },
320 threadId,
321 );
322 yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId);
323 yield* native.write(
324 {
325 event: {
326 direction: "incoming",
327 stage: "decoded",
328 payload: {
329 method: "turn/diff/updated",
330 get params() {
331 throw new Error("unused diff snapshots must not be traversed");
332 },
333 },
334 },
335 },
336 threadId,
337 );
338 yield* native.write(
339 {
340 event: {
341 method: "claude/stream_event/content_block_delta/text_delta",
342 payload: circularDelta,
343 },
344 },
345 threadId,
346 );
347 yield* native.write(
348 {
349 event: {
350 method: "session/update",
351 payload: { update: { sessionUpdate: "agent_message_chunk" } },
352 },
353 },
354 threadId,
355 );
356 yield* native.write(
357 {
358 event: {
359 direction: "incoming",
360 stage: "decoded",
361 payload: { method: "item/commandExecution/outputDelta", params: circularDelta },
362 },
363 },
364 threadId,
365 );
366 yield* native.write(
367 {
368 event: {
369 direction: "incoming",
370 stage: "decoded",
371 payload: {
372 type: "stream_event",
373 event: { type: "content_block_delta", delta: circularDelta },
374 },
375 },
376 },
377 threadId,
378 );
379 yield* native.write(
380 {
381 event: {
382 direction: "incoming",
383 stage: "raw",
384 get payload() {
385 throw new Error("raw frames must not be inspected");
386 },
387 },
388 },
389 threadId,
390 );
391 yield* native.write(
392 {
393 event: {
394 type: "message.part.updated",
395 payload: { properties: { part: { type: "text" } } },
396 },
397 },
398 threadId,
399 );
400 yield* native.write(
401 {
402 event: {
403 type: "message.part.updated",
404 payload: {
405 properties: {
406 part: { type: "tool", state: { status: "running", output: "progress" } },
407 },
408 },
409 },
410 },
411 threadId,
412 );
413 yield* native.write({ type: "turn.completed", id: "native-final" }, threadId);
414 yield* store.close();
415
416 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8")
417 .trim()
418 .split("\n")
419 .map(parseLogLine);
420
421 assert.deepEqual(
422 lines.map(({ stream, payload }) => ({ stream, payload })),
423 [
424 { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' },
425 { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' },
426 ],
427 );
428 } finally {
429 NodeFS.rmSync(tempDir, { recursive: true, force: true });
430 }
431 }),
432 );
433
434 it.effect("summarizes large histories without reading their items", () =>
435 Effect.gen(function* () {
436 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
437 const basePath = NodePath.join(tempDir, "events.log");
438 const turns = Array.from({ length: 10_000 });
439 Object.defineProperty(turns, 0, {
440 get: () => {
441 throw new Error("history must not be serialized");
442 },
443 });
444
445 try {
446 const store = yield* makeEventNdjsonLogStore(basePath);
447 yield* store.logger("native").write(
448 {
449 provider: "codex",
450 event: {
451 direction: "incoming",
452 stage: "decoded",
453 payload: { id: 42, result: { thread: { id: "native-thread", turns } } },
454 },
455 },
456 ThreadId.make("large-history"),
457 );
458 yield* store.close();
459
460 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8");
461 assert.isBelow(Buffer.byteLength(contents), 2_048);
462 const record = decodeUnknownJson(parseLogLine(contents.trim()).payload);
463 assert.nestedPropertyVal(record, "event.payload.id", 42);
464 assert.nestedPropertyVal(record, "event.payload.result.thread.id", "native-thread");
465 assert.nestedPropertyVal(record, "event.payload.result.thread.turns.itemCount", 10_000);
466 } finally {
467 NodeFS.rmSync(tempDir, { recursive: true, force: true });
468 }
469 }),
470 );
471
472 it.effect("bounds oversized records while retaining failure details", () =>
473 Effect.gen(function* () {
474 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
475 const basePath = NodePath.join(tempDir, "events.log");
476
477 try {
478 const store = yield* makeEventNdjsonLogStore(basePath);
479 const logger = store.logger("native");
480 const threadId = ThreadId.make("large-error");
481 const failure = {
482 method: "error",
483 params: {
484 threadId: "native-thread",
485 turnId: "native-turn",
486 error: { message: "The provider is unavailable.", code: "overloaded" },
487 output: "x".repeat(128 * 1_024),
488 },
489 };
490 yield* logger.write(failure, threadId);
491 yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId);
492 yield* store.close();
493
494 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8");
495 const records = contents
496 .trim()
497 .split("\n")
498 .map((line) => decodeUnknownJson(parseLogLine(line).payload));
499 assert.isBelow(Buffer.byteLength(contents), 64 * 1_024);
500 assert.equal(records.length, 2);
501 assert.nestedPropertyVal(records[0], "params.threadId", "native-thread");
502 assert.nestedPropertyVal(records[0], "params.turnId", "native-turn");
503 assert.nestedPropertyVal(
504 records[0],
505 "params.error.message",
506 "The provider is unavailable.",
507 );
508 assert.nestedPropertyVal(records[0], "params.error.code", "overloaded");
509 assert.propertyVal(records[1], "id", "escaped");
510 } finally {
511 NodeFS.rmSync(tempDir, { recursive: true, force: true });
512 }
513 }),
514 );
515
516 it.effect("bounds canonical diff snapshots before serializing their duplicate payloads", () =>
517 Effect.gen(function* () {
518 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
519 const basePath = NodePath.join(tempDir, "events.log");
520 const threadId = ThreadId.make("large-diff");
521 const diff = "diff-payload".repeat(128 * 1_024);
522 try {
523 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
524 yield* store.logger("canonical").write(
525 {
526 type: "turn.diff.updated",
527 threadId,
528 turnId: "native-turn",
529 raw: { method: "turn/diff/updated", payload: { diff } },
530 payload: { unifiedDiff: diff },
531 },
532 threadId,
533 );
534 yield* store.close();
535 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8");
536 assert.isBelow(Buffer.byteLength(contents), 2_048);
537 const record = decodeUnknownJson(parseLogLine(contents.trim()).payload);
538 assert.propertyVal(record, "type", "turn.diff.updated");
539 assert.propertyVal(record, "threadId", threadId);
540 assert.propertyVal(record, "turnId", "native-turn");
541 } finally {
542 NodeFS.rmSync(tempDir, { recursive: true, force: true });
543 }
544 }),
545 );
546
547 it.effect("keeps OpenCode tool input, final output, and errors in native logs", () =>
548 Effect.gen(function* () {
549 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
550 const basePath = NodePath.join(tempDir, "events.log");
551 const threadId = ThreadId.make("thread-tool-lifecycle");
552
553 try {
554 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
555 const native = store.logger("native");
556 const input = { command: "run-build" };
557 const states = [
558 { status: "pending", input },
559 { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } },
560 { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } },
561 { status: "unknown", input },
562 null,
563 ];
564 const events = states.map((state) => ({
565 event: {
566 type: "message.part.updated",
567 payload: { properties: { part: { type: "tool", state } } },
568 },
569 }));
570 for (const event of events) {
571 yield* native.write(event, threadId);
572 }
573 yield* store.close();
574
575 const payloads = NodeFS.readFileSync(
576 ownedLogPath(basePath, "thread-tool-lifecycle"),
577 "utf8",
578 )
579 .trim()
580 .split("\n")
581 .map((line) => parseLogLine(line).payload);
582 assert.deepEqual(
583 payloads,
584 events.map((event) => encodeUnknownJson(event)),
585 );
586 } finally {
587 NodeFS.rmSync(tempDir, { recursive: true, force: true });
588 }
589 }),
590 );
591
592 it.effect("contains hostile event accessors inside guarded serialization", () =>
593 Effect.gen(function* () {
594 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
595 const basePath = NodePath.join(tempDir, "events.log");
596
597 try {
598 const logger = yield* makeEventNdjsonLogger(basePath, {
599 stream: "canonical",
600 batchWindowMs: 0,
601 });
602 assert.exists(logger);
603 if (!logger) return;
604 const hostile = new Proxy(
605 { id: "hostile" },
606 {
607 get(_target, property) {
608 if (property === "type") throw new Error("blocked");
609 return undefined;
610 },
611 },
612 );
613
614 yield* logger.write(hostile, ThreadId.make("thread-hostile"));
615 yield* logger.close();
616
617 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8");
618 assert.notInclude(contents, "blocked");
619 } finally {
620 NodeFS.rmSync(tempDir, { recursive: true, force: true });
621 }
622 }),
623 );
624
625 it.effect("serializes concurrent first writes for the same segment", () =>
626 Effect.gen(function* () {
627 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
628 const basePath = NodePath.join(tempDir, "provider-canonical.ndjson");
629
630 try {
631 const logger = yield* makeEventNdjsonLogger(basePath, {
632 stream: "canonical",
633 batchWindowMs: 0,
634 });
635 assert.notEqual(logger, undefined);
636 if (!logger) {
637 return;
638 }
639
640 yield* Effect.all(
641 [
642 logger.write({ id: "evt-concurrent-1" }, null),
643 logger.write({ id: "evt-concurrent-2" }, null),
644 ],
645 { concurrency: "unbounded" },
646 );
647 yield* logger.close();
648
649 const globalPath = ownedLogPath(basePath, "_global");
650 assert.equal(NodeFS.existsSync(globalPath), true);
651 const lines = NodeFS.readFileSync(globalPath, "utf8")
652 .trim()
653 .split("\n")
654 .map((line) => parseLogLine(line));
655
656 assert.equal(lines.length, 2);
657 assert.deepEqual(lines.map((line) => line.payload).toSorted(), [
658 '{"id":"evt-concurrent-1"}',
659 '{"id":"evt-concurrent-2"}',
660 ]);
661 } finally {
662 NodeFS.rmSync(tempDir, { recursive: true, force: true });
663 }
664 }),
665 );
666
667 it.effect("rotates per-thread files when max size is exceeded", () =>
668 Effect.gen(function* () {
669 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
670 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
671
672 try {
673 const store = yield* makeEventNdjsonLogStore(basePath, {
674 maxBytes: 120,
675 maxFiles: 2,
676 batchWindowMs: 0,
677 });
678 const native = store.logger("native");
679 const canonical = store.logger("canonical");
680
681 for (let index = 0; index < 10; index += 1) {
682 const logger = index % 2 === 0 ? native : canonical;
683 yield* logger.write(
684 {
685 type: "session.started",
686 threadId: "provider-thread-rotate",
687 id: `evt-${index}`,
688 payload: "x".repeat(40),
689 },
690 ThreadId.make("thread-rotate"),
691 );
692 }
693 yield* store.close();
694
695 const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate"));
696 const matchingFiles = NodeFS.readdirSync(tempDir)
697 .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`))
698 .toSorted();
699
700 assert.equal(
701 matchingFiles.some((entry) => entry === `${fileStem}.1`),
702 true,
703 );
704 assert.equal(
705 matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`),
706 true,
707 );
708 assert.equal(
709 matchingFiles.some((entry) => entry === `${fileStem}.3`),
710 false,
711 );
712 } finally {
713 NodeFS.rmSync(tempDir, { recursive: true, force: true });
714 }
715 }),
716 );
717
718 it.effect("enforces aggregate age and byte retention on startup", () =>
719 Effect.gen(function* () {
720 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
721 const basePath = NodePath.join(tempDir, "events.log");
722 const expiredPath = ownedLogPath(basePath, "expired");
723 const oldPath = ownedLogPath(basePath, "old");
724 const newPath = ownedLogPath(basePath, "new");
725 const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log");
726 const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log");
727 const ignoredPath = NodePath.join(tempDir, "ignored.txt");
728
729 try {
730 yield* TestClock.setTime(1_800_000_000_000);
731 const now = yield* Clock.currentTimeMillis;
732 for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) {
733 NodeFS.writeFileSync(filePath, "x".repeat(40));
734 }
735 NodeFS.writeFileSync(
736 legacyLogPath,
737 "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n",
738 );
739 NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000);
740 NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000);
741 NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000);
742 NodeFS.utimesSync(newPath, now / 1_000, now / 1_000);
743
744 const store = yield* makeEventNdjsonLogStore(basePath, {
745 maxAgeMs: 10_000,
746 maxTotalBytes: 60,
747 });
748 yield* store.close();
749
750 assert.equal(NodeFS.existsSync(expiredPath), false);
751 assert.equal(NodeFS.existsSync(legacyLogPath), false);
752 assert.equal(NodeFS.existsSync(oldPath), false);
753 assert.equal(NodeFS.existsSync(newPath), true);
754 assert.equal(NodeFS.existsSync(unrelatedLogPath), true);
755 assert.equal(NodeFS.existsSync(ignoredPath), true);
756 } finally {
757 NodeFS.rmSync(tempDir, { recursive: true, force: true });
758 }
759 }),
760 );
761
762 it.effect("does not prune an active thread sink during an unrelated flush", () =>
763 Effect.gen(function* () {
764 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
765 const basePath = NodePath.join(tempDir, "events.log");
766 const activePath = ownedLogPath(basePath, "active");
767
768 try {
769 yield* TestClock.setTime(1_800_000_000_000);
770 const store = yield* makeEventNdjsonLogStore(basePath, {
771 batchWindowMs: 0,
772 maxAgeMs: 1,
773 retentionCheckIntervalMs: 1,
774 });
775 const logger = store.logger("native");
776
777 yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active"));
778 assert.equal(NodeFS.existsSync(activePath), true);
779
780 yield* TestClock.adjust("2 millis");
781 yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other"));
782
783 assert.equal(NodeFS.existsSync(activePath), true);
784 yield* store.close();
785 } finally {
786 NodeFS.rmSync(tempDir, { recursive: true, force: true });
787 }
788 }),
789 );
790
791 it("attributes batches that were written before a later chunk fails", () => {
792 const records: ReadonlyArray<PendingRecord> = [
793 {
794 stream: "native",
795 threadSegment: "thread",
796 line: "first",
797 bytes: 5,
798 },
799 {
800 stream: "canonical",
801 threadSegment: "thread",
802 line: "second",
803 bytes: 6,
804 },
805 ];
806 const attributed: Array<PendingRecord> = [];
807 let writes = 0;
808
809 assert.throws(() =>
810 writeBatchedMessages(
811 {
812 write: () => {
813 writes += 1;
814 if (writes === 2) throw new Error("simulated disk exhaustion");
815 },
816 },
817 records,
818 5,
819 (written) => attributed.push(...written),
820 ),
821 );
822 assert.deepEqual(attributed, [records[0]]);
823 });
824
825 it.effect("reports logical provider log writes to resource attribution", () =>
826 Effect.gen(function* () {
827 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
828 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
829
830 try {
831 const attribution = yield* ResourceAttribution.make();
832 const logger = yield* makeEventNdjsonLogger(basePath, {
833 stream: "native",
834 batchWindowMs: 0,
835 attribution,
836 });
837 assert.notEqual(logger, undefined);
838 if (!logger) {
839 return;
840 }
841
842 yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution"));
843 yield* logger.close();
844
845 const snapshot = yield* attribution.snapshot;
846 assert.equal(snapshot.entries.length, 1);
847 assert.equal(snapshot.entries[0]?.component, "provider-event-log");
848 assert.equal(snapshot.entries[0]?.operation, "native.append");
849 assert.equal(snapshot.entries[0]?.count, 1);
850 assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0);
851 } finally {
852 NodeFS.rmSync(tempDir, { recursive: true, force: true });
853 }
854 }),
855 );
856});
apps/server/src/provider/EventNdjsonLogger.ts created+805
......@@ -0,0 +1,805 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Best-effort provider event logging with one shared writer per thread.
4 *
5 * Native and canonical views share batching, rotation, and retention state so
6 * they cannot race while appending to the same thread-scoped file.
7 */
8import * as NodeFS from "node:fs";
9import * as NodePath from "node:path";
10
11import type { ThreadId } from "@t3tools/contracts";
12import { RotatingFileSink } from "@t3tools/shared/logging";
13import { errorTag } from "@t3tools/shared/observability";
14import * as Clock from "effect/Clock";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";
20import * as SynchronizedRef from "effect/SynchronizedRef";
21
22import { toSafeThreadAttachmentSegment } from "../attachmentStore.ts";
23import type { ResourceAttribution } from "../resourceTelemetry/ResourceAttribution.ts";
24
25const MEBIBYTE = 1024 * 1024;
26const DAY_MS = 24 * 60 * 60 * 1_000;
27const DEFAULT_MAX_BYTES = 10 * MEBIBYTE;
28const DEFAULT_MAX_FILES = 10;
29const DEFAULT_BATCH_WINDOW_MS = 1_000;
30const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE;
31const DEFAULT_MAX_AGE_MS = 14 * DAY_MS;
32const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000;
33const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE;
34const DEFAULT_MAX_BUFFERED_RECORDS = 512;
35const MAX_RECORD_CHARACTERS = 64 * 1024;
36const MAX_RECORD_FIELDS = 1_024;
37const MAX_RECORD_DEPTH = 16;
38const GLOBAL_THREAD_SEGMENT = "_global";
39const LOG_SCOPE = "provider-observability";
40const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
41
42const transientCanonicalEventTypes = new Set([
43 "content.delta",
44 "hook.progress",
45 "item.updated",
46 "task.progress",
47 "thread.realtime.audio.delta",
48 "tool.progress",
49 "turn.proposed.delta",
50]);
51const transientNativeMethods = new Set([
52 "item/agentMessage/delta",
53 "item/commandExecution/outputDelta",
54 "item/fileChange/outputDelta",
55 "item/plan/delta",
56 "item/reasoning/summaryTextDelta",
57 "item/reasoning/textDelta",
58 "thread/realtime/outputAudio/delta",
59 "thread/realtime/transcript/delta",
60 "turn/diff/updated",
61]);
62const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
63
64export type EventNdjsonStream = "native" | "canonical" | "orchestration";
65
66export interface EventNdjsonLogger {
67 readonly filePath: string;
68 readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>;
69 readonly close: () => Effect.Effect<void>;
70}
71
72export interface EventNdjsonLogStore {
73 readonly filePath: string;
74 readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger;
75 readonly close: () => Effect.Effect<void>;
76}
77
78export interface EventNdjsonLogStoreOptions {
79 readonly maxBytes?: number;
80 readonly maxFiles?: number;
81 readonly batchWindowMs?: number;
82 readonly maxTotalBytes?: number;
83 readonly maxAgeMs?: number;
84 readonly retentionCheckIntervalMs?: number;
85 readonly maxBufferedBytes?: number;
86 readonly maxBufferedRecords?: number;
87 readonly attribution?: ResourceAttribution["Service"];
88}
89
90export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions {
91 readonly stream: EventNdjsonStream;
92}
93
94export class EventNdjsonLogConfigurationError extends Schema.TaggedError<EventNdjsonLogConfigurationError>()(
95 "EventNdjsonLogConfigurationError",
96 {
97 filePath: Schema.String,
98 option: Schema.String,
99 value: Schema.Number,
100 minimum: Schema.Number,
101 },
102) {
103 override get message(): string {
104 return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`;
105 }
106}
107
108export class EventNdjsonLogDirectoryError extends Schema.TaggedError<EventNdjsonLogDirectoryError>()(
109 "EventNdjsonLogDirectoryError",
110 {
111 directory: Schema.String,
112 cause: Schema.Defect(),
113 },
114) {
115 override get message(): string {
116 return `Failed to create provider event log directory '${this.directory}'`;
117 }
118}
119
120export type EventNdjsonLogStoreError =
121 | EventNdjsonLogConfigurationError
122 | EventNdjsonLogDirectoryError;
123
124interface ResolvedOptions {
125 readonly maxBytes: number;
126 readonly maxFiles: number;
127 readonly batchWindowMs: number;
128 readonly maxTotalBytes: number;
129 readonly maxAgeMs: number;
130 readonly retentionCheckIntervalMs: number;
131 readonly maxBufferedBytes: number;
132 readonly maxBufferedRecords: number;
133 readonly attribution: ResourceAttribution["Service"] | undefined;
134}
135
136export interface PendingRecord {
137 readonly stream: EventNdjsonStream;
138 readonly threadSegment: string;
139 readonly line: string;
140 readonly bytes: number;
141}
142
143interface StoreState {
144 readonly pending: Array<PendingRecord>;
145 readonly pendingBytes: number;
146 readonly sinks: ReadonlyMap<string, RotatingFileSink>;
147 readonly flushScheduled: boolean;
148 readonly closed: boolean;
149 readonly lastRetentionAt: number;
150}
151
152interface AttributionSummary {
153 readonly stream: EventNdjsonStream;
154 readonly count: number;
155 readonly logicalWriteBytes: number;
156}
157
158interface FileOperationFailure {
159 readonly filePath: string;
160 readonly cause: unknown;
161}
162
163interface RetentionResult {
164 readonly failures: ReadonlyArray<FileOperationFailure>;
165}
166
167interface DrainResult {
168 readonly attributions: ReadonlyArray<AttributionSummary>;
169 readonly failures: ReadonlyArray<FileOperationFailure>;
170}
171
172function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> {
173 return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE }));
174}
175
176function resolveThreadSegment(raw: string | null | undefined): string {
177 const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null;
178 return normalized ?? GLOBAL_THREAD_SEGMENT;
179}
180
181function resolveStreamLabel(stream: EventNdjsonStream): string {
182 return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON";
183}
184
185function providerLogPrefix(filePath: string): string {
186 const basename = NodePath.basename(filePath);
187 const extension = NodePath.extname(basename);
188 return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`;
189}
190
191function providerLogPath(directory: string, prefix: string, threadSegment: string): string {
192 return NodePath.join(directory, `${prefix}${threadSegment}.log`);
193}
194
195export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unknown): boolean {
196 if (stream === "orchestration" || typeof event !== "object" || event === null) {
197 return true;
198 }
199 try {
200 const type = Reflect.get(event, "type");
201 if (typeof type === "string" && transientCanonicalEventTypes.has(type)) {
202 return false;
203 }
204 if (stream !== "native") return true;
205
206 const nested = Reflect.get(event, "event");
207 const envelope = typeof nested === "object" && nested !== null ? nested : event;
208 // Decoded frames carry the same information as raw frames without another
209 // copy of every token delta. Decode failures have their own diagnostic frame.
210 if (Reflect.get(envelope, "stage") === "raw") return false;
211 const decodedPayload = Reflect.get(envelope, "payload");
212 const nativeEvent =
213 Reflect.get(envelope, "stage") === "decoded" &&
214 typeof decodedPayload === "object" &&
215 decodedPayload !== null
216 ? decodedPayload
217 : envelope;
218 const method = Reflect.get(nativeEvent, "method");
219 if (
220 typeof method === "string" &&
221 (transientNativeMethods.has(method) ||
222 method.startsWith("claude/stream_event/content_block_delta/"))
223 ) {
224 return false;
225 }
226
227 const nativeType = Reflect.get(nativeEvent, "type");
228 if (nativeType === "message.part.delta") return false;
229 if (nativeType === "stream_event") {
230 const streamEvent = Reflect.get(nativeEvent, "event");
231 if (
232 typeof streamEvent === "object" &&
233 streamEvent !== null &&
234 Reflect.get(streamEvent, "type") === "content_block_delta"
235 ) {
236 return false;
237 }
238 }
239
240 const payload = Reflect.get(nativeEvent, "payload");
241 if (typeof payload !== "object" || payload === null) return true;
242
243 if (method === "session/update") {
244 const update = Reflect.get(payload, "update");
245 if (typeof update !== "object" || update === null) return true;
246 const updateType = Reflect.get(update, "sessionUpdate");
247 return typeof updateType !== "string" || !transientAcpUpdates.has(updateType);
248 }
249
250 if (nativeType === "message.part.updated") {
251 const properties = Reflect.get(payload, "properties");
252 if (typeof properties !== "object" || properties === null) return true;
253 const part = Reflect.get(properties, "part");
254 if (typeof part !== "object" || part === null) return true;
255 const partType = Reflect.get(part, "type");
256 if (partType === "text" || partType === "reasoning") return false;
257 if (partType === "tool") {
258 // Running snapshots repeat growing output. Pending and terminal states stay in the log.
259 const state = Reflect.get(part, "state");
260 if (typeof state === "object" && state !== null) {
261 return Reflect.get(state, "status") !== "running";
262 }
263 }
264 return true;
265 }
266
267 return true;
268 } catch {
269 return true;
270 }
271}
272
273const summaryFields = [
274 "provider",
275 "protocol",
276 "kind",
277 "providerSessionId",
278 "direction",
279 "stage",
280 "type",
281 "subtype",
282 "method",
283 "id",
284 "threadId",
285 "turnId",
286 "requestId",
287 "session_id",
288 "status",
289 "is_error",
290 "api_error_status",
291 "terminal_reason",
292 "stop_reason",
293 "operation",
294 "code",
295 "willRetry",
296 "message",
297 "event",
298 "payload",
299 "params",
300 "result",
301 "thread",
302 "turn",
303 "error",
304 "turns",
305 "items",
306 "content",
307] as const;
308
309function summarizeProviderEvent(event: unknown): unknown {
310 let remainingFields = 128;
311 let remainingCharacters = 8 * 1024;
312 const summarize = (value: unknown, depth: number): unknown => {
313 if (typeof value === "string") {
314 if (value.length > Math.min(1_024, remainingCharacters)) {
315 return { omittedCharacters: value.length };
316 }
317 remainingCharacters -= value.length;
318 return value;
319 }
320 if (value === null || typeof value === "number" || typeof value === "boolean") return value;
321 if (typeof value !== "object") return undefined;
322 if (Array.isArray(value)) return { itemCount: value.length };
323 const summary: Record<string, unknown> = { truncated: true };
324 if (depth >= 6) return summary;
325 for (const key of summaryFields) {
326 if (remainingFields <= 0) break;
327 const nested = Reflect.get(value, key);
328 if (nested === undefined) continue;
329 remainingFields -= 1;
330 summary[key] = summarize(nested, depth + 1);
331 }
332 return summary;
333 };
334 try {
335 return summarize(event, 0);
336 } catch {
337 return { truncated: true };
338 }
339}
340
341/** Bounds traversal before adapters copy payloads or the logger encodes them. */
342export function boundProviderEventForLogging(event: unknown): unknown {
343 let remainingCharacters = MAX_RECORD_CHARACTERS;
344 let remainingFields = MAX_RECORD_FIELDS;
345 const ancestors = new WeakSet<object>();
346 const fits = (value: unknown, depth: number): boolean => {
347 if (typeof value === "string") {
348 remainingCharacters -= value.length;
349 return remainingCharacters >= 0;
350 }
351 if (typeof value !== "object" || value === null) return true;
352 if (depth > MAX_RECORD_DEPTH || ancestors.has(value)) return false;
353 if (Array.isArray(value) && value.length > remainingFields) return false;
354 ancestors.add(value);
355 for (const key in value) {
356 if (!Object.hasOwn(value, key)) continue;
357 remainingFields -= 1;
358 remainingCharacters -= key.length;
359 if (
360 remainingFields < 0 ||
361 remainingCharacters < 0 ||
362 !fits(Reflect.get(value, key), depth + 1)
363 )
364 return false;
365 }
366 ancestors.delete(value);
367 return true;
368 };
369 try {
370 if (fits(event, 0)) return event;
371 } catch {
372 // A failing accessor must not escape into provider processing.
373 }
374 return summarizeProviderEvent(event);
375}
376
377export function writeBatchedMessages(
378 sink: Pick<RotatingFileSink, "write">,
379 records: ReadonlyArray<PendingRecord>,
380 maxBytes: number,
381 onWritten: (records: ReadonlyArray<PendingRecord>) => void,
382): void {
383 let pendingRecords: Array<PendingRecord> = [];
384 let pendingBytes = 0;
385
386 const flush = () => {
387 if (pendingRecords.length === 0) return;
388 const writtenRecords = pendingRecords;
389 sink.write(writtenRecords.map((record) => record.line).join(""));
390 onWritten(writtenRecords);
391 pendingRecords = [];
392 pendingBytes = 0;
393 };
394
395 for (const record of records) {
396 if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) {
397 flush();
398 }
399 pendingRecords.push(record);
400 pendingBytes += record.bytes;
401 if (pendingBytes >= maxBytes) {
402 flush();
403 }
404 }
405 flush();
406}
407
408function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean {
409 if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false;
410 if (fileName.startsWith(filePrefix)) return true;
411
412 const descriptor = NodeFS.openSync(filePath, "r");
413 try {
414 const header = Buffer.alloc(256);
415 const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0);
416 return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead));
417 } finally {
418 NodeFS.closeSync(descriptor);
419 }
420}
421
422function enforceRetention(input: {
423 readonly directory: string;
424 readonly maxTotalBytes: number;
425 readonly maxAgeMs: number;
426 readonly activeFilePaths: ReadonlySet<string>;
427 readonly filePrefix: string;
428 readonly now: number;
429}): RetentionResult {
430 const failures: Array<FileOperationFailure> = [];
431 const files: Array<{ filePath: string; mtimeMs: number; size: number }> = [];
432
433 let entries: ReadonlyArray<NodeFS.Dirent>;
434 try {
435 entries = NodeFS.readdirSync(input.directory, { withFileTypes: true });
436 } catch (cause) {
437 return { failures: [{ filePath: input.directory, cause }] };
438 }
439
440 for (const entry of entries) {
441 if (!entry.isFile()) continue;
442 const filePath = NodePath.join(input.directory, entry.name);
443 try {
444 if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue;
445 const stat = NodeFS.statSync(filePath);
446 files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
447 } catch (cause) {
448 failures.push({ filePath, cause });
449 }
450 }
451
452 let totalBytes = files.reduce((total, file) => total + file.size, 0);
453 const remove = (file: (typeof files)[number]) => {
454 if (input.activeFilePaths.has(file.filePath)) return false;
455 try {
456 NodeFS.rmSync(file.filePath, { force: true });
457 totalBytes -= file.size;
458 return true;
459 } catch (cause) {
460 failures.push({ filePath: file.filePath, cause });
461 return false;
462 }
463 };
464
465 const retained = files.filter((file) => {
466 if (input.now - file.mtimeMs <= input.maxAgeMs) return true;
467 return !remove(file);
468 });
469
470 for (const file of retained.toSorted(
471 (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath),
472 )) {
473 if (totalBytes <= input.maxTotalBytes) break;
474 remove(file);
475 }
476
477 return { failures };
478}
479
480function validateOption(input: {
481 readonly filePath: string;
482 readonly option: string;
483 readonly value: number;
484 readonly minimum: number;
485}): EventNdjsonLogConfigurationError | undefined {
486 if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined;
487 return new EventNdjsonLogConfigurationError(input);
488}
489
490function resolveOptions(
491 filePath: string,
492 options: EventNdjsonLogStoreOptions,
493): Effect.Effect<ResolvedOptions, EventNdjsonLogConfigurationError> {
494 const resolved = {
495 maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
496 maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
497 batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS,
498 maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
499 maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
500 retentionCheckIntervalMs:
501 options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS,
502 maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
503 maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS,
504 attribution: options.attribution,
505 } satisfies ResolvedOptions;
506
507 const validations = [
508 ["maxBytes", resolved.maxBytes, 1],
509 ["maxFiles", resolved.maxFiles, 1],
510 ["batchWindowMs", resolved.batchWindowMs, 0],
511 ["maxTotalBytes", resolved.maxTotalBytes, 1],
512 ["maxAgeMs", resolved.maxAgeMs, 1],
513 ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1],
514 ["maxBufferedBytes", resolved.maxBufferedBytes, 1],
515 ["maxBufferedRecords", resolved.maxBufferedRecords, 1],
516 ] as const;
517
518 for (const [option, value, minimum] of validations) {
519 const error = validateOption({ filePath, option, value, minimum });
520 if (error) return Effect.fail(error);
521 }
522 return Effect.succeed(resolved);
523}
524
525function drainPending(input: {
526 readonly directory: string;
527 readonly options: ResolvedOptions;
528 readonly state: StoreState;
529 readonly filePrefix: string;
530 readonly now: number;
531 readonly timerFired: boolean;
532 readonly close: boolean;
533}): readonly [DrainResult, StoreState] {
534 if (input.state.closed) {
535 return [{ attributions: [], failures: [] }, input.state];
536 }
537
538 const sinks = new Map(input.state.sinks);
539 const failures: Array<FileOperationFailure> = [];
540 const attributionByStream = new Map<
541 EventNdjsonStream,
542 { count: number; logicalWriteBytes: number }
543 >();
544 const recordsBySegment = new Map<string, Array<PendingRecord>>();
545
546 for (const record of input.state.pending) {
547 const records = recordsBySegment.get(record.threadSegment) ?? [];
548 records.push(record);
549 recordsBySegment.set(record.threadSegment, records);
550 }
551
552 for (const [threadSegment, records] of recordsBySegment) {
553 const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment);
554 let sink = sinks.get(threadSegment);
555 if (!sink) {
556 try {
557 sink = new RotatingFileSink({
558 filePath,
559 maxBytes: input.options.maxBytes,
560 maxFiles: input.options.maxFiles,
561 throwOnError: true,
562 });
563 sinks.set(threadSegment, sink);
564 } catch (cause) {
565 failures.push({ filePath, cause });
566 continue;
567 }
568 }
569
570 try {
571 writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => {
572 for (const record of writtenRecords) {
573 const current = attributionByStream.get(record.stream) ?? {
574 count: 0,
575 logicalWriteBytes: 0,
576 };
577 attributionByStream.set(record.stream, {
578 count: current.count + 1,
579 logicalWriteBytes: current.logicalWriteBytes + record.bytes,
580 });
581 }
582 });
583 } catch (cause) {
584 sinks.delete(threadSegment);
585 failures.push({ filePath, cause });
586 }
587 }
588
589 const retentionDue =
590 input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs;
591 const retention = retentionDue
592 ? enforceRetention({
593 directory: input.directory,
594 maxTotalBytes: input.options.maxTotalBytes,
595 maxAgeMs: input.options.maxAgeMs,
596 activeFilePaths: new Set(
597 Array.from(sinks.keys(), (threadSegment) =>
598 providerLogPath(input.directory, input.filePrefix, threadSegment),
599 ),
600 ),
601 filePrefix: input.filePrefix,
602 now: input.now,
603 })
604 : { failures: [] };
605
606 return [
607 {
608 attributions: Array.from(attributionByStream, ([stream, value]) => ({
609 stream,
610 ...value,
611 })),
612 failures: [...failures, ...retention.failures],
613 },
614 {
615 pending: [],
616 pendingBytes: 0,
617 sinks,
618 flushScheduled: input.timerFired ? false : input.state.flushScheduled,
619 closed: input.close,
620 lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt,
621 },
622 ];
623}
624
625const serializeEvent = Effect.fnUntraced(function* (event: unknown) {
626 return yield* encodeUnknownJsonString(event).pipe(
627 Effect.catch((error) =>
628 logWarning("failed to serialize provider event log record", {
629 errorTag: errorTag(error),
630 }).pipe(Effect.as(undefined)),
631 ),
632 );
633});
634
635export const makeEventNdjsonLogStore = Effect.fnUntraced(function* (
636 filePath: string,
637 options: EventNdjsonLogStoreOptions = {},
638): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> {
639 const resolved = yield* resolveOptions(filePath, options);
640 const directory = NodePath.dirname(filePath);
641 const filePrefix = providerLogPrefix(filePath);
642
643 yield* Effect.try({
644 try: () => NodeFS.mkdirSync(directory, { recursive: true }),
645 catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }),
646 });
647
648 const initializedAt = yield* Clock.currentTimeMillis;
649 const initialRetention = yield* Effect.sync(() =>
650 enforceRetention({
651 directory,
652 maxTotalBytes: resolved.maxTotalBytes,
653 maxAgeMs: resolved.maxAgeMs,
654 activeFilePaths: new Set(),
655 filePrefix,
656 now: initializedAt,
657 }),
658 );
659 for (const failure of initialRetention.failures) {
660 yield* logWarning("provider event log retention failed", {
661 filePath: failure.filePath,
662 errorTag: errorTag(failure.cause),
663 });
664 }
665
666 const stateRef = yield* SynchronizedRef.make<StoreState>({
667 pending: [],
668 pendingBytes: 0,
669 sinks: new Map(),
670 flushScheduled: false,
671 closed: false,
672 lastRetentionAt: initializedAt,
673 });
674 const timerScope = yield* Scope.make();
675
676 const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) {
677 const startedAt = yield* Clock.currentTimeMillis;
678 const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
679 Effect.sync(() =>
680 drainPending({
681 directory,
682 options: resolved,
683 state,
684 filePrefix,
685 now: startedAt,
686 timerFired,
687 close,
688 }),
689 ),
690 );
691
692 for (const failure of result.failures) {
693 yield* logWarning("provider event log write or retention failed", {
694 filePath: failure.filePath,
695 errorTag: errorTag(failure.cause),
696 });
697 }
698
699 if (resolved.attribution && result.attributions.length > 0) {
700 const completedAt = yield* Clock.currentTimeMillis;
701 const durationMs = Math.max(0, completedAt - startedAt);
702 const totalBytes = result.attributions.reduce(
703 (total, entry) => total + entry.logicalWriteBytes,
704 0,
705 );
706 yield* Effect.forEach(
707 result.attributions,
708 (entry) =>
709 resolved.attribution?.record({
710 component: "provider-event-log",
711 operation: `${entry.stream}.append`,
712 logicalWriteBytes: entry.logicalWriteBytes,
713 count: entry.count,
714 durationMs:
715 totalBytes === 0
716 ? 0
717 : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)),
718 }) ?? Effect.void,
719 { discard: true },
720 );
721 }
722 });
723
724 const scheduleFlush = Effect.fnUntraced(function* () {
725 yield* Effect.forkIn(
726 Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))),
727 timerScope,
728 { startImmediately: true },
729 );
730 });
731
732 const close = Effect.fnUntraced(function* () {
733 yield* flush(false, true);
734 yield* Scope.close(timerScope, Exit.void);
735 });
736
737 const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>();
738 const logger = (stream: EventNdjsonStream): EventNdjsonLogger => {
739 const existing = loggerViews.get(stream);
740 if (existing) return existing;
741
742 const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) {
743 if (!shouldPersistProviderEvent(stream, event)) return;
744 let payload = yield* serializeEvent(boundProviderEventForLogging(event));
745 if (payload === undefined) return;
746 // Escaping can expand strings beyond their input size. Keep that bounded
747 // serialization out of the file too, while retaining routing/error fields.
748 if (Buffer.byteLength(payload) > MAX_RECORD_CHARACTERS) {
749 payload = yield* serializeEvent(summarizeProviderEvent(event));
750 if (payload === undefined) return;
751 }
752
753 const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
754 const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`;
755 const bytes = Buffer.byteLength(line);
756 const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => {
757 if (state.closed) {
758 return Effect.succeed([{ flush: false }, state] as const);
759 }
760 const pending = state.pending;
761 pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes });
762 const pendingBytes = state.pendingBytes + bytes;
763 const flush =
764 resolved.batchWindowMs === 0 ||
765 pending.length >= resolved.maxBufferedRecords ||
766 pendingBytes >= resolved.maxBufferedBytes;
767 const schedule = !flush && !state.flushScheduled;
768 const nextState = {
769 ...state,
770 pending,
771 pendingBytes,
772 flushScheduled: state.flushScheduled || schedule,
773 };
774 return (schedule ? scheduleFlush() : Effect.void).pipe(
775 Effect.as([{ flush }, nextState] as const),
776 );
777 }).pipe(Effect.uninterruptible);
778
779 if (action.flush) {
780 yield* flush(false, false);
781 }
782 });
783
784 const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger;
785 loggerViews.set(stream, view);
786 return view;
787 };
788
789 return { filePath, logger, close } satisfies EventNdjsonLogStore;
790});
791
792export const makeEventNdjsonLogger = Effect.fnUntraced(function* (
793 filePath: string,
794 options: EventNdjsonLoggerOptions,
795): Effect.fn.Return<EventNdjsonLogger | undefined> {
796 const store = yield* makeEventNdjsonLogStore(filePath, options).pipe(
797 Effect.catch((error) =>
798 logWarning(error.message, { error }).pipe(
799 Effect.as<EventNdjsonLogStore | undefined>(undefined),
800 ),
801 ),
802 );
803 if (!store) return undefined;
804 return { ...store.logger(options.stream), close: store.close };
805});
apps/server/src/provider/GrokProvider.test.ts created+845
......@@ -0,0 +1,845 @@
1// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file.
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Schema from "effect/Schema";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import { GrokSettings } from "@t3tools/contracts";
12
13import {
14 buildGrokModelCapabilities,
15 buildGrokModelsFromSessionModelState,
16 buildInitialGrokProviderSnapshot,
17 checkGrokProviderStatus,
18 grokSlashCommandsFromInitialize,
19 parseGrokModelsCliOutput,
20} from "./GrokProvider.ts";
21import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
22import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts";
23
24const decodeGrokSettings = Schema.decodeSync(GrokSettings);
25const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
26
27const LOGGED_IN_MODELS_OUTPUT = [
28 "You are logged in with grok.com.",
29 "",
30 "Default model: grok-4.6",
31 "",
32 "Available models:",
33 " * grok-4.6 (default)",
34 " - grok-4.5",
35 "",
36].join("\n");
37
38const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace(
39 "You are logged in with grok.com.",
40 "You are not authenticated.",
41);
42
43describe("parseGrokModelsCliOutput", () => {
44 it("reads login state and model slugs, marking the default", () => {
45 const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT);
46 expect(parsed.authenticated).toBe(true);
47 expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
48 ["grok-4.6", true],
49 ["grok-4.5", false],
50 ]);
51 });
52
53 it("detects a logged-out CLI even though it exits 0", () => {
54 expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false);
55 });
56
57 it("returns unknown auth for unrecognized output", () => {
58 expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull();
59 });
60});
61
62describe("grokSlashCommandsFromInitialize", () => {
63 it("publishes native ACP commands and input hints without permission overrides", () => {
64 const commands = grokSlashCommandsFromInitialize({
65 protocolVersion: 1,
66 _meta: {
67 availableCommands: [
68 { name: "compact", description: "Compress history", input: { hint: "what to preserve" } },
69 {
70 name: "always-approve",
71 description: "Skip permission prompts",
72 input: { hint: "on|off" },
73 },
74 { name: "context", description: "Show context usage", input: null },
75 { name: "session-info", description: "Show session details" },
76 { name: "deep-research", description: "Research a topic", input: { hint: "<query>" } },
77 { name: "workflow", description: "Manage workflows", input: { hint: "runs" } },
78 { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } },
79 ],
80 },
81 });
82 expect(commands.map((command) => command.name)).toEqual([
83 "compact",
84 "session-info",
85 "deep-research",
86 "workflow",
87 "goal",
88 ]);
89 expect(commands[0]?.input).toEqual({ hint: "what to preserve" });
90 expect(commands[2]).toEqual({
91 name: "deep-research",
92 description: "Research a topic",
93 input: { hint: "<query>" },
94 });
95 });
96
97 it("keeps valid commands when other metadata entries are malformed", () => {
98 const commands = grokSlashCommandsFromInitialize({
99 protocolVersion: 1,
100 _meta: {
101 availableCommands: [
102 null,
103 { name: "broken", description: 42 },
104 { name: " ", description: "Empty name" },
105 { name: " session-info ", description: " Session details " },
106 { name: "session-info", description: "Updated session details" },
107 ],
108 },
109 });
110 expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]);
111 expect(commands[1]?.description).toBe("Updated session details");
112 });
113
114 it("keeps compact available for older agents without command metadata", () => {
115 for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) {
116 expect(
117 grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map(
118 (command) => command.name,
119 ),
120 ).toEqual(["compact"]);
121 }
122 });
123});
124
125describe("buildGrokModelsFromSessionModelState", () => {
126 it("marks the agent's current model as default and keeps reasoning options", () => {
127 const models = buildGrokModelsFromSessionModelState({
128 currentModelId: "grok-4.6",
129 availableModels: [
130 {
131 modelId: "grok-4.6",
132 name: "Grok 4.6",
133 _meta: {
134 supportsReasoningEffort: true,
135 reasoningEffort: "high",
136 reasoningEfforts: [{ value: "high", label: "High", default: true }],
137 },
138 },
139 { modelId: "grok-4.5", name: "Grok 4.5" },
140 ],
141 });
142 expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
143 ["grok-4.6", true],
144 ["grok-4.5", false],
145 ]);
146 expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1);
147 });
148});
149
150describe("buildGrokModelCapabilities", () => {
151 it("preserves ACP-provided reasoning labels and the active default", () => {
152 const capabilities = buildGrokModelCapabilities({
153 modelId: "grok-4.6",
154 name: "Grok 4.6",
155 _meta: {
156 supportsReasoningEffort: true,
157 reasoningEffort: "xhigh",
158 reasoningEfforts: [
159 { value: "xhigh", label: "Extra High Effort", default: true },
160 { value: "high", label: "High Effort", default: true },
161 { value: "medium", label: "Medium Effort" },
162 { value: "low", label: "Low Effort" },
163 ],
164 },
165 });
166
167 expect(capabilities.optionDescriptors).toEqual([
168 {
169 id: "reasoningEffort",
170 label: "Reasoning",
171 type: "select",
172 currentValue: "xhigh",
173 options: [
174 { id: "xhigh", label: "Extra High Effort", isDefault: true },
175 { id: "high", label: "High Effort" },
176 { id: "medium", label: "Medium Effort" },
177 { id: "low", label: "Low Effort" },
178 ],
179 },
180 ]);
181 });
182
183 it("uses raw ACP values when option labels are omitted", () => {
184 const capabilities = buildGrokModelCapabilities({
185 modelId: "grok-4.6",
186 name: "Grok 4.6",
187 _meta: {
188 supportsReasoningEffort: true,
189 reasoningEffort: "xhigh",
190 reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }],
191 },
192 });
193
194 expect(capabilities.optionDescriptors).toEqual([
195 {
196 id: "reasoningEffort",
197 label: "Reasoning",
198 type: "select",
199 currentValue: "xhigh",
200 options: [
201 { id: "xhigh", label: "xhigh" },
202 { id: "medium", label: "medium" },
203 ],
204 },
205 ]);
206 });
207
208 it("keeps ACP current effort separate from its collapsed advertised default", () => {
209 const capabilities = buildGrokModelCapabilities({
210 modelId: "grok-4.6",
211 name: "Grok 4.6",
212 _meta: {
213 supportsReasoningEffort: true,
214 reasoningEffort: "medium",
215 reasoningEfforts: [
216 { value: "xhigh", label: "Extra High Effort", default: true },
217 { value: "high", label: "High Effort", default: true },
218 { value: "medium", label: "Medium Effort" },
219 ],
220 },
221 });
222
223 expect(capabilities.optionDescriptors).toEqual([
224 {
225 id: "reasoningEffort",
226 label: "Reasoning",
227 type: "select",
228 currentValue: "medium",
229 options: [
230 { id: "xhigh", label: "Extra High Effort", isDefault: true },
231 { id: "high", label: "High Effort" },
232 { id: "medium", label: "Medium Effort" },
233 ],
234 },
235 ]);
236 });
237
238 it("preserves ACP descriptions and falls back from invalid values to valid ids", () => {
239 const capabilities = buildGrokModelCapabilities({
240 modelId: "grok-4.6",
241 name: "Grok 4.6",
242 _meta: {
243 supportsReasoningEffort: true,
244 reasoningEffort: "high",
245 reasoningEfforts: [
246 {
247 id: "high",
248 value: "not a token",
249 label: "High Effort",
250 description: "Higher implementation quality",
251 default: true,
252 },
253 { id: "bad id", value: "also invalid", label: "Invalid" },
254 ],
255 },
256 });
257
258 expect(capabilities.optionDescriptors).toEqual([
259 {
260 id: "reasoningEffort",
261 label: "Reasoning",
262 type: "select",
263 currentValue: "high",
264 options: [
265 {
266 id: "high",
267 label: "High Effort",
268 description: "Higher implementation quality",
269 isDefault: true,
270 },
271 ],
272 },
273 ]);
274 });
275
276 it("accepts an advertised ACP menu when the support flag is omitted", () => {
277 const capabilities = buildGrokModelCapabilities({
278 modelId: "grok-4.6",
279 name: "Grok 4.6",
280 _meta: {
281 reasoningEffort: "high",
282 reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
283 },
284 });
285
286 expect(capabilities.optionDescriptors).toHaveLength(1);
287 });
288
289 it("honors an explicit ACP opt-out even when a menu is present", () => {
290 const capabilities = buildGrokModelCapabilities({
291 modelId: "grok-4.6",
292 name: "Grok 4.6",
293 _meta: {
294 supportsReasoningEffort: false,
295 reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
296 },
297 });
298
299 expect(capabilities.optionDescriptors).toEqual([]);
300 });
301
302 it("does not synthesize a reasoning menu when ACP omits it", () => {
303 expect(
304 buildGrokModelCapabilities({
305 modelId: "grok-4.6",
306 name: "Grok 4.6",
307 _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" },
308 }).optionDescriptors,
309 ).toEqual([]);
310 });
311
312 it("keeps non-reasoning Grok models free of reasoning controls", () => {
313 expect(
314 buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors,
315 ).toEqual([]);
316 });
317});
318
319describe("buildInitialGrokProviderSnapshot", () => {
320 it.effect("returns a disabled snapshot when settings.enabled is false", () =>
321 Effect.gen(function* () {
322 const snapshot = yield* buildInitialGrokProviderSnapshot(
323 decodeGrokSettings({ enabled: false }),
324 );
325 expect(snapshot.enabled).toBe(false);
326 expect(snapshot.status).toBe("disabled");
327 expect(snapshot.installed).toBe(false);
328 expect(snapshot.message).toContain("disabled");
329 }),
330 );
331
332 it.effect("returns a disabled snapshot by default — Grok is opt-in", () =>
333 Effect.gen(function* () {
334 const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({}));
335 expect(snapshot.enabled).toBe(false);
336 expect(snapshot.status).toBe("disabled");
337 }),
338 );
339
340 it.effect("returns a pending snapshot when enabled", () =>
341 Effect.gen(function* () {
342 const snapshot = yield* buildInitialGrokProviderSnapshot(
343 decodeGrokSettings({ enabled: true }),
344 );
345 expect(snapshot.enabled).toBe(true);
346 expect(snapshot.installed).toBe(true);
347 expect(snapshot.status).toBe("warning");
348 expect(snapshot.version).toBeNull();
349 expect(snapshot.message).toContain("Checking Grok");
350 expect(snapshot.requiresNewThreadForModelChange).toBeUndefined();
351 expect(snapshot.supportsConversationRollback).toBe(false);
352 }),
353 );
354});
355
356it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
357 it.effect("reports the binary as missing when the binary path does not resolve", () =>
358 Effect.gen(function* () {
359 const snapshot = yield* checkGrokProviderStatus(
360 decodeGrokSettings({
361 enabled: true,
362 binaryPath: "/definitely/not/installed/grok-binary",
363 }),
364 );
365 expect(snapshot.enabled).toBe(true);
366 expect(snapshot.installed).toBe(false);
367 expect(snapshot.status).toBe("error");
368 expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/);
369 }),
370 );
371
372 it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () =>
373 Effect.gen(function* () {
374 const secretStderr = "broken grok install: secret-token-value";
375 const snapshot = yield* Effect.scoped(
376 Effect.gen(function* () {
377 const fs = yield* FileSystem.FileSystem;
378 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" });
379 const grokPath = writeFakeCli({
380 directory: dir,
381 name: "grok",
382 source: [
383 // @effect-diagnostics-next-line preferSchemaOverJson:off
384 `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`,
385 "process.exit(2);",
386 "",
387 ].join("\n"),
388 });
389
390 return yield* checkGrokProviderStatus(
391 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
392 );
393 }),
394 );
395
396 expect(snapshot.enabled).toBe(true);
397 expect(snapshot.installed).toBe(true);
398 expect(snapshot.status).toBe("error");
399 expect(snapshot.message).toBe("Grok CLI is installed but failed to run.");
400 expect(snapshot.message).not.toContain(secretStderr);
401 }),
402 );
403
404 // A stand-in for the Grok CLI: `--version` and `models` print canned text,
405 // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata.
406 const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) =>
407 Effect.gen(function* () {
408 const fs = yield* FileSystem.FileSystem;
409 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" });
410 const mockAgentPath = NodePath.resolve(__dirname, "../../scripts/acp-mock-agent.ts");
411 return writeFakeCli({
412 directory: dir,
413 name: "grok",
414 source: [
415 'if (process.argv[2] === "--version") {',
416 ' process.stdout.write("grok 1.0.13\\n");',
417 " process.exit(0);",
418 "}",
419 'if (process.argv[2] === "models") {',
420 // @effect-diagnostics-next-line preferSchemaOverJson:off
421 ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`,
422 " process.exit(0);",
423 "}",
424 'if (process.argv[2] !== "agent") process.exit(1);',
425 ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]),
426 "",
427 ].join("\n"),
428 });
429 });
430
431 it.effect("reports ready with ACP-discovered models when logged in", () =>
432 Effect.gen(function* () {
433 const snapshot = yield* Effect.scoped(
434 Effect.gen(function* () {
435 const grokPath = yield* writeFakeGrokCli({
436 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
437 acp: true,
438 });
439 return yield* checkGrokProviderStatus(
440 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
441 { ...process.env, XAI_API_KEY: "" },
442 );
443 }),
444 );
445
446 expect(snapshot.status).toBe("ready");
447 expect(snapshot.version).toBe("1.0.13");
448 expect(snapshot.auth).toEqual({
449 status: "authenticated",
450 type: "cached_token",
451 label: "Grok account",
452 });
453 // The mock agent advertises grok-4.6 with reasoning options in initialize._meta.
454 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]);
455 expect(snapshot.models[0]?.isDefault).toBe(true);
456 expect(
457 snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [],
458 ).toEqual(["reasoningEffort"]);
459 }),
460 );
461
462 it.effect("reports unauthenticated from `grok models` without starting a session", () =>
463 Effect.gen(function* () {
464 const snapshot = yield* Effect.scoped(
465 Effect.gen(function* () {
466 const grokPath = yield* writeFakeGrokCli({
467 modelsOutput: LOGGED_OUT_MODELS_OUTPUT,
468 acp: true,
469 });
470 return yield* checkGrokProviderStatus(
471 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
472 { ...process.env, XAI_API_KEY: "" },
473 );
474 }),
475 );
476
477 expect(snapshot.status).toBe("error");
478 expect(snapshot.auth.status).toBe("unauthenticated");
479 expect(snapshot.message).toContain("grok login");
480 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]);
481 }),
482 );
483
484 it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () =>
485 Effect.gen(function* () {
486 const snapshot = yield* Effect.scoped(
487 Effect.gen(function* () {
488 const grokPath = yield* writeFakeGrokCli({
489 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
490 acp: false,
491 });
492 return yield* checkGrokProviderStatus(
493 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
494 { ...process.env, XAI_API_KEY: "" },
495 );
496 }),
497 );
498
499 expect(snapshot.status).toBe("warning");
500 expect(snapshot.installed).toBe(true);
501 expect(snapshot.auth.status).toBe("authenticated");
502 expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
503 ["grok-4.6", true],
504 ["grok-4.5", false],
505 ]);
506 expect(snapshot.message).toContain("ACP initialize failed");
507 expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]);
508 }),
509 );
510
511 it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () =>
512 Effect.gen(function* () {
513 const snapshot = yield* Effect.scoped(
514 Effect.gen(function* () {
515 const grokPath = yield* writeFakeGrokCli({
516 modelsOutput: LOGGED_OUT_MODELS_OUTPUT,
517 acp: false,
518 });
519 return yield* checkGrokProviderStatus(
520 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
521 { ...process.env, XAI_API_KEY: "xai-test-key" },
522 );
523 }),
524 );
525
526 expect(snapshot.auth).toEqual({
527 status: "authenticated",
528 type: "api_key",
529 label: "xAI API key",
530 });
531 expect(snapshot.status).toBe("warning");
532 }),
533 );
534});
535
536describe("Grok usage limits", () => {
537 const checkedAt = "2026-09-16T00:00:00.000Z";
538
539 it("uses the reported subscription percentage and weekly reset", () => {
540 const limits = grokUsageResponseToLimits(
541 {
542 config: {
543 creditUsagePercent: 100,
544 currentPeriod: {
545 type: "USAGE_PERIOD_TYPE_WEEKLY",
546 end: "2026-09-18T03:10:30.159171+00:00",
547 },
548 },
549 },
550 checkedAt,
551 );
552 expect(limits.windows).toEqual([
553 {
554 id: "subscription",
555 kind: "weekly",
556 label: "Weekly",
557 usedPercent: 100,
558 resetsAt: "2026-09-18T03:10:30.159Z",
559 },
560 ]);
561 });
562
563 it("does not invent allowance or reset dates when billing omits them", () => {
564 for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) {
565 const limits = grokUsageResponseToLimits(response, checkedAt);
566 expect(limits.windows).toEqual([]);
567 // Nothing metered yet, which xAI reports by omitting the field until
568 // usage registers. Marking it `unsupported` would drop the account from
569 // the Limits view for good; leaving the marker off keeps it listed.
570 expect(limits.unavailable).toBeUndefined();
571 }
572 expect(
573 grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows,
574 ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]);
575 expect(
576 grokUsageResponseToLimits(
577 {
578 config: {
579 creditUsagePercent: 120,
580 currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" },
581 },
582 },
583 checkedAt,
584 ).windows,
585 ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]);
586 });
587});
588
589it.layer(NodeServices.layer)("readGrokAccount", (it) => {
590 it.effect("reads the configured Grok home and prefers the current login scope to legacy", () =>
591 Effect.gen(function* () {
592 const fs = yield* FileSystem.FileSystem;
593 const directory = yield* fs.makeTempDirectoryScoped();
594 yield* fs.writeFileString(
595 NodePath.join(directory, "auth.json"),
596 '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}',
597 );
598 const { usageLimits: limits } = yield* readGrokAccount({
599 GROK_HOME: directory,
600 HOME: "/unrelated-home",
601 }).pipe(
602 Effect.provideService(
603 HttpClient.HttpClient,
604 HttpClient.make((request) => {
605 expect(request.method).toBe("GET");
606 expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits");
607 expect(request.headers.authorization).toBe("Bearer session-token");
608 return Effect.succeed(
609 HttpClientResponse.fromWeb(
610 request,
611 Response.json({ config: { creditUsagePercent: 37 } }),
612 ),
613 );
614 }),
615 ),
616 );
617 expect(limits.windows[0]?.usedPercent).toBe(37);
618 }).pipe(Effect.scoped),
619 );
620
621 it.effect(
622 "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope",
623 () =>
624 readGrokAccount({
625 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}',
626 }).pipe(
627 Effect.provideService(
628 FileSystem.FileSystem,
629 FileSystem.makeNoop({
630 readFileString: (path) =>
631 path.endsWith("auth.json")
632 ? Effect.die("must not read stored credentials")
633 : Effect.succeed(""),
634 }),
635 ),
636 Effect.provideService(
637 HttpClient.HttpClient,
638 HttpClient.make((request) => {
639 expect(request.headers.authorization).toBe("Bearer legacy-token");
640 return Effect.succeed(
641 HttpClientResponse.fromWeb(
642 request,
643 Response.json({ config: { creditUsagePercent: 12 } }),
644 ),
645 );
646 }),
647 ),
648 Effect.tap(({ usageLimits }) =>
649 Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)),
650 ),
651 ),
652 );
653
654 it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () =>
655 Effect.gen(function* () {
656 for (const environment of [
657 {
658 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
659 XAI_API_KEY: "different-api-account",
660 },
661 { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' },
662 { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' },
663 { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' },
664 {
665 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}',
666 GROK_OIDC_ISSUER: "https://custom.example",
667 },
668 {
669 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
670 GROK_MODELS_BASE_URL: "https://custom.example/v1",
671 },
672 {
673 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
674 GROK_OAUTH2_PRINCIPAL_TYPE: "Team",
675 },
676 {
677 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
678 GROK_OAUTH2_PRINCIPAL_ID: "team-id",
679 },
680 {
681 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}',
682 GROK_CONFIG: '{"auth_provider_command":"custom-auth"}',
683 },
684 {
685 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}',
686 GROK_CONFIG_PATH: "/custom-config.toml",
687 },
688 ]) {
689 const { usageLimits: limits } = yield* readGrokAccount({
690 HOME: "/definitely/not/a/grok-home",
691 ...environment,
692 }).pipe(
693 Effect.provideService(
694 HttpClient.HttpClient,
695 HttpClient.make(() => Effect.die("must not request another account's quota")),
696 ),
697 );
698 expect(limits.windows).toEqual([]);
699 expect(limits.unavailable?.reason).toBe("unsupported");
700 }
701 }),
702 );
703
704 it.effect(
705 "reports a missing login as unsupported and malformed credentials as a sanitized failure",
706 () =>
707 Effect.gen(function* () {
708 const fs = yield* FileSystem.FileSystem;
709 const directory = yield* fs.makeTempDirectoryScoped();
710 const client = HttpClient.make(() => Effect.die("must not request without credentials"));
711 const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe(
712 Effect.provideService(HttpClient.HttpClient, client),
713 );
714 expect(missing.unavailable?.reason).toBe("unsupported");
715 for (const contents of [
716 "private-token-invalid-json",
717 '{"https://accounts.x.ai/sign-in":{"key":42}}',
718 ]) {
719 const { usageLimits: malformed } = yield* readGrokAccount({
720 GROK_HOME: directory,
721 GROK_AUTH: contents,
722 }).pipe(Effect.provideService(HttpClient.HttpClient, client));
723 expect(malformed.unavailable).toEqual({
724 reason: "probeFailed",
725 message: "Grok could not read usage limits.",
726 });
727 }
728 }).pipe(Effect.scoped),
729 );
730
731 it.effect(
732 "does not request subscription limits for custom account or endpoint configuration",
733 () =>
734 Effect.gen(function* () {
735 for (const config of [
736 '[auth]\nprovider_command = "custom-auth"',
737 '[grok_com_config]\nissuer = "https://custom.example"',
738 'endpoints.proxy = "https://custom.example"',
739 ]) {
740 const { usageLimits: limits } = yield* readGrokAccount({
741 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}',
742 }).pipe(
743 Effect.provideService(
744 FileSystem.FileSystem,
745 FileSystem.makeNoop({
746 readFileString: (path) => {
747 expect(path.endsWith("config.toml")).toBe(true);
748 return Effect.succeed(config);
749 },
750 }),
751 ),
752 Effect.provideService(
753 HttpClient.HttpClient,
754 HttpClient.make(() => Effect.die("must not request another account's quota")),
755 ),
756 );
757 expect(limits.windows).toEqual([]);
758 expect(limits.unavailable?.reason).toBe("unsupported");
759 }
760 }),
761 );
762
763 it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () =>
764 Effect.gen(function* () {
765 for (const response of [
766 new Response("private response", { status: 401 }),
767 Response.json({ config: { creditUsagePercent: "private-value" } }),
768 ]) {
769 const { email, usageLimits: limits } = yield* readGrokAccount({
770 HOME: "/definitely/not/a/grok-home",
771 GROK_AUTH:
772 '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}',
773 }).pipe(
774 Effect.provideService(
775 HttpClient.HttpClient,
776 HttpClient.make((request) =>
777 Effect.succeed(HttpClientResponse.fromWeb(request, response)),
778 ),
779 ),
780 );
781 expect(email).toBe("someone@example.com");
782 expect(limits.windows).toEqual([]);
783 expect(limits.unavailable).toEqual({
784 reason: "probeFailed",
785 message: "Grok could not read usage limits.",
786 });
787 }
788 }),
789 );
790});
791
792it.layer(NodeServices.layer)("readGrokAccount email", (it) => {
793 it.effect("names the account whose limits were read, and nothing for other auth", () =>
794 Effect.gen(function* () {
795 const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"';
796 const cases = [
797 [
798 { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` },
799 "Someone@Example.com",
800 ],
801 [
802 {
803 GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`,
804 },
805 "current@example.com",
806 ],
807 [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined],
808 [
809 {
810 GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`,
811 XAI_API_KEY: "api-key",
812 },
813 undefined,
814 ],
815 [
816 {
817 GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`,
818 },
819 undefined,
820 ],
821 [{ GROK_AUTH: "not-json" }, undefined],
822 ] as const;
823 for (const [environment, expected] of cases) {
824 const { email, usageLimits } = yield* readGrokAccount({
825 HOME: "/definitely/not/a/grok-home",
826 ...environment,
827 }).pipe(
828 Effect.provideService(
829 HttpClient.HttpClient,
830 HttpClient.make((request) =>
831 Effect.succeed(
832 HttpClientResponse.fromWeb(
833 request,
834 Response.json({ config: { creditUsagePercent: 25 } }),
835 ),
836 ),
837 ),
838 ),
839 );
840 expect(email).toBe(expected);
841 if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25);
842 }
843 }),
844 );
845});
apps/server/src/provider/GrokProvider.ts created+586
......@@ -0,0 +1,586 @@
1import {
2 type CustomModelSetting,
3 type GrokSettings,
4 type ModelCapabilities,
5 type ServerProvider,
6 type ServerProviderAuth,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import type * as EffectAcpSchema from "effect-acp/compat";
11import { causeErrorTag } from "@t3tools/shared/observability";
12import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import { HttpClient } from "effect/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/process";
21import { createModelCapabilities } from "@t3tools/shared/model";
22import { resolveSpawnCommand } from "@t3tools/shared/shell";
23
24import {
25 AUTH_PROBE_TIMEOUT_MS,
26 buildServerProvider,
27 COMPACT_SLASH_COMMAND,
28 isCommandMissingCause,
29 parseGenericCliVersion,
30 providerModelsFromSettings,
31 spawnAndCollect,
32 type ServerProviderDraft,
33} from "./providerSnapshot.ts";
34import {
35 enrichProviderSnapshotWithVersionAdvisory,
36 type ProviderMaintenanceCapabilities,
37} from "./providerMaintenance.ts";
38import {
39 GROK_DEFAULT_MODEL_SLUG,
40 GROK_SUPPORTED_RUNTIME_MODES,
41 isValidGrokReasoningEffortToken,
42 makeGrokAcpRuntime,
43 resolveGrokAcpBaseModelId,
44} from "./acp/GrokAcpSupport.ts";
45import { sessionModelStateFromInitialize } from "./acp/AcpRuntimeModel.ts";
46import { discoverGrokSkills } from "./Drivers/GrokSkills.ts";
47
48const GROK_PRESENTATION = {
49 displayName: "Grok",
50 supportsConversationRollback: false,
51 showInteractionModeToggle: false,
52 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
53} as const;
54const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
55 optionDescriptors: [],
56});
57
58const VERSION_PROBE_TIMEOUT_MS = 4_000;
59// `initialize` is a single local round trip, so this is generous even on slow machines.
60const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000;
61const GROK_API_KEY_ENV = "XAI_API_KEY";
62
63const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
64 {
65 slug: GROK_DEFAULT_MODEL_SLUG,
66 name: "Grok Build",
67 isCustom: false,
68 capabilities: EMPTY_CAPABILITIES,
69 },
70];
71
72export function buildInitialGrokProviderSnapshot(
73 grokSettings: GrokSettings,
74): Effect.Effect<ServerProviderDraft> {
75 return Effect.gen(function* () {
76 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
77 const models = grokModelsFromSettings(grokSettings.customModels);
78
79 if (!grokSettings.enabled) {
80 return buildServerProvider({
81 presentation: GROK_PRESENTATION,
82 enabled: false,
83 checkedAt,
84 models,
85 probe: {
86 installed: false,
87 version: null,
88 status: "warning",
89 auth: { status: "unknown" },
90 message: "Grok is disabled in T3 Code settings.",
91 },
92 });
93 }
94
95 return buildServerProvider({
96 presentation: GROK_PRESENTATION,
97 enabled: true,
98 checkedAt,
99 models,
100 probe: {
101 installed: true,
102 version: null,
103 status: "warning",
104 auth: { status: "unknown" },
105 message: "Checking Grok CLI availability...",
106 },
107 });
108 });
109}
110
111function grokModelsFromSettings(
112 customModels: ReadonlyArray<CustomModelSetting> | undefined,
113 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
114): ReadonlyArray<ServerProviderModel> {
115 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
116}
117
118function nonEmptyString(value: unknown): string | undefined {
119 return typeof value === "string" ? value.trim() || undefined : undefined;
120}
121
122function isRecord(value: unknown): value is Record<string, unknown> {
123 return typeof value === "object" && value !== null && !Array.isArray(value);
124}
125
126function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
127 readonly options: ReadonlyArray<{
128 value: string;
129 label: string;
130 description?: string;
131 isDefault?: boolean;
132 }>;
133 readonly currentValue: string | undefined;
134} {
135 const meta = model._meta;
136 if (!meta || meta.supportsReasoningEffort === false) {
137 return { options: [], currentValue: undefined };
138 }
139
140 const currentEffort = nonEmptyString(meta.reasoningEffort);
141 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
142 const seen = new Set<string>();
143 const options: Array<{
144 value: string;
145 label: string;
146 description?: string;
147 advertisedDefault: boolean;
148 }> = [];
149
150 for (const entry of advertisedOptions) {
151 if (!isRecord(entry)) {
152 continue;
153 }
154 const rawValue = nonEmptyString(entry.value);
155 const rawId = nonEmptyString(entry.id);
156 const value =
157 rawValue && isValidGrokReasoningEffortToken(rawValue)
158 ? rawValue
159 : rawId && isValidGrokReasoningEffortToken(rawId)
160 ? rawId
161 : undefined;
162 if (value === undefined || seen.has(value)) {
163 continue;
164 }
165 seen.add(value);
166 const description = nonEmptyString(entry.description);
167 options.push({
168 value,
169 label: nonEmptyString(entry.label) ?? value,
170 ...(description ? { description } : {}),
171 advertisedDefault: entry.default === true || entry.isDefault === true,
172 });
173 }
174
175 const currentValue =
176 currentEffort && options.some((option) => option.value === currentEffort)
177 ? currentEffort
178 : undefined;
179 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
180 const selectedDefault =
181 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
182 advertisedDefaults[0]?.value;
183 return {
184 options: options.map(({ value, label, description }) => ({
185 value,
186 label,
187 ...(description ? { description } : {}),
188 ...(value === selectedDefault ? { isDefault: true } : {}),
189 })),
190 currentValue: currentValue ?? selectedDefault,
191 };
192}
193
194export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
195 const reasoning = grokReasoningOptionsFromModel(model);
196 return reasoning.options.length > 0
197 ? createModelCapabilities({
198 optionDescriptors: [
199 {
200 id: "reasoningEffort",
201 label: "Reasoning",
202 type: "select",
203 options: reasoning.options.map((option) => ({
204 id: option.value,
205 label: option.label,
206 ...(option.description ? { description: option.description } : {}),
207 ...(option.isDefault ? { isDefault: true } : {}),
208 })),
209 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
210 },
211 ],
212 })
213 : EMPTY_CAPABILITIES;
214}
215
216/** Models advertised by the ACP agent, with the session's current model marked as default. */
217export function buildGrokModelsFromSessionModelState(
218 modelState: EffectAcpSchema.SessionModelState | null | undefined,
219): ReadonlyArray<ServerProviderModel> {
220 if (!modelState || modelState.availableModels.length === 0) {
221 return [];
222 }
223 const currentModelId = modelState.currentModelId.trim();
224 const seen = new Set<string>();
225 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
226 const slug = resolveGrokAcpBaseModelId(model.modelId);
227 if (!slug || seen.has(slug)) {
228 return [];
229 }
230 seen.add(slug);
231 return [
232 {
233 slug,
234 name: model.name.trim() || slug,
235 isCustom: false,
236 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
237 capabilities: buildGrokModelCapabilities(model),
238 },
239 ];
240 });
241}
242
243export interface GrokModelsCliOutput {
244 /** True or false when the CLI printed a login line, null when it printed neither. */
245 readonly authenticated: boolean | null;
246 readonly models: ReadonlyArray<ServerProviderModel>;
247}
248
249/**
250 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
251 * text is the only signal. Current output looks like:
252 *
253 * You are logged in with grok.com.
254 * Default model: grok-4.6
255 * Available models:
256 * * grok-4.6 (default)
257 * - grok-4.5
258 */
259export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
260 const authenticated = /you are logged in/i.test(output)
261 ? true
262 : /not authenticated|not logged in/i.test(output)
263 ? false
264 : null;
265
266 const seen = new Set<string>();
267 const models: ServerProviderModel[] = [];
268 for (const line of output.split(/\r?\n/)) {
269 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
270 if (!bullet?.[1]) {
271 continue;
272 }
273 const slug = resolveGrokAcpBaseModelId(bullet[1]);
274 if (seen.has(slug)) {
275 continue;
276 }
277 seen.add(slug);
278 models.push({
279 slug,
280 name: displayNameFromGrokModelSlug(slug),
281 isCustom: false,
282 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
283 capabilities: EMPTY_CAPABILITIES,
284 });
285 }
286 return { authenticated, models };
287}
288
289function displayNameFromGrokModelSlug(slug: string): string {
290 return slug
291 .split(/[-_]/g)
292 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
293 .join(" ");
294}
295
296const runGrokCliCommand = (
297 grokSettings: GrokSettings,
298 args: ReadonlyArray<string>,
299 environment: NodeJS.ProcessEnv,
300) =>
301 Effect.gen(function* () {
302 const command = grokSettings.binaryPath || "grok";
303 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
304 return yield* spawnAndCollect(
305 command,
306 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
307 env: environment,
308 shell: spawnCommand.shell,
309 }),
310 );
311 });
312
313const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
314const decodeAvailableCommand = Schema.decodeUnknownOption(
315 Schema.Struct({
316 name: Schema.String,
317 description: Schema.String,
318 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
319 }),
320);
321
322export function grokSlashCommandsFromInitialize(
323 initialized: EffectAcpSchema.InitializeResponse,
324): ReadonlyArray<ServerProviderSlashCommand> {
325 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
326 const byName = new Map<string, ServerProviderSlashCommand>([
327 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
328 ]);
329 for (const entry of Option.getOrElse(commands, () => [])) {
330 const decoded = decodeAvailableCommand(entry);
331 if (Option.isNone(decoded)) continue;
332 const command = decoded.value;
333 const name = command.name.trim();
334 // Permission changes must go through T3 so the client and provider agree.
335 if (!name || name.toLowerCase() === "always-approve") continue;
336 // Grok advertises /context, but its ACP handler completes without emitting output.
337 if (name.toLowerCase() === "context") continue;
338 const description = command.description.trim();
339 const hint = command.input?.hint.trim();
340 byName.set(name, {
341 name,
342 ...(description ? { description } : {}),
343 ...(hint ? { input: { hint } } : {}),
344 });
345 }
346 return [...byName.values()];
347}
348
349/**
350 * Reads model and command metadata from `initialize._meta`. This never calls `authenticate`
351 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
352 */
353const discoverGrokMetadataViaAcpInitialize = (
354 grokSettings: GrokSettings,
355 environment: NodeJS.ProcessEnv,
356) =>
357 Effect.gen(function* () {
358 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
359 const acp = yield* makeGrokAcpRuntime({
360 grokSettings,
361 environment,
362 childProcessSpawner,
363 cwd: process.cwd(),
364 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
365 });
366 const initialized = yield* acp.initialize();
367 return {
368 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
369 slashCommands: grokSlashCommandsFromInitialize(initialized),
370 };
371 }).pipe(Effect.scoped);
372
373export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
374 grokSettings: GrokSettings,
375 environment: NodeJS.ProcessEnv = process.env,
376 cwd?: string,
377): Effect.fn.Return<
378 ServerProviderDraft,
379 never,
380 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
381> {
382 const checkedAt = DateTime.formatIso(yield* DateTime.now);
383 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
384
385 if (!grokSettings.enabled) {
386 return buildServerProvider({
387 presentation: GROK_PRESENTATION,
388 enabled: false,
389 checkedAt,
390 models: fallbackModels,
391 probe: {
392 installed: false,
393 version: null,
394 status: "warning",
395 auth: { status: "unknown" },
396 message: "Grok is disabled in T3 Code settings.",
397 },
398 });
399 }
400
401 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
402 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
403 Effect.result,
404 );
405
406 if (Result.isFailure(versionResult)) {
407 const error = versionResult.failure;
408 yield* Effect.logWarning("Grok CLI health check failed.", {
409 errorTag: error._tag,
410 });
411 return buildServerProvider({
412 presentation: GROK_PRESENTATION,
413 enabled: grokSettings.enabled,
414 checkedAt,
415 models: fallbackModels,
416 probe: {
417 installed: !isCommandMissingCause(error),
418 version: null,
419 status: "error",
420 auth: { status: "unknown" },
421 message: isCommandMissingCause(error)
422 ? "Grok CLI (`grok`) is not installed or not on PATH."
423 : "Failed to execute Grok CLI health check.",
424 },
425 });
426 }
427
428 if (Option.isNone(versionResult.success)) {
429 return buildServerProvider({
430 presentation: GROK_PRESENTATION,
431 enabled: grokSettings.enabled,
432 checkedAt,
433 models: fallbackModels,
434 probe: {
435 installed: true,
436 version: null,
437 status: "error",
438 auth: { status: "unknown" },
439 message: "Grok CLI is installed but timed out while running `grok --version`.",
440 },
441 });
442 }
443
444 const versionOutput = versionResult.success.value;
445 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
446 if (versionOutput.code !== 0) {
447 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
448 exitCode: versionOutput.code,
449 stdoutLength: versionOutput.stdout.length,
450 stderrLength: versionOutput.stderr.length,
451 });
452 return buildServerProvider({
453 presentation: GROK_PRESENTATION,
454 enabled: grokSettings.enabled,
455 checkedAt,
456 models: fallbackModels,
457 probe: {
458 installed: true,
459 version,
460 status: "error",
461 auth: { status: "unknown" },
462 message: "Grok CLI is installed but failed to run.",
463 },
464 });
465 }
466
467 // `grok models` reports login state and model slugs without starting the agent.
468 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
469 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
470 Effect.result,
471 );
472 // Only a clean exit is parsed. Failed invocations print help or error text that
473 // must not be read as model slugs or as a login verdict.
474 const modelsOutput =
475 Result.isSuccess(modelsResult) &&
476 Option.isSome(modelsResult.success) &&
477 modelsResult.success.value.code === 0
478 ? modelsResult.success.value
479 : undefined;
480 const cliModels: GrokModelsCliOutput = modelsOutput
481 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
482 : { authenticated: null, models: [] };
483 if (!modelsOutput) {
484 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
485 errorTag: Result.isFailure(modelsResult)
486 ? modelsResult.failure._tag
487 : Option.isNone(modelsResult.success)
488 ? "Timeout"
489 : `ExitCode${modelsResult.success.value.code}`,
490 });
491 }
492
493 const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim()
494 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
495 : cliModels.authenticated === true
496 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
497 : cliModels.authenticated === false
498 ? { status: "unauthenticated" }
499 : { status: "unknown" };
500
501 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
502 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
503 Effect.orElseSucceed(() => []),
504 );
505
506 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe(
507 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
508 Effect.exit,
509 );
510 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
511 const acpModels = acpMetadata?.models ?? [];
512 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
513 if (acpFailed) {
514 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
515 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
516 });
517 }
518
519 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
520 const models =
521 discoveredModels.length > 0
522 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
523 : fallbackModels;
524
525 if (auth.status === "unauthenticated") {
526 return buildServerProvider({
527 presentation: GROK_PRESENTATION,
528 enabled: grokSettings.enabled,
529 checkedAt,
530 models,
531 skills,
532 probe: {
533 installed: true,
534 version,
535 status: "error",
536 auth,
537 message: "Grok CLI is installed but not logged in. Run `grok login`.",
538 },
539 });
540 }
541
542 return buildServerProvider({
543 presentation: GROK_PRESENTATION,
544 enabled: grokSettings.enabled,
545 checkedAt,
546 models,
547 skills,
548 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
549 probe: {
550 installed: true,
551 version,
552 // A failed metadata probe degrades the model picker, it does not make chats fail.
553 status: acpFailed ? "warning" : "ready",
554 auth,
555 ...(acpFailed
556 ? {
557 message:
558 "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.",
559 }
560 : {}),
561 },
562 });
563});
564
565export const enrichGrokSnapshot = (input: {
566 readonly snapshot: ServerProvider;
567 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
568 readonly enableProviderUpdateChecks?: boolean;
569 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
570 readonly httpClient: HttpClient.HttpClient;
571}): Effect.Effect<void> => {
572 const { snapshot, publishSnapshot } = input;
573
574 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
575 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
576 }).pipe(
577 Effect.provideService(HttpClient.HttpClient, input.httpClient),
578 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
579 Effect.catchCause((cause) =>
580 Effect.logWarning("Grok version advisory enrichment failed", {
581 errorTag: causeErrorTag(cause),
582 }),
583 ),
584 Effect.asVoid,
585 );
586};
apps/server/src/provider/Layers/AcpRegistryCatalog.ts deleted-22
......@@ -1,22 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Path from "effect/Path";
4
5import * as ServerConfig from "../../config.ts";
6import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts";
7import * as AcpRegistryRuntimeCoordinator from "../acp/AcpRegistryRuntimeCoordinator.ts";
8
9/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */
10export const layer = Layer.merge(
11 Layer.unwrap(
12 Effect.gen(function* () {
13 const config = yield* ServerConfig.ServerConfig;
14 const path = yield* Path.Path;
15 return AcpRegistrySupport.AcpRegistryCatalog.layer({
16 cacheDir: config.providerStatusCacheDir,
17 toolsDir: path.join(config.baseDir, "tools"),
18 });
19 }),
20 ),
21 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer,
22);
apps/server/src/provider/Layers/AntigravityProvider.test.ts deleted-661
......@@ -1,661 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 ANTIGRAVITY_DEFAULT_MODEL,
4 AntigravitySettings,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ProviderSetupError,
8} from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
17import * as EffectAcpErrors from "effect-acp/errors";
18import type * as EffectAcpSchema from "effect-acp/compat";
19
20import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
21import * as ServerSettings from "../../serverSettings.ts";
22import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts";
23import {
24 buildAntigravityModelsFromSession,
25 makeAntigravityProvider,
26} from "./AntigravityProvider.ts";
27
28const decodeSettings = Schema.decodeSync(AntigravitySettings);
29const instanceId = ProviderInstanceId.make("antigravity-test");
30const driver = ProviderDriverKind.make("antigravity");
31
32const initializeResult = {
33 protocolVersion: 1,
34 agentCapabilities: {
35 loadSession: true,
36 promptCapabilities: { image: true, audio: true, embeddedContext: true },
37 sessionCapabilities: { list: {}, resume: {} },
38 },
39 authMethods: [{ id: "oauth-personal", name: "Log in with Google" }],
40 agentInfo: {
41 name: "antigravity-acp",
42 title: "Google Antigravity",
43 version: "agy_acp_server_20260818_01_RC01",
44 },
45} satisfies EffectAcpSchema.InitializeResponse;
46
47const modelOptions = [
48 { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" },
49 { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" },
50 { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" },
51 { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" },
52 { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" },
53 { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" },
54 { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" },
55 { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" },
56 { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" },
57 { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
58 { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
59];
60
61const modelConfig = {
62 id: "model",
63 name: "Model",
64 category: "model",
65 type: "select",
66 currentValue: "gemini-3.7-flash-high",
67 options: modelOptions,
68} satisfies EffectAcpSchema.SessionConfigOption;
69
70const sessionSetupResult = {
71 sessionId: "session-1",
72 models: {
73 currentModelId: "gemini-3.7-flash-high",
74 availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })),
75 },
76 configOptions: [
77 modelConfig,
78 {
79 id: "mode",
80 name: "Session Mode",
81 category: "mode",
82 type: "select",
83 currentValue: "default",
84 options: [
85 { value: "default", name: "Default" },
86 { value: "yolo", name: "YOLO" },
87 ],
88 },
89 ],
90} satisfies EffectAcpSchema.NewSessionResponse;
91
92const started = {
93 sessionId: "session-1",
94 initializeResult,
95 sessionSetupResult,
96 modelConfigId: "model",
97} satisfies AcpSessionRuntimeStartResult;
98
99const commands = [
100 { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } },
101 { name: "logout", description: "Sign out of Google" },
102] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>;
103
104const expectedCommands = [
105 { name: "plan", description: "Create a plan", input: { hint: "What to plan" } },
106 { name: "logout", description: "Sign out of Google" },
107];
108
109const layerTest = Layer.merge(
110 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
111 shouldRunScopeWork: () => Effect.succeed(false),
112 }),
113 ServerSettings.layerTest(),
114);
115
116type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError;
117
118const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* (
119 options: { readonly enabled?: boolean; readonly safe?: boolean } = {},
120) {
121 const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>();
122 const probeCalls = yield* Ref.make(0);
123 const safetyCalls = yield* Ref.make(0);
124 const probe = yield* Ref.make<Effect.Effect<EffectAcpSchema.InitializeResponse, ProbeError>>(
125 Deferred.await(initialProbe),
126 );
127 const safety = yield* Ref.make<Effect.Effect<boolean>>(Effect.succeed(options.safe ?? true));
128 const provider = yield* makeAntigravityProvider(
129 decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }),
130 {
131 stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }),
132 probe: Ref.update(probeCalls, (count) => count + 1).pipe(
133 Effect.andThen(Ref.get(probe)),
134 Effect.flatten,
135 ),
136 supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe(
137 Effect.andThen(Ref.get(safety)),
138 Effect.flatten,
139 ),
140 },
141 );
142 const initialUpdate = yield* Stream.toPull(
143 provider.snapshot.streamChanges.pipe(
144 Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"),
145 ),
146 );
147 const initialize = Deferred.succeed(initialProbe, initializeResult).pipe(
148 Effect.andThen(initialUpdate),
149 Effect.asVoid,
150 );
151 return {
152 provider,
153 probe,
154 probeCalls,
155 safety,
156 safetyCalls,
157 initialProbe,
158 initialUpdate,
159 initialize,
160 };
161});
162
163describe("Antigravity model catalog", () => {
164 it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => {
165 const models = buildAntigravityModelsFromSession(sessionSetupResult);
166 expect(models.map((model) => [model.slug, model.name])).toEqual(
167 modelOptions.map((option) => [option.value, option.name]),
168 );
169 expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([
170 "gemini-3.7-flash-high",
171 ]);
172 expect(
173 models
174 .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))
175 .map((model) => model.slug),
176 ).toEqual(["gemini-3.7-flash-high"]);
177 expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true);
178 expect(models.every((model) => !model.isCustom)).toBe(true);
179 });
180
181 it("uses legacy session models only when model config is absent", () => {
182 const fromLegacy = buildAntigravityModelsFromSession({
183 models: sessionSetupResult.models,
184 });
185 expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
186 expect(
187 buildAntigravityModelsFromSession({
188 ...sessionSetupResult,
189 configOptions: [{ ...modelConfig, options: [] }],
190 }),
191 ).toEqual([]);
192 });
193
194 it("flattens native option groups without combining distinct model IDs", () => {
195 const models = buildAntigravityModelsFromSession({
196 configOptions: [
197 {
198 ...modelConfig,
199 currentValue: "gemini-pro-agent",
200 options: [
201 { groupId: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] },
202 { groupId: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] },
203 ],
204 },
205 ],
206 });
207 expect(models.map((model) => model.slug)).toEqual([
208 "gemini-3.7-flash-high",
209 "gemini-3.7-flash-medium",
210 "gemini-pro-agent",
211 ]);
212 expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent");
213 expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe(
214 "gemini-pro-agent",
215 );
216 });
217});
218
219it.layer(layerTest)("Antigravity provider snapshots", (it) => {
220 it.effect("does not probe or run helper safety checks while disabled", () =>
221 Effect.scoped(
222 Effect.gen(function* () {
223 const harness = yield* makeHarness({ enabled: false });
224 const snapshot = yield* harness.provider.snapshot.refresh;
225 expect(snapshot).toMatchObject({
226 enabled: false,
227 installed: false,
228 status: "disabled",
229 auth: { status: "unknown" },
230 models: [],
231 setup: { canAuthenticate: true, canInstall: true },
232 showInteractionModeToggle: false,
233 supportsConversationRollback: false,
234 supportsTextGeneration: false,
235 });
236 expect(yield* Ref.get(harness.probeCalls)).toBe(0);
237 expect(yield* Ref.get(harness.safetyCalls)).toBe(0);
238 }),
239 ),
240 );
241
242 it.effect("records explicit sign-in while disabled without starting a health probe", () =>
243 Effect.scoped(
244 Effect.gen(function* () {
245 const harness = yield* makeHarness({ enabled: false });
246 const signedIn = yield* Stream.toPull(
247 harness.provider.snapshot.streamChanges.pipe(
248 Stream.filter(
249 (snapshot) =>
250 snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0,
251 ),
252 ),
253 );
254 yield* harness.provider.onSessionStarted(started);
255 yield* harness.provider.onAvailableCommands(commands);
256 const [snapshot] = yield* signedIn;
257 expect(snapshot).toMatchObject({
258 enabled: false,
259 installed: true,
260 status: "disabled",
261 auth: { status: "authenticated", type: "oauth-personal" },
262 workspaceSnapshots: [],
263 });
264 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
265 expect(snapshot.slashCommands).toEqual(expectedCommands);
266 expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models);
267 expect(yield* Ref.get(harness.probeCalls)).toBe(0);
268 }),
269 ),
270 );
271
272 it.effect("publishes the configured sign-in method before any account is checked", () =>
273 Effect.scoped(
274 Effect.gen(function* () {
275 const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), {
276 stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }),
277 probe: Effect.succeed(initializeResult),
278 supportsTextGeneration: Effect.succeed(true),
279 auth: { type: "gemini-api-key", label: "Gemini API key" },
280 });
281 expect((yield* provider.snapshot.getSnapshot).auth).toEqual({
282 status: "unknown",
283 type: "gemini-api-key",
284 });
285 }),
286 ),
287 );
288
289 it.effect("treats initialize as installation proof, not account or model discovery", () =>
290 Effect.scoped(
291 Effect.gen(function* () {
292 const harness = yield* makeHarness();
293 expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({
294 installed: false,
295 status: "warning",
296 auth: { status: "unknown" },
297 models: [],
298 });
299 yield* harness.initialize;
300 const snapshot = yield* harness.provider.snapshot.getSnapshot;
301 expect(snapshot).toMatchObject({
302 installed: true,
303 status: "warning",
304 version: "agy_acp_server_20260818_01_RC01",
305 auth: { status: "unknown" },
306 models: [],
307 });
308 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
309 }),
310 ),
311 );
312
313 it.effect("publishes session metadata and native commands without another health probe", () =>
314 Effect.scoped(
315 Effect.gen(function* () {
316 const harness = yield* makeHarness();
317 yield* harness.initialize;
318 const nextReady = yield* Stream.toPull(
319 harness.provider.snapshot.streamChanges.pipe(
320 Stream.filter(
321 (snapshot) =>
322 snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0,
323 ),
324 ),
325 );
326 yield* harness.provider.onSessionStarted(started, "/workspace");
327 yield* harness.provider.onAvailableCommands(commands, "/workspace");
328 const [snapshot] = yield* nextReady;
329 expect(snapshot).toMatchObject({
330 status: "ready",
331 auth: { status: "authenticated", type: "oauth-personal" },
332 supportsTextGeneration: true,
333 });
334 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
335 expect(snapshot.slashCommands).toEqual(expectedCommands);
336 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual(
337 expectedCommands,
338 );
339 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
340 }),
341 ),
342 );
343
344 it.effect("does not retain a disposable sign-in workspace", () =>
345 Effect.scoped(
346 Effect.gen(function* () {
347 const harness = yield* makeHarness();
348 yield* harness.initialize;
349 yield* harness.provider.onSessionStarted(started);
350 yield* harness.provider.onAvailableCommands(commands);
351 const snapshot = yield* harness.provider.snapshot.getSnapshot;
352 expect(snapshot.models).toHaveLength(11);
353 expect(snapshot.slashCommands).toEqual(expectedCommands);
354 expect(snapshot.workspaceSnapshots).toEqual([]);
355 }),
356 ),
357 );
358
359 it.effect("clears all account metadata on sign-out and authentication failure", () =>
360 Effect.scoped(
361 Effect.gen(function* () {
362 const harness = yield* makeHarness();
363 yield* harness.initialize;
364 for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) {
365 yield* harness.provider.onSessionStarted(started, "/workspace");
366 yield* harness.provider.onAvailableCommands(commands, "/workspace");
367 yield* clear;
368 expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({
369 installed: true,
370 status: "warning",
371 auth: { status: "unauthenticated" },
372 models: [],
373 slashCommands: [],
374 skills: [],
375 workspaceSnapshots: [],
376 supportsTextGeneration: false,
377 });
378 yield* harness.provider.onAvailableCommands(commands, "/workspace");
379 yield* harness.provider.onConfigOptionsUpdated([modelConfig]);
380 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]);
381 expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]);
382 }
383 const refreshed = yield* harness.provider.snapshot.refresh;
384 expect(refreshed.auth.status).toBe("unauthenticated");
385 expect(refreshed.supportsTextGeneration).toBe(false);
386 }),
387 ),
388 );
389
390 it.effect("replaces live model choices and accepts an empty catalog", () =>
391 Effect.scoped(
392 Effect.gen(function* () {
393 const harness = yield* makeHarness();
394 yield* harness.initialize;
395 yield* harness.provider.onSessionStarted(started, "/workspace");
396 yield* harness.provider.onAvailableCommands(commands, "/workspace");
397 const before = yield* harness.provider.snapshot.getSnapshot;
398 const configOptions = [
399 {
400 ...modelConfig,
401 currentValue: "gemini-3.8-flash-high",
402 options: modelOptions.slice(0, 3),
403 },
404 ];
405 const nextSnapshot = yield* Stream.toPull(
406 harness.provider.snapshot.streamChanges.pipe(
407 Stream.filter((snapshot) => snapshot.models.length === 3),
408 ),
409 );
410 yield* harness.provider.onConfigOptionsUpdated(configOptions);
411 expect((yield* nextSnapshot)[0]).toMatchObject({
412 models: buildAntigravityModelsFromSession({ configOptions }),
413 auth: before.auth,
414 workspaceSnapshots: before.workspaceSnapshots,
415 slashCommands: expectedCommands,
416 });
417 yield* harness.provider.onConfigOptionsUpdated([]);
418 expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]);
419 }),
420 ),
421 );
422
423 it.effect("replaces one account's catalog instead of combining accounts", () =>
424 Effect.scoped(
425 Effect.gen(function* () {
426 const harness = yield* makeHarness();
427 yield* harness.initialize;
428 yield* harness.provider.onSessionStarted(started);
429 yield* harness.provider.onSignedOut;
430 yield* harness.provider.onSessionStarted({
431 ...started,
432 sessionSetupResult: {
433 sessionId: "new-account-session",
434 configOptions: [
435 { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] },
436 ],
437 },
438 });
439 expect(
440 (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug),
441 ).toEqual(["gemini-pro-agent"]);
442 }),
443 ),
444 );
445
446 it.effect("retains known account metadata when a local health check fails", () =>
447 Effect.scoped(
448 Effect.gen(function* () {
449 const harness = yield* makeHarness();
450 yield* harness.initialize;
451 yield* harness.provider.onSessionStarted(started, "/workspace");
452 yield* harness.provider.onAvailableCommands(commands, "/workspace");
453 yield* Ref.set(
454 harness.probe,
455 Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")),
456 );
457 const snapshot = yield* harness.provider.snapshot.refresh;
458 expect(snapshot).toMatchObject({
459 installed: true,
460 status: "error",
461 auth: { status: "authenticated" },
462 });
463 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
464 expect(snapshot.slashCommands).toEqual(expectedCommands);
465 expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace");
466 }),
467 ),
468 );
469
470 it.effect("allows a slow packaged runtime health check to finish", () =>
471 Effect.scoped(
472 Effect.gen(function* () {
473 const harness = yield* makeHarness();
474 yield* harness.initialize;
475 const entered = yield* Deferred.make<void>();
476 const initialized = yield* Deferred.make<EffectAcpSchema.InitializeResponse>();
477 yield* Ref.set(
478 harness.probe,
479 Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))),
480 );
481
482 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
483 yield* Deferred.await(entered);
484 yield* TestClock.adjust("47 seconds");
485 yield* Deferred.succeed(initialized, initializeResult);
486 const snapshot = yield* Fiber.join(refresh);
487
488 expect(snapshot).toMatchObject({
489 installed: true,
490 status: "warning",
491 auth: { status: "unknown" },
492 });
493 }),
494 ),
495 );
496
497 it.effect("closes a stalled health probe at its deadline", () =>
498 Effect.scoped(
499 Effect.gen(function* () {
500 const harness = yield* makeHarness();
501 yield* harness.initialize;
502 const entered = yield* Deferred.make<void>();
503 const closed = yield* Deferred.make<void>();
504 yield* Ref.set(
505 harness.probe,
506 Deferred.succeed(entered, undefined).pipe(
507 Effect.andThen(Effect.never),
508 Effect.ensuring(Deferred.succeed(closed, undefined)),
509 ),
510 );
511 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
512 yield* Deferred.await(entered);
513 yield* TestClock.adjust("90 seconds");
514 const snapshot = yield* Fiber.join(refresh);
515 yield* Deferred.await(closed);
516 expect(snapshot.status).toBe("error");
517 expect(snapshot.message).toContain("90 seconds");
518 }),
519 ),
520 );
521
522 it.effect("distinguishes missing executables from a failed installed executable", () =>
523 Effect.scoped(
524 Effect.gen(function* () {
525 const harness = yield* makeHarness();
526 yield* harness.initialize;
527 const failures = [
528 {
529 error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }),
530 installed: false,
531 },
532 {
533 error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }),
534 installed: true,
535 },
536 {
537 error: new ProviderSetupError({
538 instanceId,
539 operation: "resolve",
540 detail: "Antigravity is not installed.",
541 }),
542 installed: false,
543 },
544 ];
545 for (const { error, installed } of failures) {
546 yield* harness.provider.onSessionStarted(started, "/workspace");
547 yield* harness.provider.onAvailableCommands(commands, "/workspace");
548 yield* Ref.set(harness.probe, Effect.fail(error));
549 const snapshot = yield* harness.provider.snapshot.refresh;
550 expect(snapshot).toMatchObject({
551 installed,
552 status: "error",
553 auth: { status: "authenticated" },
554 });
555 expect(snapshot.models).toHaveLength(installed ? 11 : 0);
556 expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0);
557 expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0);
558 expect(snapshot.supportsTextGeneration).toBe(installed);
559 }
560 }),
561 ),
562 );
563
564 it.effect("does not let an old health result restore a signed-out account", () =>
565 Effect.scoped(
566 Effect.gen(function* () {
567 const harness = yield* makeHarness();
568 yield* harness.initialize;
569 yield* harness.provider.onSessionStarted(started);
570 const releaseProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse>();
571 const probeEntered = yield* Deferred.make<void>();
572 yield* Ref.set(
573 harness.probe,
574 Deferred.succeed(probeEntered, undefined).pipe(
575 Effect.andThen(Deferred.await(releaseProbe)),
576 ),
577 );
578 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
579 yield* Deferred.await(probeEntered);
580 yield* harness.provider.onSignedOut;
581 yield* Deferred.succeed(releaseProbe, initializeResult);
582 const snapshot = yield* Fiber.join(refresh);
583 expect(snapshot).toMatchObject({
584 auth: { status: "unauthenticated" },
585 models: [],
586 supportsTextGeneration: false,
587 });
588 }),
589 ),
590 );
591
592 it.effect("exposes helper support only when the supplied safety check allows it", () =>
593 Effect.scoped(
594 Effect.gen(function* () {
595 const harness = yield* makeHarness({ safe: false });
596 yield* harness.initialize;
597 yield* harness.provider.onSessionStarted(started);
598 expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false);
599 yield* Ref.set(harness.safety, Effect.succeed(true));
600 yield* harness.provider.snapshot.refresh;
601 expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true);
602 }),
603 ),
604 );
605
606 it.effect("keeps discovered workspace skills through session and command updates", () =>
607 Effect.scoped(
608 Effect.gen(function* () {
609 const harness = yield* makeHarness();
610 yield* harness.initialize;
611 const skills = [
612 {
613 name: "deploy",
614 description: "Ship it",
615 path: "/workspace/.agent/skills/deploy",
616 enabled: true,
617 },
618 ];
619 const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills);
620 expect(discovered.skills).toEqual(skills);
621 yield* harness.provider.onSessionStarted(started, "/workspace");
622 yield* harness.provider.onAvailableCommands(commands, "/workspace");
623 const after = yield* harness.provider.snapshot.getSnapshot;
624 expect(
625 after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills,
626 ).toEqual(skills);
627 expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills);
628
629 const rescanned = [
630 ...skills,
631 { name: "review", path: "/workspace/.agent/skills/review", enabled: true },
632 ];
633 yield* harness.provider.snapshotForCwd("/workspace", rescanned);
634 yield* harness.provider.onSessionStarted(started, "/workspace");
635 expect(
636 (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find(
637 (entry) => entry.cwd === "/workspace",
638 )?.skills,
639 ).toEqual(rescanned);
640 }),
641 ),
642 );
643
644 it.effect("bounds workspace metadata without starting sessions for workspace lookup", () =>
645 Effect.scoped(
646 Effect.gen(function* () {
647 const harness = yield* makeHarness();
648 yield* harness.initialize;
649 yield* harness.provider.onSessionStarted(started);
650 for (let index = 0; index < 35; index++) {
651 yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`);
652 }
653 const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34");
654 expect(snapshot.workspaceSnapshots).toHaveLength(32);
655 expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3");
656 expect(snapshot.slashCommands).toEqual(expectedCommands);
657 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
658 }),
659 ),
660 );
661});
apps/server/src/provider/Layers/AntigravityProvider.ts deleted-415
......@@ -1,415 +0,0 @@
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 type AntigravitySettings,
5 type ProviderSetupError,
6 type ServerProvider,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Predicate from "effect/Predicate";
15import * as Result from "effect/Result";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import type * as EffectAcpErrors from "effect-acp/errors";
19import type * as EffectAcpSchema from "effect-acp/compat";
20
21import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts";
22import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
23import {
24 makeManualOnlyProviderMaintenanceCapabilities,
25 type ProviderMaintenanceCapabilities,
26} from "../providerMaintenance.ts";
27import {
28 buildServerProvider,
29 isCommandMissingCause,
30 type ServerProviderDraft,
31} from "../providerSnapshot.ts";
32
33const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
34const MAX_WORKSPACE_SNAPSHOTS = 32;
35const HEALTH_CHECK_TIMEOUT = "90 seconds";
36const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";
37const AUTH_UNCHECKED_MESSAGE =
38 "Antigravity is installed. Google account access is not checked yet.";
39
40type SessionSetupResult = Pick<
41 AcpSessionRuntimeStartResult["sessionSetupResult"],
42 "configOptions" | "models"
43>;
44
45/** Keep the native model IDs, including model-specific thinking levels. */
46export function buildAntigravityModelsFromSession(
47 setup: SessionSetupResult,
48): ReadonlyArray<ServerProviderModel> {
49 const config = setup.configOptions?.find(
50 (option) => option.id === "model" || option.category === "model",
51 );
52 const currentValue =
53 config?.type === "select" ? config.currentValue : setup.models?.currentModelId;
54 const entries =
55 config?.type === "select"
56 ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options))
57 : config === undefined
58 ? (setup.models?.availableModels.map((model) => ({
59 value: model.modelId,
60 name: model.name,
61 })) ?? [])
62 : [];
63 const seen = new Set<string>();
64 return entries.flatMap((entry): ServerProviderModel[] => {
65 if (!entry.value.trim() || seen.has(entry.value)) return [];
66 seen.add(entry.value);
67 return [
68 {
69 slug: entry.value,
70 name: entry.name.trim() ? entry.name : entry.value,
71 isCustom: false,
72 ...(entry.value === currentValue
73 ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }
74 : {}),
75 capabilities: EMPTY_MODEL_CAPABILITIES,
76 },
77 ];
78 });
79}
80
81function nativeCommands(
82 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
83): ReadonlyArray<ServerProviderSlashCommand> {
84 const seen = new Set<string>();
85 return commands.flatMap((command): ServerProviderSlashCommand[] => {
86 if (!command.name.trim() || seen.has(command.name)) return [];
87 seen.add(command.name);
88 const description = command.description.trim();
89 const hint = command.input?.hint.trim();
90 return [
91 {
92 name: command.name,
93 ...(description ? { description } : {}),
94 ...(hint ? { input: { hint } } : {}),
95 },
96 ];
97 });
98}
99
100function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean {
101 if (error._tag === "AcpSpawnError") {
102 return (
103 isCommandMissingCause(error.cause) ||
104 (Predicate.isObject(error.cause) && error.cause.code === "ENOENT")
105 );
106 }
107 return (
108 error._tag === "ProviderSetupError" &&
109 error.operation === "resolve" &&
110 /not installed|missing|incomplete|does not publish/i.test(error.detail)
111 );
112}
113
114interface AntigravityProviderState {
115 readonly draft: ServerProviderDraft;
116 readonly authRevision: number;
117}
118
119interface AntigravityProviderOptions {
120 readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect<ServerProvider>;
121 readonly probe: Effect.Effect<
122 EffectAcpSchema.InitializeResponse,
123 EffectAcpErrors.AcpError | ProviderSetupError
124 >;
125 readonly supportsTextGeneration: Effect.Effect<boolean>;
126 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
127 /** Auth type and label published once a session authenticates. */
128 readonly auth?: { readonly type: string; readonly label: string };
129}
130
131/** Health uses initialize only. Session callbacks supply account-specific metadata. */
132export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* (
133 settings: AntigravitySettings,
134 options: AntigravityProviderOptions,
135) {
136 const checkedAt = DateTime.formatIso(yield* DateTime.now);
137 const initialDraft = {
138 ...buildServerProvider({
139 presentation: { displayName: "Antigravity", showInteractionModeToggle: false },
140 enabled: settings.enabled,
141 checkedAt,
142 models: [],
143 probe: {
144 installed: false,
145 version: null,
146 status: "warning",
147 // The configured method rides along so the registry can tell a saved
148 // account for this method from one left by a previous configuration.
149 auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) },
150 message: settings.enabled
151 ? "Checking Antigravity availability."
152 : "Antigravity is disabled in T3 Code settings.",
153 },
154 }),
155 setup: { canAuthenticate: true, canInstall: true },
156 supportsConversationRollback: false,
157 supportsTextGeneration: false,
158 workspaceSnapshots: [],
159 } satisfies ServerProviderDraft;
160 const metadata = yield* SubscriptionRef.make<AntigravityProviderState>({
161 draft: initialDraft,
162 authRevision: 0,
163 });
164 // Skills the driver discovered on disk per workspace. Session callbacks
165 // rewrite the workspace entry with native commands and must keep these, or
166 // the registry drops the suggestions and never re-reads the workspace.
167 const discoveredSkills = new Map<string, ServerProvider["skills"]>();
168 const getSnapshot = SubscriptionRef.get(metadata).pipe(
169 Effect.flatMap((state) => options.stampIdentity(state.draft)),
170 );
171
172 const checkProvider = Effect.fn("checkAntigravityProvider")(function* () {
173 if (!settings.enabled) return yield* getSnapshot;
174 const before = yield* SubscriptionRef.get(metadata);
175 const result = yield* options.probe.pipe(
176 Effect.timeoutOption(HEALTH_CHECK_TIMEOUT),
177 Effect.result,
178 );
179 const initialized =
180 Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined;
181 const failure = Result.isFailure(result) ? result.failure : undefined;
182 const missingInstallation = failure !== undefined && isMissingInstallation(failure);
183 const errorMessage =
184 initialized !== undefined
185 ? undefined
186 : failure?._tag === "ProviderSetupError"
187 ? failure.detail.trim() || "Antigravity could not complete its local health check."
188 : missingInstallation
189 ? "Antigravity is not installed or its executable could not be found."
190 : failure
191 ? "Antigravity could not complete its local health check."
192 : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`;
193 const supportsTextGeneration =
194 initialized !== undefined ? yield* options.supportsTextGeneration : false;
195 const updatedAt = DateTime.formatIso(yield* DateTime.now);
196 const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => {
197 if (state.authRevision !== before.authRevision) return state;
198 const { message: _previousMessage, ...draft } = state.draft;
199 const authenticated = draft.auth.status === "authenticated";
200 const message =
201 errorMessage ??
202 (authenticated
203 ? undefined
204 : draft.auth.status === "unauthenticated"
205 ? SIGN_IN_MESSAGE
206 : AUTH_UNCHECKED_MESSAGE);
207 return {
208 ...state,
209 draft: {
210 ...draft,
211 installed: !missingInstallation,
212 version: initialized?.agentInfo?.version || draft.version,
213 status: errorMessage ? "error" : authenticated ? "ready" : "warning",
214 checkedAt: updatedAt,
215 ...(missingInstallation
216 ? {
217 models: [],
218 slashCommands: [],
219 skills: [],
220 workspaceSnapshots: [],
221 supportsTextGeneration: false,
222 }
223 : {}),
224 ...(initialized !== undefined
225 ? {
226 supportsTextGeneration:
227 supportsTextGeneration && draft.auth.status !== "unauthenticated",
228 }
229 : {}),
230 ...(message ? { message } : {}),
231 },
232 } satisfies AntigravityProviderState;
233 });
234 return yield* options.stampIdentity(next.draft);
235 });
236
237 const maintenanceCapabilities =
238 options.maintenanceCapabilities ??
239 makeManualOnlyProviderMaintenanceCapabilities({
240 provider: ProviderDriverKind.make("antigravity"),
241 packageName: null,
242 });
243 const managed = yield* makeManagedServerProvider({
244 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
245 getSettings: Effect.succeed(settings),
246 streamSettings: Stream.empty,
247 haveSettingsChanged: () => false,
248 initialSnapshot: () => getSnapshot,
249 checkProvider: checkProvider(),
250 enrichSnapshot: ({ publishSnapshot }) =>
251 SubscriptionRef.changes(metadata).pipe(
252 Stream.runForEach((state) =>
253 options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)),
254 ),
255 ),
256 });
257
258 const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* (
259 started: AcpSessionRuntimeStartResult,
260 cwd?: string,
261 ) {
262 const before = yield* SubscriptionRef.get(metadata);
263 const supportsTextGeneration = yield* options.supportsTextGeneration;
264 const updatedAt = DateTime.formatIso(yield* DateTime.now);
265 yield* SubscriptionRef.update(metadata, (state) => {
266 if (
267 state.authRevision !== before.authRevision &&
268 state.draft.auth.status === "unauthenticated"
269 ) {
270 return state;
271 }
272 const { message: _previousMessage, ...draft } = state.draft;
273 const workspaces = draft.workspaceSnapshots ?? [];
274 const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined;
275 return {
276 authRevision: state.authRevision + 1,
277 draft: {
278 ...draft,
279 installed: true,
280 status: settings.enabled ? "ready" : "disabled",
281 version: started.initializeResult.agentInfo?.version || draft.version,
282 auth: {
283 status: "authenticated",
284 type: options.auth?.type ?? "oauth-personal",
285 label: options.auth?.label ?? "Google account",
286 },
287 checkedAt: updatedAt,
288 models: buildAntigravityModelsFromSession(started.sessionSetupResult),
289 supportsTextGeneration,
290 ...(cwd
291 ? {
292 workspaceSnapshots: [
293 ...workspaces.filter((entry) => entry.cwd !== cwd),
294 {
295 cwd,
296 checkedAt: updatedAt,
297 slashCommands: workspace?.slashCommands ?? draft.slashCommands,
298 skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [],
299 },
300 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
301 }
302 : {}),
303 },
304 } satisfies AntigravityProviderState;
305 });
306 });
307
308 const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* (
309 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
310 ) {
311 const models = buildAntigravityModelsFromSession({ configOptions });
312 yield* SubscriptionRef.update(metadata, (state) => {
313 if (state.draft.auth.status !== "authenticated") return state;
314 return { ...state, draft: { ...state.draft, models } };
315 });
316 });
317
318 const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* (
319 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
320 cwd?: string,
321 ) {
322 const slashCommands = nativeCommands(commands);
323 const updatedAt = DateTime.formatIso(yield* DateTime.now);
324 yield* SubscriptionRef.update(metadata, (state) => {
325 if (state.draft.auth.status === "unauthenticated") return state;
326 return {
327 ...state,
328 draft: {
329 ...state.draft,
330 slashCommands,
331 ...(cwd
332 ? {
333 workspaceSnapshots: [
334 ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd),
335 {
336 cwd,
337 checkedAt: updatedAt,
338 slashCommands,
339 skills:
340 state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ??
341 discoveredSkills.get(cwd) ??
342 [],
343 },
344 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
345 }
346 : {}),
347 },
348 };
349 });
350 });
351
352 const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () {
353 const updatedAt = DateTime.formatIso(yield* DateTime.now);
354 yield* SubscriptionRef.update(
355 metadata,
356 (state) =>
357 ({
358 authRevision: state.authRevision + 1,
359 draft: {
360 ...state.draft,
361 auth: { status: "unauthenticated" },
362 status: settings.enabled ? "warning" : "disabled",
363 message: SIGN_IN_MESSAGE,
364 checkedAt: updatedAt,
365 models: [],
366 slashCommands: [],
367 skills: [],
368 workspaceSnapshots: [],
369 supportsTextGeneration: false,
370 },
371 }) satisfies AntigravityProviderState,
372 );
373 discoveredSkills.clear();
374 });
375
376 const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* (
377 cwd: string,
378 skills?: ServerProvider["skills"],
379 ) {
380 if (skills) {
381 discoveredSkills.set(cwd, skills);
382 // A rescan replaces the stored entry's skills. Session callbacks and
383 // health checks republish that entry, so it must not keep old ones.
384 yield* SubscriptionRef.update(metadata, (state) =>
385 state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd)
386 ? {
387 ...state,
388 draft: {
389 ...state.draft,
390 workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) =>
391 entry.cwd === cwd ? { ...entry, skills } : entry,
392 ),
393 },
394 }
395 : state,
396 );
397 }
398 const snapshot = yield* getSnapshot;
399 const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd);
400 const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? [];
401 return workspace
402 ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills }
403 : { ...snapshot, skills: resolvedSkills };
404 });
405
406 return {
407 snapshot: { ...managed, getSnapshot },
408 onSessionStarted,
409 onConfigOptionsUpdated,
410 onAvailableCommands,
411 onSignedOut: clearAccountMetadata(),
412 onAuthRequired: clearAccountMetadata(),
413 snapshotForCwd,
414 };
415});
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts deleted-355
......@@ -1,355 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
2import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3import { vi } from "vite-plus/test";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as TestClock from "effect/testing/TestClock";
7import {
8 ClaudeSettings,
9 ProviderDriverKind,
10 ProviderInstanceId,
11 type ServerProvider,
12} from "@t3tools/contracts";
13import * as NodeFSP from "node:fs/promises";
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { assert, it } from "@effect/vitest";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20
21import {
22 buildClaudeCapabilitiesProbeQueryOptions,
23 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
24 probeClaudeCapabilities,
25 probeClaudeWorkspaceSnapshot,
26} from "./ClaudeProvider.ts";
27import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
28
29vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
30
31const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
32
33it("isolates Claude capability probes without dropping workspace setting sources", () => {
34 const abortController = new AbortController();
35 const options = buildClaudeCapabilitiesProbeQueryOptions({
36 executablePath: "/usr/bin/claude",
37 abortController,
38 environment: {
39 HOME: "/home/user",
40 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
41 FORCE_CODE_TERMINAL: "1",
42 },
43 cwd: "/workspace/project",
44 });
45
46 assert.deepEqual(options.mcpServers, {});
47 assert.equal(options.strictMcpConfig, true);
48 assert.equal(options.cwd, "/workspace/project");
49 assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]);
50 assert.deepEqual(options.settings, { disableAllHooks: true });
51 assert.deepEqual(options.allowedTools, []);
52 assert.equal(options.persistSession, false);
53 assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude");
54 assert.equal(options.abortController, abortController);
55 assert.equal(options.env?.HOME, "/home/user");
56 assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
57 assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined);
58 assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0");
59 assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1");
60});
61
62it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
63 it.effect(
64 "discovers commands and skills separately for each cwd without replacing machine metadata",
65 () =>
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" });
70 const configDir = path.join(tempDir, "claude-home");
71 const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")];
72 for (const cwd of workspaces) {
73 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
74 yield* fs.makeDirectory(skillDir, { recursive: true });
75 yield* fs.writeFileString(
76 path.join(skillDir, "SKILL.md"),
77 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
78 );
79 }
80 const machineSnapshot = {
81 instanceId: ProviderInstanceId.make("claude"),
82 driver: ProviderDriverKind.make("claudeAgent"),
83 enabled: true,
84 installed: true,
85 status: "ready",
86 auth: { status: "authenticated", email: "machine@example.com" },
87 checkedAt: "2026-03-25T00:00:00.000Z",
88 version: "2.1.288",
89 models: [],
90 slashCommands: [{ name: "server-cwd-only" }],
91 skills: [],
92 } satisfies ServerProvider;
93 let usageCalls = 0;
94 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
95 assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir);
96 assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance");
97 return {
98 initializationResult: async () => ({
99 account: { email: "workspace@example.com" },
100 commands: [
101 {
102 name: options?.cwd === workspaces[0] ? "start-session" : "other-project",
103 description: "Project command",
104 argumentHint: "[topic]",
105 },
106 { name: "nested:review", description: "Review changes", argumentHint: "" },
107 { name: "NESTED:review", description: "", argumentHint: "[path]" },
108 { name: "compact", description: "Provider compact", argumentHint: "" },
109 { name: "user-command", description: "Existing user command", argumentHint: "" },
110 ],
111 }),
112 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => {
113 usageCalls++;
114 return { rate_limits_available: false, rate_limits: null };
115 },
116 } as ReturnType<typeof ClaudeSdk.query>;
117 });
118 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
119 for (const [index, cwd] of workspaces.entries()) {
120 const scoped = yield* probeClaudeWorkspaceSnapshot(
121 decodeClaudeSettings({ homePath: configDir }),
122 machineSnapshot,
123 cwd,
124 { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" },
125 );
126 assert.deepEqual(scoped, {
127 ...machineSnapshot,
128 slashCommandsPending: false,
129 slashCommands: [
130 COMPACT_SLASH_COMMAND,
131 {
132 name: index === 0 ? "start-session" : "other-project",
133 description: "Project command",
134 input: { hint: "[topic]" },
135 },
136 { name: "nested:review", description: "Review changes", input: { hint: "[path]" } },
137 { name: "user-command", description: "Existing user command" },
138 ],
139 skills: [
140 {
141 name: "existing-skill",
142 path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"),
143 enabled: true,
144 scope: "project",
145 description: "Existing project skill",
146 },
147 ],
148 });
149 }
150 assert.deepEqual(
151 query.mock.calls.map(([input]) => input.options?.cwd),
152 workspaces,
153 );
154 assert.equal(usageCalls, 0);
155 }).pipe(Effect.scoped),
156 );
157
158 it.effect("keeps readable skills during failed command discovery and recovers on retry", () =>
159 Effect.gen(function* () {
160 const fs = yield* FileSystem.FileSystem;
161 const path = yield* Path.Path;
162 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" });
163 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
164 yield* fs.makeDirectory(skillDir, { recursive: true });
165 yield* fs.writeFileString(
166 path.join(skillDir, "SKILL.md"),
167 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
168 );
169 const machineSnapshot = {
170 instanceId: ProviderInstanceId.make("claude"),
171 driver: ProviderDriverKind.make("claudeAgent"),
172 enabled: true,
173 installed: true,
174 status: "ready",
175 auth: { status: "authenticated" },
176 checkedAt: "2026-03-25T00:00:00.000Z",
177 version: "2.1.288",
178 models: [],
179 slashCommands: [{ name: "server-cwd-only" }],
180 skills: [],
181 } satisfies ServerProvider;
182 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
183 () =>
184 ({
185 initializationResult: () =>
186 Promise.reject<ClaudeSdk.SDKControlInitializeResponse>(
187 new Error("Initialization failed"),
188 ),
189 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
190 rate_limits_available: false,
191 rate_limits: null,
192 }),
193 }) as ReturnType<typeof ClaudeSdk.query>,
194 );
195 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
196 const settings = decodeClaudeSettings({ homePath: cwd });
197 const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
198 assert.deepEqual(failed, {
199 ...machineSnapshot,
200 slashCommands: [COMPACT_SLASH_COMMAND],
201 slashCommandsPending: true,
202 skills: [
203 {
204 name: "existing-skill",
205 path: path.join(skillDir, "SKILL.md"),
206 enabled: true,
207 scope: "project",
208 description: "Existing project skill",
209 },
210 ],
211 });
212 query.mockImplementation(
213 () =>
214 ({
215 initializationResult: async () => ({
216 commands: [{ name: "recovered", description: "", argumentHint: "" }],
217 }),
218 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
219 rate_limits_available: false,
220 rate_limits: null,
221 }),
222 }) as ReturnType<typeof ClaudeSdk.query>,
223 );
224 const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
225 assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]);
226 assert.equal(recovered.status, "ready");
227 assert.equal(recovered.slashCommandsPending, false);
228 assert.deepEqual(recovered.skills, failed.skills);
229 const disabled = yield* probeClaudeWorkspaceSnapshot(
230 { ...settings, enabled: false },
231 machineSnapshot,
232 cwd,
233 );
234 assert.equal(disabled, machineSnapshot);
235 assert.equal(query.mock.calls.length, 2);
236 }).pipe(Effect.scoped),
237 );
238
239 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
240 Effect.gen(function* () {
241 const fs = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" });
244 const executablePath = yield* path.fromFileUrl(
245 new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url),
246 );
247 const invocationPath = path.join(tempDir, "invocation.json");
248 // The probe aborts the SDK without awaiting the child's exit, and on
249 // Windows a directory that is still some process's cwd cannot be
250 // removed. Keep the workspace outside the scoped directory and let it
251 // go with a retrying removal once the child has gone.
252 const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" });
253 // Node's own retry rather than an Effect schedule: it.effect runs on a
254 // TestClock, so a scheduled retry would wait for time nobody advances.
255 // If the child still holds the directory after that, an empty temp
256 // directory is left behind rather than failing the test for it.
257 yield* Effect.addFinalizer(() =>
258 Effect.promise(() =>
259 NodeFSP.rm(workspaceCwd, {
260 recursive: true,
261 force: true,
262 maxRetries: 20,
263 retryDelay: 250,
264 }).catch(() => undefined),
265 ),
266 );
267
268 const capabilities = yield* probeClaudeCapabilities(
269 decodeClaudeSettings({ binaryPath: executablePath }),
270 {
271 ...process.env,
272 T3_PROBE_INVOCATION_PATH: invocationPath,
273 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
274 },
275 workspaceCwd,
276 );
277
278 assert.deepEqual(capabilities, {
279 email: "dev@example.com",
280 subscriptionType: "pro",
281 tokenSource: "oauth",
282 apiProvider: undefined,
283 slashCommands: [
284 {
285 name: "review",
286 description: "Review changes",
287 input: { hint: "[path]" },
288 },
289 ],
290 usage: {
291 rate_limits_available: true,
292 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
293 },
294 });
295
296 // @effect-diagnostics-next-line preferSchemaOverJson:off
297 const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as {
298 readonly args: ReadonlyArray<string>;
299 readonly cwd: string;
300 readonly connectorEnv: string;
301 readonly mcpConfig: unknown;
302 };
303 assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd));
304 assert.equal(invocation.connectorEnv, "false");
305 assert.equal(invocation.args.includes("--strict-mcp-config"), true);
306 assert.equal(invocation.args.includes("--mcp-config"), false);
307 assert.equal(invocation.mcpConfig, undefined);
308
309 assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true);
310
311 const settingsFlagIndex = invocation.args.indexOf("--settings");
312 assert.notEqual(settingsFlagIndex, -1);
313 // @effect-diagnostics-next-line preferSchemaOverJson:off
314 const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as {
315 readonly disableAllHooks?: boolean;
316 };
317 assert.equal(flagSettings.disableAllHooks, true);
318 }).pipe(Effect.scoped),
319 );
320});
321
322it.effect("preserves initialized capabilities when optional usage times out", () =>
323 Effect.gen(function* () {
324 const usageStarted = yield* Deferred.make<void>();
325 let abortSignal: AbortSignal | undefined;
326 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
327 abortSignal = options?.abortController?.signal;
328 return {
329 initializationResult: async () => ({
330 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
331 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
332 }),
333 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => {
334 Deferred.doneUnsafe(usageStarted, Effect.void);
335 return new Promise(() => {});
336 },
337 } as ReturnType<typeof ClaudeSdk.query>;
338 });
339 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
340 const probe = yield* probeClaudeCapabilities(
341 decodeClaudeSettings({ binaryPath: "claude" }),
342 ).pipe(Effect.forkChild);
343 yield* Deferred.await(usageStarted);
344 yield* TestClock.adjust("4 seconds");
345 const capabilities = yield* Fiber.join(probe);
346 assert.equal(capabilities?.email, "dev@example.com");
347 assert.equal(capabilities?.subscriptionType, "pro");
348 assert.equal(capabilities?.tokenSource, "oauth");
349 assert.deepEqual(capabilities?.slashCommands, [
350 { name: "review", description: "Review changes", input: { hint: "[path]" } },
351 ]);
352 assert.equal(capabilities?.usage, undefined);
353 assert.equal(abortSignal?.aborted, true);
354 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
355);
apps/server/src/provider/Layers/ClaudeProvider.ts deleted-675
......@@ -1,675 +0,0 @@
1import {
2 type ClaudeSettings,
3 type ModelCapabilities,
4 type ServerProvider,
5 type ServerProviderSlashCommand,
6 type ServerProviderResetCredits,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Ref from "effect/Ref";
14import * as Result from "effect/Result";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16import { createModelCapabilities } from "@t3tools/shared/model";
17import { resolveSpawnCommand } from "@t3tools/shared/shell";
18import {
19 query as claudeQuery,
20 type Options as ClaudeQueryOptions,
21 type SlashCommand as ClaudeSlashCommand,
22 type SDKControlGetUsageResponse,
23 type SDKUserMessage,
24 type SettingSource,
25} from "@anthropic-ai/claude-agent-sdk";
26
27import {
28 buildServerProvider,
29 COMPACT_SLASH_COMMAND,
30 DEFAULT_TIMEOUT_MS,
31 isCommandMissingCause,
32 parseGenericCliVersion,
33 providerModelsFromSettings,
34 spawnAndCollect,
35 type ServerProviderDraft,
36} from "../providerSnapshot.ts";
37import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
38import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
39import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
40import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
41import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
42import {
43 type ClaudeScopedLimitNames,
44 claudeUsageResponseToLimits,
45 recordClaudeUsageResponse,
46} from "./claudeUsageLimits.ts";
47import {
48 BUNDLED_CLAUDE_MODEL_CATALOG,
49 type ClaudeModelCatalog,
50 formatClaudeVersionUpgradeMessage,
51 resolveClaudeModelsForVersion,
52} from "../ClaudeModelCatalog.ts";
53
54const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
55 optionDescriptors: [],
56});
57
58const CLAUDE_PRESENTATION = {
59 displayName: "Claude",
60 showInteractionModeToggle: true,
61 reportsContextWindow: true,
62} as const;
63function toTitleCaseWords(value: string): string {
64 const parts: Array<string> = [];
65 for (const part of value.split(/[\s_-]+/g)) {
66 if (part.length > 0) {
67 parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase());
68 }
69 }
70 return parts.join(" ");
71}
72
73function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined {
74 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
75 if (!normalized) return undefined;
76
77 switch (normalized) {
78 case "claudemaxsubscription":
79 return "Max";
80 case "claudemax5xsubscription":
81 return "Max 5x";
82 case "claudemax20xsubscription":
83 return "Max 20x";
84 case "claudeenterprisesubscription":
85 return "Enterprise";
86 case "claudeteamsubscription":
87 return "Team";
88 case "claudeprosubscription":
89 return "Pro";
90 case "claudefreesubscription":
91 return "Free";
92 case "max":
93 case "maxplan":
94 return "Max";
95 case "max5":
96 return "Max 5x";
97 case "max20":
98 return "Max 20x";
99 case "enterprise":
100 return "Enterprise";
101 case "team":
102 return "Team";
103 case "pro":
104 return "Pro";
105 case "free":
106 return "Free";
107 default:
108 return toTitleCaseWords(subscriptionType!);
109 }
110}
111
112function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined {
113 const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, "");
114 if (!normalized) return undefined;
115 if (
116 normalized === "apikey" ||
117 normalized === "anthropicapikey" ||
118 normalized === "anthropicauthtoken"
119 ) {
120 return "apiKey";
121 }
122 return undefined;
123}
124
125function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string {
126 const subscriptionLabel =
127 claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType);
128 const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, "");
129
130 if (normalized.startsWith("claude") && normalized.endsWith("subscription")) {
131 return subscriptionLabel;
132 }
133 if (normalized.startsWith("claude")) {
134 return `${subscriptionLabel} Subscription`;
135 }
136 if (normalized.endsWith("subscription")) {
137 return `Claude ${subscriptionLabel}`;
138 }
139 return `Claude ${subscriptionLabel} Subscription`;
140}
141
142function claudeAuthMetadata(input: {
143 readonly subscriptionType: string | undefined;
144 readonly authMethod: string | undefined;
145}): { readonly type: string; readonly label: string } | undefined {
146 if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") {
147 return {
148 type: "apiKey",
149 label: "Claude API Key",
150 };
151 }
152
153 if (input.subscriptionType) {
154 return {
155 type: input.subscriptionType,
156 label: formatClaudeSubscriptionAuthLabel(input.subscriptionType),
157 };
158 }
159
160 return undefined;
161}
162
163function apiProviderAuthMetadata(
164 apiProvider: string | undefined,
165): { readonly type: string; readonly label: string } | undefined {
166 return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined;
167}
168
169// ── SDK capability probe ────────────────────────────────────────────
170
171// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the
172// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning
173// account info. The previous 8s budget expired mid-init, so the probe returned
174// `undefined` and left the provider unverified and unselectable in the picker.
175const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;
176
177/**
178 * Keep workspace-scoped command discovery intact while isolating the periodic
179 * health check from configured MCP servers.
180 */
181export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [
182 "user",
183 "project",
184 "local",
185] as const satisfies ReadonlyArray<SettingSource>;
186
187/** Build the exact SDK options used by the periodic Claude capability probe. */
188export function buildClaudeCapabilitiesProbeQueryOptions(input: {
189 readonly executablePath: string;
190 readonly abortController: AbortController;
191 readonly environment: NodeJS.ProcessEnv;
192 readonly cwd: string | undefined;
193}): ClaudeQueryOptions {
194 return {
195 persistSession: false,
196 pathToClaudeCodeExecutable: input.executablePath,
197 abortController: input.abortController,
198 settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES],
199 // The probe keeps filesystem setting sources for slash-command discovery,
200 // but must not run the user's hooks: it fires every few minutes, so
201 // SessionStart hooks would run on every health check.
202 settings: { disableAllHooks: true },
203 allowedTools: [],
204 // Ignore MCP definitions from every filesystem setting source above. The
205 // SDK combines this empty explicit map with --strict-mcp-config.
206 mcpServers: {},
207 strictMcpConfig: true,
208 env: {
209 ...input.environment,
210 // Connected claude.ai MCP servers are discovered outside filesystem
211 // config; disable them independently for this health check.
212 ENABLE_CLAUDEAI_MCP_SERVERS: "false",
213 // This is a noninteractive health check, so IDE discovery cannot add any
214 // useful capability data. Skipping it also avoids Claude spawning a
215 // Windows `tasklist | findstr` process tree on every periodic refresh.
216 FORCE_CODE_TERMINAL: undefined,
217 CLAUDE_CODE_AUTO_CONNECT_IDE: "0",
218 CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1",
219 },
220 ...(input.cwd ? { cwd: input.cwd } : {}),
221 stderr: () => {},
222 };
223}
224
225function nonEmptyProbeString(value: string): string | undefined {
226 const candidate = value.trim();
227 return candidate ? candidate : undefined;
228}
229
230type ClaudeCapabilitiesProbe = {
231 readonly email: string | undefined;
232 readonly subscriptionType: string | undefined;
233 readonly tokenSource: string | undefined;
234 /**
235 * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth
236 * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`)
237 * the subscription/token fields are absent and auth is external AWS creds.
238 */
239 readonly apiProvider: string | undefined;
240 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
241 /**
242 * Subscription windows from the SDK's `get_usage` control request, or
243 * `undefined` when the request itself failed. Absent windows on an
244 * otherwise successful response mean the account has none (API key).
245 */
246 readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
247};
248
249function parseClaudeInitializationCommands(
250 commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
251): ReadonlyArray<ServerProviderSlashCommand> {
252 return dedupeSlashCommands(
253 (commands ?? []).flatMap((command) => {
254 const name = nonEmptyProbeString(command.name);
255 if (!name) {
256 return [];
257 }
258
259 const description = nonEmptyProbeString(command.description);
260 const argumentHint = nonEmptyProbeString(command.argumentHint);
261
262 return [
263 {
264 name,
265 ...(description ? { description } : {}),
266 ...(argumentHint ? { input: { hint: argumentHint } } : {}),
267 } satisfies ServerProviderSlashCommand,
268 ];
269 }),
270 );
271}
272
273function dedupeSlashCommands(
274 commands: ReadonlyArray<ServerProviderSlashCommand>,
275): ReadonlyArray<ServerProviderSlashCommand> {
276 const commandsByName = new Map<string, ServerProviderSlashCommand>();
277
278 for (const command of commands) {
279 const name = nonEmptyProbeString(command.name);
280 if (!name) {
281 continue;
282 }
283
284 const key = name.toLowerCase();
285 const existing = commandsByName.get(key);
286 if (!existing) {
287 commandsByName.set(key, {
288 ...command,
289 name,
290 });
291 continue;
292 }
293
294 commandsByName.set(key, {
295 ...existing,
296 ...(existing.description
297 ? {}
298 : command.description
299 ? { description: command.description }
300 : {}),
301 ...(existing.input?.hint
302 ? {}
303 : command.input?.hint
304 ? { input: { hint: command.input.hint } }
305 : {}),
306 });
307 }
308
309 return [...commandsByName.values()];
310}
311
312function waitForAbortSignal(signal: AbortSignal): Promise<void> {
313 if (signal.aborted) {
314 return Promise.resolve();
315 }
316 return new Promise((resolve) => {
317 signal.addEventListener("abort", () => resolve(), { once: true });
318 });
319}
320
321/**
322 * Probe account information by spawning a lightweight Claude Agent SDK
323 * session and reading the initialization result.
324 *
325 * We pass a never-yielding AsyncIterable as the prompt so that no user
326 * message is ever written to the subprocess stdin. This means the Claude
327 * Code subprocess completes its local initialization IPC (returning
328 * account info and slash commands) but never starts an API request to
329 * Anthropic. We read the init data and then abort the subprocess.
330 *
331 * This is used as a fallback when `claude auth status` does not include
332 * subscription type information.
333 */
334const probeClaudeCapabilities = (
335 claudeSettings: ClaudeSettings,
336 environment?: NodeJS.ProcessEnv,
337 cwd?: string,
338 includeUsage = true,
339) => {
340 const abort = new AbortController();
341 return Effect.gen(function* () {
342 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
343 const executablePath = yield* resolveClaudeSdkExecutablePath(
344 claudeSettings.binaryPath,
345 claudeEnvironment,
346 );
347 return yield* Effect.tryPromise(async () => {
348 const q = claudeQuery({
349 // Never yield — we only need initialization data, not a conversation.
350 // This prevents any prompt from reaching the Anthropic API.
351 // oxlint-disable-next-line require-yield
352 prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
353 await waitForAbortSignal(abort.signal);
354 })(),
355 options: buildClaudeCapabilitiesProbeQueryOptions({
356 executablePath,
357 abortController: abort,
358 environment: claudeEnvironment,
359 cwd,
360 }),
361 });
362 const init = await q.initializationResult();
363 return { q, init };
364 });
365 }).pipe(
366 Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS),
367 Effect.flatMap(({ q, init }) =>
368 Effect.gen(function* () {
369 // Usage has its own deadline so a slow optional request cannot discard initialization.
370 const usageResult = includeUsage
371 ? yield* Effect.tryPromise(() =>
372 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
373 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
374 : undefined;
375 const usage =
376 usageResult && Result.isSuccess(usageResult)
377 ? {
378 rate_limits_available: usageResult.success.rate_limits_available,
379 rate_limits: usageResult.success.rate_limits,
380 }
381 : undefined;
382 const account = init.account as
383 | {
384 readonly email?: string;
385 readonly subscriptionType?: string;
386 readonly tokenSource?: string;
387 readonly apiProvider?: string;
388 }
389 | undefined;
390 return {
391 email: account?.email,
392 subscriptionType: account?.subscriptionType,
393 tokenSource: account?.tokenSource,
394 apiProvider: account?.apiProvider,
395 slashCommands: parseClaudeInitializationCommands(init.commands),
396 ...(usage ? { usage } : {}),
397 } satisfies ClaudeCapabilitiesProbe;
398 }),
399 ),
400 Effect.ensuring(
401 Effect.sync(() => {
402 if (!abort.signal.aborted) abort.abort();
403 }),
404 ),
405 Effect.result,
406 Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)),
407 );
408};
409
410const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
411 claudeSettings: ClaudeSettings,
412 args: ReadonlyArray<string>,
413 environment?: NodeJS.ProcessEnv,
414) {
415 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
416 const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, {
417 env: claudeEnvironment,
418 });
419 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
420 env: claudeEnvironment,
421 shell: spawnCommand.shell,
422 });
423 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
424});
425
426/** Read commands from the same cwd Claude uses for a workspace session. */
427export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* (
428 claudeSettings: ClaudeSettings,
429 machineSnapshot: ServerProvider,
430 cwd: string,
431 environment?: NodeJS.ProcessEnv,
432): Effect.fn.Return<ProviderWorkspaceSnapshot, never, FileSystem.FileSystem | Path.Path> {
433 if (!claudeSettings.enabled) return machineSnapshot;
434 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment);
435 const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false);
436 return {
437 ...machineSnapshot,
438 skills,
439 slashCommands: dedupeSlashCommands([
440 COMPACT_SLASH_COMMAND,
441 ...(capabilities?.slashCommands ?? []),
442 ]),
443 slashCommandsPending: !capabilities,
444 };
445});
446
447export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
448 claudeSettings: ClaudeSettings,
449 resolveCapabilities?: (
450 claudeSettings: ClaudeSettings,
451 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
452 environment?: NodeJS.ProcessEnv,
453 cwd?: string,
454 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
455 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
456 scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,
457 /** Banked resets for a subscription login, given the CLI version for the user agent. */
458 resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>,
459): Effect.fn.Return<
460 ServerProviderDraft,
461 never,
462 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
463> {
464 const resolvedEnvironment = environment ?? process.env;
465 const checkedAt = DateTime.formatIso(yield* DateTime.now);
466 const allModels = providerModelsFromSettings(
467 modelCatalog.models.map((entry) => entry.model),
468 claudeSettings.customModels,
469 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
470 );
471
472 if (!claudeSettings.enabled) {
473 return buildServerProvider({
474 presentation: CLAUDE_PRESENTATION,
475 enabled: false,
476 checkedAt,
477 models: allModels,
478 probe: {
479 installed: false,
480 version: null,
481 status: "warning",
482 auth: { status: "unknown" },
483 message: "Claude is disabled in T3 Code settings.",
484 },
485 });
486 }
487
488 const versionProbe = yield* runClaudeCommand(
489 claudeSettings,
490 ["--version"],
491 resolvedEnvironment,
492 ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result);
493
494 if (Result.isFailure(versionProbe)) {
495 const error = versionProbe.failure;
496 yield* Effect.logWarning("Claude Agent CLI health check failed.", {
497 errorTag: error._tag,
498 });
499 return buildServerProvider({
500 presentation: CLAUDE_PRESENTATION,
501 enabled: claudeSettings.enabled,
502 checkedAt,
503 models: allModels,
504 probe: {
505 installed: !isCommandMissingCause(error),
506 version: null,
507 status: "error",
508 auth: { status: "unknown" },
509 message: isCommandMissingCause(error)
510 ? "Claude Agent CLI (`claude`) was not found on PATH."
511 : "Failed to execute Claude Agent CLI health check.",
512 },
513 });
514 }
515
516 if (Option.isNone(versionProbe.success)) {
517 return buildServerProvider({
518 presentation: CLAUDE_PRESENTATION,
519 enabled: claudeSettings.enabled,
520 checkedAt,
521 models: allModels,
522 probe: {
523 installed: true,
524 version: null,
525 status: "error",
526 auth: { status: "unknown" },
527 message:
528 "Claude Agent CLI is installed but failed to run. Timed out while running command.",
529 },
530 });
531 }
532
533 const version = versionProbe.success.value;
534 const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
535 if (version.code !== 0) {
536 yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", {
537 exitCode: version.code,
538 stdoutLength: version.stdout.length,
539 stderrLength: version.stderr.length,
540 });
541 return buildServerProvider({
542 presentation: CLAUDE_PRESENTATION,
543 enabled: claudeSettings.enabled,
544 checkedAt,
545 models: allModels,
546 probe: {
547 installed: true,
548 version: parsedVersion,
549 status: "error",
550 auth: { status: "unknown" },
551 message: "Claude Agent CLI is installed but failed to run.",
552 },
553 });
554 }
555
556 const models = providerModelsFromSettings(
557 resolveClaudeModelsForVersion(modelCatalog, parsedVersion),
558 claudeSettings.customModels,
559 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
560 );
561 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);
562
563 const capabilities = resolveCapabilities
564 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
565 : undefined;
566 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
567 const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
568 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
569
570 if (!capabilities) {
571 return buildServerProvider({
572 presentation: CLAUDE_PRESENTATION,
573 enabled: claudeSettings.enabled,
574 checkedAt,
575 models,
576 slashCommands: dedupedSlashCommands,
577 skills,
578 probe: {
579 installed: true,
580 version: parsedVersion,
581 status: "warning",
582 auth: { status: "unknown" },
583 message: "Could not verify Claude authentication status from initialization result.",
584 },
585 });
586 }
587
588 const authMetadata =
589 claudeAuthMetadata({
590 subscriptionType: capabilities.subscriptionType,
591 authMethod: capabilities.tokenSource,
592 }) ?? apiProviderAuthMetadata(capabilities.apiProvider);
593 const usageLimits = !capabilities.usage
594 ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
595 : scopedLimitNames
596 ? yield* recordClaudeUsageResponse(scopedLimitNames, {
597 response: capabilities.usage,
598 checkedAt,
599 })
600 : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits;
601 const resetCredits =
602 resolveResetCredits &&
603 capabilities.subscriptionType &&
604 !usageLimits.unavailable &&
605 parsedVersion
606 ? yield* resolveResetCredits(parsedVersion)
607 : undefined;
608 return buildServerProvider({
609 presentation: CLAUDE_PRESENTATION,
610 enabled: claudeSettings.enabled,
611 checkedAt,
612 models,
613 slashCommands: dedupedSlashCommands,
614 skills,
615 probe: {
616 installed: true,
617 version: parsedVersion,
618 status: "ready",
619 auth: {
620 status: "authenticated",
621 ...(capabilities.email ? { email: capabilities.email } : {}),
622 ...(authMetadata ? authMetadata : {}),
623 },
624 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
625 usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits,
626 },
627 });
628});
629
630const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
631
632export const makePendingClaudeProvider = (
633 claudeSettings: ClaudeSettings,
634 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
635): Effect.Effect<ServerProviderDraft> =>
636 Effect.gen(function* () {
637 const checkedAt = yield* nowIso;
638 const models = providerModelsFromSettings(
639 modelCatalog.models.map((entry) => entry.model),
640 claudeSettings.customModels,
641 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
642 );
643
644 if (!claudeSettings.enabled) {
645 return buildServerProvider({
646 presentation: CLAUDE_PRESENTATION,
647 enabled: false,
648 checkedAt,
649 models,
650 probe: {
651 installed: false,
652 version: null,
653 status: "warning",
654 auth: { status: "unknown" },
655 message: "Claude is disabled in T3 Code settings.",
656 },
657 });
658 }
659
660 return buildServerProvider({
661 presentation: CLAUDE_PRESENTATION,
662 enabled: true,
663 checkedAt,
664 models,
665 probe: {
666 installed: false,
667 version: null,
668 status: "warning",
669 auth: { status: "unknown" },
670 message: "Claude provider status has not been checked in this session yet.",
671 },
672 });
673 });
674
675export { probeClaudeCapabilities };
apps/server/src/provider/Layers/CodexProvider.test.ts deleted-173
......@@ -1,173 +0,0 @@
1import { assert, it } from "@effect/vitest";
2
3import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
4
5it("maps current Codex model capability fields", () => {
6 const capabilities = mapCodexModelCapabilities({
7 additionalSpeedTiers: [],
8 defaultReasoningEffort: "super-high",
9 description: "Test model",
10 displayName: "GPT Test",
11 hidden: false,
12 id: "gpt-test",
13 isDefault: true,
14 model: "gpt-test",
15 defaultServiceTier: "flex",
16 serviceTiers: [
17 {
18 id: "priority",
19 name: "Fast",
20 description: "Lower latency responses.",
21 },
22 {
23 id: "flex",
24 name: "Flex",
25 description: "Lower-cost asynchronous routing.",
26 },
27 ],
28 supportedReasoningEfforts: [
29 {
30 description: "Maximum reasoning",
31 reasoningEffort: "super-high",
32 },
33 ],
34 });
35
36 assert.deepStrictEqual(capabilities.optionDescriptors, [
37 {
38 id: "reasoningEffort",
39 label: "Reasoning",
40 type: "select",
41 options: [{ id: "super-high", label: "super-high", isDefault: true }],
42 currentValue: "super-high",
43 },
44 {
45 id: "serviceTier",
46 label: "Service Tier",
47 type: "select",
48 options: [
49 { id: "default", label: "Standard" },
50 {
51 id: "priority",
52 label: "Fast",
53 description: "Lower latency responses.",
54 },
55 {
56 id: "flex",
57 label: "Flex",
58 description: "Lower-cost asynchronous routing.",
59 isDefault: true,
60 },
61 ],
62 currentValue: "flex",
63 },
64 ]);
65});
66
67it("uses standard routing when the catalog has no default service tier", () => {
68 const capabilities = mapCodexModelCapabilities({
69 additionalSpeedTiers: ["fast"],
70 defaultReasoningEffort: "medium",
71 defaultServiceTier: null,
72 description: "Test model",
73 displayName: "GPT Test",
74 hidden: false,
75 id: "gpt-test",
76 isDefault: true,
77 model: "gpt-test",
78 serviceTiers: [
79 {
80 id: "priority",
81 name: "Fast",
82 description: "1.5x speed, increased usage",
83 },
84 {
85 id: "ultrafast",
86 name: "Ultrafast",
87 description: "The fastest available responses for latency-sensitive work.",
88 },
89 ],
90 supportedReasoningEfforts: [],
91 });
92
93 assert.deepStrictEqual(capabilities.optionDescriptors, [
94 {
95 id: "serviceTier",
96 label: "Service Tier",
97 type: "select",
98 options: [
99 { id: "default", label: "Standard", isDefault: true },
100 {
101 id: "priority",
102 label: "Fast",
103 description: "1.5x speed, increased usage",
104 },
105 {
106 id: "ultrafast",
107 label: "Ultrafast",
108 description: "Even faster, more expensive",
109 },
110 ],
111 currentValue: "default",
112 },
113 ]);
114});
115
116it("marks the most preferred available model as default", () => {
117 const models = applyPreferredCodexDefaultModel([
118 { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null },
119 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null },
120 ]);
121
122 assert.deepStrictEqual(
123 models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })),
124 [
125 { slug: "gpt-5.6-terra", isDefault: true },
126 { slug: "gpt-5.4", isDefault: undefined },
127 ],
128 );
129});
130
131it("prefers sol over terra when both are available", () => {
132 const models = applyPreferredCodexDefaultModel([
133 { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null },
134 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
135 ]);
136
137 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol");
138});
139
140it("ranks qualified Codex models while preserving their wire ids", () => {
141 const models = applyPreferredCodexDefaultModel([
142 {
143 slug: "openai.gpt-5.6-luna",
144 name: "Luna",
145 isCustom: false,
146 isDefault: true,
147 capabilities: null,
148 },
149 { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null },
150 ]);
151 assert.deepStrictEqual(
152 models.filter((model) => model.isDefault).map((model) => model.slug),
153 ["openai.gpt-5.6-sol"],
154 );
155});
156
157it("keeps Codex's own default when no preferred model is available", () => {
158 const models = applyPreferredCodexDefaultModel([
159 { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null },
160 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null },
161 ]);
162
163 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4");
164});
165
166it("ignores custom models that shadow a preferred slug", () => {
167 const models = applyPreferredCodexDefaultModel([
168 { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null },
169 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null },
170 ]);
171
172 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4");
173});
apps/server/src/provider/Layers/CodexProvider.ts deleted-723
......@@ -1,723 +0,0 @@
1import * as DateTime from "effect/DateTime";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import * as Scope from "effect/Scope";
9import * as Types from "effect/Types";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import * as CodexClient from "effect-codex-app-server/client";
13import * as CodexSchema from "effect-codex-app-server/schema";
14import * as CodexErrors from "effect-codex-app-server/errors";
15
16import type {
17 CodexSettings,
18 CustomModelSetting,
19 ServerProvider,
20 ServerProviderState,
21 ModelCapabilities,
22 ProviderOptionDescriptor,
23 ServerProviderModel,
24 ServerProviderSkill,
25} from "@t3tools/contracts";
26import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
27
28import {
29 codexModelFamily,
30 createModelCapabilities,
31 formatCodexModelName,
32 readCustomModelEntries,
33} from "@t3tools/shared/model";
34import { resolveSpawnCommand } from "@t3tools/shared/shell";
35import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
36import {
37 AUTH_PROBE_TIMEOUT_MS,
38 buildServerProvider,
39 COMPACT_SLASH_COMMAND,
40 type ServerProviderDraft,
41} from "../providerSnapshot.ts";
42import { expandHomePath } from "../../pathExpansion.ts";
43import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
44import {
45 codexRateLimitsFailureMessage,
46 codexRateLimitsToLimits,
47 type CodexRateLimitSnapshot,
48 type CodexResetCreditsSummary,
49} from "./codexUsageLimits.ts";
50import packageJson from "../../../package.json" with { type: "json" };
51const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
52const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
53
54type CodexRateLimitsProbe =
55 | {
56 readonly snapshot: CodexRateLimitSnapshot;
57 readonly rateLimitsByLimitId?:
58 | Readonly<Record<string, CodexRateLimitSnapshot>>
59 | null
60 | undefined;
61 readonly resetCredits: CodexResetCreditsSummary | null | undefined;
62 }
63 | { readonly failure: string };
64
65const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
66
67const CODEX_PRESENTATION = {
68 displayName: "Codex",
69 showInteractionModeToggle: true,
70 reportsContextWindow: true,
71} as const;
72
73export interface CodexAppServerProviderSnapshot {
74 readonly account: CodexSchema.V2GetAccountResponse;
75 readonly rateLimits?: CodexRateLimitsProbe;
76 readonly version: string | undefined;
77 readonly models: ReadonlyArray<ServerProviderModel>;
78 readonly skills: ReadonlyArray<ServerProviderSkill>;
79}
80
81const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
82 none: "None",
83 minimal: "Minimal",
84 low: "Low",
85 medium: "Medium",
86 high: "High",
87 xhigh: "Extra High",
88 max: "Max",
89 ultra: "Ultra",
90};
91
92const DEFAULT_SERVICE_TIER_ID = "default";
93
94/** Shorter copy for tiers whose catalog description wraps in the traits menu. */
95const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = {
96 ultrafast: "Even faster, more expensive",
97};
98
99function reasoningEffortLabel(reasoningEffort: string): string {
100 return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
101}
102
103function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) {
104 if (!account) return undefined;
105 if (account.type === "apiKey") return "OpenAI API Key";
106 if (account.type === "amazonBedrock") return "Amazon Bedrock";
107 if (account.type !== "chatgpt") return undefined;
108 return codexPlanLabel(account.planType);
109}
110
111/** Shared with usage-limit sources, which report the same `planType` slugs. */
112export function codexPlanLabel(planType: string | null | undefined): string | undefined {
113 switch (planType) {
114 case "free":
115 return "ChatGPT Free Subscription";
116 case "go":
117 return "ChatGPT Go Subscription";
118 case "plus":
119 return "ChatGPT Plus Subscription";
120 case "pro":
121 return "ChatGPT Pro 20x Subscription";
122 case "prolite":
123 return "ChatGPT Pro 5x Subscription";
124 case "promax":
125 return "ChatGPT Pro Max Subscription";
126 case "team":
127 return "ChatGPT Team Subscription";
128 case "self_serve_business_prolite":
129 case "self_serve_business_usage_based":
130 case "business":
131 return "ChatGPT Business Subscription";
132 case "ent26":
133 case "enterprise_cbp_automation":
134 case "enterprise_cbp_usage_based":
135 case "enterprise":
136 return "ChatGPT Enterprise Subscription";
137 case "edu":
138 case "edu_plus":
139 case "edu_pro":
140 return "ChatGPT Edu Subscription";
141 case "unknown":
142 return "ChatGPT Subscription";
143 default:
144 return undefined;
145 }
146}
147
148function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) {
149 if (!account || account.type !== "chatgpt") return undefined;
150 return account.email;
151}
152
153export function mapCodexModelCapabilities(
154 model: CodexSchema.V2ModelListResponse__Model,
155): ModelCapabilities {
156 const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) =>
157 reasoningEffort ===
158 (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort)
159 ? {
160 id: reasoningEffort,
161 label: reasoningEffortLabel(reasoningEffort),
162 isDefault: true,
163 }
164 : {
165 id: reasoningEffort,
166 label: reasoningEffortLabel(reasoningEffort),
167 },
168 );
169 const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id;
170 const serviceTiers =
171 model.serviceTiers && model.serviceTiers.length > 0
172 ? model.serviceTiers
173 : (model.additionalSpeedTiers ?? []).map((id) => ({
174 id,
175 name: id === "fast" ? "Fast" : id,
176 description: "",
177 }));
178 const catalogDefaultServiceTier = serviceTiers.some(
179 (tier) => tier.id === model.defaultServiceTier,
180 )
181 ? model.defaultServiceTier
182 : null;
183 const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID;
184 const optionDescriptors: ProviderOptionDescriptor[] = [];
185
186 if (reasoningOptions.length > 0) {
187 optionDescriptors.push({
188 id: "reasoningEffort",
189 label: "Reasoning",
190 type: "select",
191 options: reasoningOptions,
192 ...(defaultReasoning ? { currentValue: defaultReasoning } : {}),
193 });
194 }
195 if (serviceTiers.length > 0) {
196 optionDescriptors.push({
197 id: "serviceTier",
198 label: "Service Tier",
199 type: "select",
200 options: [
201 {
202 id: DEFAULT_SERVICE_TIER_ID,
203 label: "Standard",
204 ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}),
205 },
206 ...serviceTiers.map((tier) => {
207 const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description;
208 return {
209 id: tier.id,
210 label: tier.name,
211 ...(description ? { description } : {}),
212 ...(defaultServiceTier === tier.id ? { isDefault: true } : {}),
213 };
214 }),
215 ],
216 currentValue: defaultServiceTier,
217 });
218 }
219
220 return createModelCapabilities({
221 optionDescriptors,
222 });
223}
224
225function parseCodexModelListResponse(
226 response: CodexSchema.V2ModelListResponse,
227): ReadonlyArray<ServerProviderModel> {
228 return response.data.map((model) => ({
229 slug: model.model,
230 name: formatCodexModelName(model.displayName),
231 isCustom: false,
232 ...(model.isDefault ? { isDefault: true } : {}),
233 capabilities: mapCodexModelCapabilities(model),
234 }));
235}
236
237/**
238 * Prefer our own default-model ranking when one of the preferred slugs is in
239 * the live catalog; otherwise keep whatever Codex itself flagged as default.
240 */
241export function applyPreferredCodexDefaultModel(
242 models: ReadonlyArray<ServerProviderModel>,
243): ReadonlyArray<ServerProviderModel> {
244 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) =>
245 models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug),
246 )[0]?.slug;
247 if (!preferredSlug) {
248 return models;
249 }
250 return models.map((model) => {
251 if (model.slug === preferredSlug) {
252 return model.isDefault ? model : { ...model, isDefault: true };
253 }
254 if (!model.isDefault) {
255 return model;
256 }
257 const { isDefault: _isDefault, ...rest } = model;
258 return rest;
259 });
260}
261
262/**
263 * Codex has no static default capability set, so a bare custom slug borrows
264 * the first built-in's descriptors; an entry with its own capabilities keeps
265 * them.
266 */
267function appendCustomCodexModels(
268 models: ReadonlyArray<ServerProviderModel>,
269 customModels: ReadonlyArray<CustomModelSetting>,
270): ReadonlyArray<ServerProviderModel> {
271 if (customModels.length === 0) {
272 return models;
273 }
274
275 const seen = new Set(models.map((model) => model.slug));
276 const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
277 const customEntries: ServerProviderModel[] = [];
278 for (const entry of readCustomModelEntries(customModels)) {
279 if (seen.has(entry.slug)) {
280 continue;
281 }
282 seen.add(entry.slug);
283 customEntries.push({
284 slug: entry.slug,
285 name: entry.name,
286 isCustom: true,
287 capabilities: entry.capabilities ?? fallbackCapabilities,
288 });
289 }
290 return customEntries.length === 0 ? models : [...models, ...customEntries];
291}
292
293function parseCodexSkillsListResponse(
294 response: CodexSchema.V2SkillsListResponse,
295 cwd: string,
296): ReadonlyArray<ServerProviderSkill> {
297 const matchingEntry = response.data.find((entry) => entry.cwd === cwd);
298 const skills = matchingEntry
299 ? matchingEntry.skills
300 : response.data.flatMap((entry) => entry.skills);
301
302 return skills.map((skill) => {
303 const shortDescription =
304 skill.shortDescription ?? skill.interface?.shortDescription ?? undefined;
305
306 const parsedSkill: Types.Mutable<ServerProviderSkill> = {
307 name: skill.name,
308 path: skill.path,
309 enabled: skill.enabled,
310 };
311
312 if (skill.description) {
313 parsedSkill.description = skill.description;
314 }
315 if (skill.scope) {
316 parsedSkill.scope = skill.scope;
317 }
318 if (skill.interface?.displayName) {
319 parsedSkill.displayName = skill.interface.displayName;
320 }
321 if (shortDescription) {
322 parsedSkill.shortDescription = shortDescription;
323 }
324
325 return parsedSkill;
326 });
327}
328
329const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
330 client: CodexClient.CodexAppServerClient["Service"],
331) {
332 const models: ServerProviderModel[] = [];
333 let cursor: string | null | undefined = undefined;
334
335 do {
336 const response: CodexSchema.V2ModelListResponse = yield* client.request(
337 "model/list",
338 cursor ? { cursor } : {},
339 );
340 models.push(...parseCodexModelListResponse(response));
341 cursor = response.nextCursor;
342 } while (cursor);
343
344 return models;
345});
346
347export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
348 return {
349 clientInfo: {
350 name: "T3 Code",
351 title: "T3 Code",
352 version: packageJson.version,
353 },
354 capabilities: {
355 experimentalApi: true,
356 },
357 };
358}
359
360/**
361 * Spawns a short-lived `codex app-server`, runs the initialize handshake, and
362 * hands the caller a connected client. Scoped: the process is killed when
363 * the caller's scope closes. Shared by the status probe, the skills probe,
364 * and account-level requests such as reset-credit redemption.
365 */
366export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: {
367 readonly binaryPath: string;
368 readonly homePath?: string | undefined;
369 readonly launchArgs?: string | undefined;
370 readonly cwd: string;
371 readonly environment?: NodeJS.ProcessEnv | undefined;
372}) {
373 // `~` is not shell-expanded when env vars are set via `child_process.spawn`,
374 // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
375 // "CODEX_HOME points to '~/.codex_work', but that path does not exist".
376 // Expand here for parity with `CodexTextGeneration`.
377 const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
378 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
379 const environment = {
380 ...input.environment,
381 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
382 };
383 const spawnCommand = yield* resolveSpawnCommand(
384 input.binaryPath,
385 codexAppServerArgs(input.launchArgs),
386 { env: environment, extendEnv: true },
387 );
388 const child = yield* spawner
389 .spawn(
390 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
391 cwd: input.cwd,
392 env: environment,
393 extendEnv: true,
394 forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
395 shell: spawnCommand.shell,
396 }),
397 )
398 .pipe(
399 Effect.mapError(
400 (cause) =>
401 new CodexErrors.CodexAppServerSpawnError({
402 command: `${input.binaryPath} app-server`,
403 cause,
404 }),
405 ),
406 );
407 const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
408 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
409 Effect.provide(clientContext),
410 );
411 const initialize = yield* client.request("initialize", buildCodexInitializeParams());
412 yield* client.notify("initialized", undefined);
413 return { client, initialize };
414});
415
416const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
417 readonly binaryPath: string;
418 readonly homePath?: string;
419 readonly launchArgs?: string;
420 readonly cwd: string;
421 readonly customModels?: ReadonlyArray<CustomModelSetting>;
422 readonly environment?: NodeJS.ProcessEnv;
423 readonly skipNativeUsage?: boolean;
424}) {
425 const { client, initialize } = yield* withCodexAppServerClient(input);
426
427 // Extract the version string after the first '/' in userAgent, up to the next space or the end
428 const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
429 const version = versionMatch ? versionMatch[1] : undefined;
430
431 const accountResponse = yield* client.request("account/read", {});
432 if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
433 return {
434 account: accountResponse,
435 version,
436 models: appendCustomCodexModels([], input.customModels ?? []),
437 skills: [],
438 } satisfies CodexAppServerProviderSnapshot;
439 }
440
441 const [skillsResponse, models, rateLimits] = yield* Effect.all(
442 [
443 client.request("skills/list", {
444 cwds: [input.cwd],
445 }),
446 requestAllCodexModels(client),
447 // Usage is an enrichment: a failure or a slow answer degrades to "no
448 // usage this probe" rather than costing the account and models.
449 input.skipNativeUsage
450 ? Effect.succeed(undefined)
451 : client.request("account/rateLimits/read", null).pipe(
452 Effect.map((response): CodexRateLimitsProbe => ({
453 snapshot: response.rateLimits,
454 rateLimitsByLimitId: response.rateLimitsByLimitId,
455 resetCredits: response.rateLimitResetCredits,
456 })),
457 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
458 Effect.map(
459 Option.getOrElse((): CodexRateLimitsProbe => ({
460 failure: "Codex did not answer the usage request.",
461 })),
462 ),
463 Effect.catch((error) =>
464 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
465 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
466 ),
467 ),
468 ),
469 ],
470 { concurrency: "unbounded" },
471 );
472
473 return {
474 account: accountResponse,
475 ...(rateLimits ? { rateLimits } : {}),
476 version,
477 models: applyPreferredCodexDefaultModel(
478 appendCustomCodexModels(models, input.customModels ?? []),
479 ),
480 skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
481 } satisfies CodexAppServerProviderSnapshot;
482});
483
484export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: {
485 readonly binaryPath: string;
486 readonly homePath?: string;
487 readonly launchArgs?: string;
488 readonly cwd: string;
489 readonly environment?: NodeJS.ProcessEnv;
490}) {
491 const { client } = yield* withCodexAppServerClient(input);
492 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
493 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
494});
495
496const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
497 appendCustomCodexModels([], codexSettings.customModels);
498
499const makePendingCodexProvider = (
500 codexSettings: CodexSettings,
501): Effect.Effect<ServerProviderDraft> =>
502 Effect.gen(function* () {
503 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
504 const models = emptyCodexModelsFromSettings(codexSettings);
505
506 if (!codexSettings.enabled) {
507 return buildServerProvider({
508 presentation: CODEX_PRESENTATION,
509 enabled: false,
510 checkedAt,
511 models,
512 skills: [],
513 probe: {
514 installed: false,
515 version: null,
516 status: "warning",
517 auth: { status: "unknown" },
518 message: "Codex is disabled in T3 Code settings.",
519 },
520 });
521 }
522
523 return buildServerProvider({
524 presentation: CODEX_PRESENTATION,
525 enabled: true,
526 checkedAt,
527 models,
528 skills: [],
529 probe: {
530 installed: false,
531 version: null,
532 status: "warning",
533 auth: { status: "unknown" },
534 message: "Codex provider status has not been checked in this session yet.",
535 },
536 });
537 });
538
539function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): {
540 readonly status: Exclude<ServerProviderState, "disabled">;
541 readonly auth: ServerProvider["auth"];
542 readonly message?: string;
543} {
544 const authLabel = codexAccountAuthLabel(account.account);
545 const authEmail = codexAccountEmail(account.account);
546 const auth = {
547 status: account.account ? ("authenticated" as const) : ("unknown" as const),
548 ...(account.account?.type ? { type: account.account?.type } : {}),
549 ...(authLabel ? { label: authLabel } : {}),
550 ...(authEmail ? { email: authEmail } : {}),
551 } satisfies ServerProvider["auth"];
552
553 if (account.account) {
554 return { status: "ready", auth };
555 }
556
557 if (account.requiresOpenaiAuth) {
558 return {
559 status: "error",
560 auth: { status: "unauthenticated" },
561 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
562 };
563 }
564
565 return { status: "ready", auth };
566}
567
568export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
569 codexSettings: CodexSettings,
570 probe: (input: {
571 readonly binaryPath: string;
572 readonly homePath?: string;
573 readonly launchArgs?: string;
574 readonly cwd: string;
575 readonly customModels: ReadonlyArray<CustomModelSetting>;
576 readonly environment?: NodeJS.ProcessEnv;
577 readonly skipNativeUsage?: boolean;
578 }) => Effect.Effect<
579 CodexAppServerProviderSnapshot,
580 CodexErrors.CodexAppServerError,
581 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
582 > = probeCodexAppServerProvider,
583 environment?: NodeJS.ProcessEnv,
584 managedAuth?: ServerProvider["auth"],
585): Effect.fn.Return<
586 ServerProviderDraft,
587 ServerSettingsError,
588 ChildProcessSpawner.ChildProcessSpawner
589> {
590 const resolvedEnvironment = environment ?? process.env;
591 const checkedAt = DateTime.formatIso(yield* DateTime.now);
592 const emptyModels = emptyCodexModelsFromSettings(codexSettings);
593
594 if (!codexSettings.enabled) {
595 return buildServerProvider({
596 presentation: CODEX_PRESENTATION,
597 enabled: false,
598 checkedAt,
599 models: emptyModels,
600 skills: [],
601 probe: {
602 installed: false,
603 version: null,
604 status: "warning",
605 auth: { status: "unknown" },
606 message: "Codex is disabled in T3 Code settings.",
607 },
608 });
609 }
610
611 const probeResult = yield* probe({
612 binaryPath: codexSettings.binaryPath,
613 homePath: codexSettings.homePath,
614 launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
615 cwd: process.cwd(),
616 customModels: codexSettings.customModels,
617 environment: resolvedEnvironment,
618 ...(managedAuth ? { skipNativeUsage: true } : {}),
619 }).pipe(
620 Effect.scoped,
621 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
622 Effect.result,
623 );
624
625 if (Result.isFailure(probeResult)) {
626 const error = probeResult.failure;
627 const installed = !isCodexAppServerSpawnError(error);
628 return buildServerProvider({
629 presentation: CODEX_PRESENTATION,
630 enabled: codexSettings.enabled,
631 checkedAt,
632 models: emptyModels,
633 skills: [],
634 probe: {
635 installed,
636 version: null,
637 status: "error",
638 auth: { status: "unknown" },
639 message: installed
640 ? `Codex app-server provider probe failed: ${error.message}.`
641 : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` +
642 (codexSettings.binaryPath === "codex"
643 ? " Installing ChatGPT or Codex desktop may not add codex to PATH."
644 : " Make sure the configured executable exists and can be run."),
645 },
646 });
647 }
648
649 if (Option.isNone(probeResult.success)) {
650 return buildServerProvider({
651 presentation: CODEX_PRESENTATION,
652 enabled: codexSettings.enabled,
653 checkedAt,
654 models: emptyModels,
655 skills: [],
656 probe: {
657 installed: true,
658 version: null,
659 status: "error",
660 auth: { status: "unknown" },
661 message: "Timed out while checking Codex app-server provider status.",
662 },
663 });
664 }
665
666 const snapshot = probeResult.success.value;
667 const accountStatus = managedAuth
668 ? { status: "ready" as const, auth: managedAuth, message: undefined }
669 : accountProbeStatus(snapshot.account);
670 const usageLimits =
671 snapshot.account.account?.type === "apiKey"
672 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
673 : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits
674 ? makeUnavailableUsageLimits({
675 checkedAt,
676 reason: "probeFailed",
677 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),
678 })
679 : codexRateLimitsToLimits({
680 snapshot: snapshot.rateLimits.snapshot,
681 rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId,
682 resetCredits: snapshot.rateLimits.resetCredits,
683 checkedAt,
684 });
685
686 return buildServerProvider({
687 presentation: CODEX_PRESENTATION,
688 enabled: codexSettings.enabled,
689 checkedAt,
690 models: snapshot.models,
691 skills: snapshot.skills,
692 slashCommands: [
693 COMPACT_SLASH_COMMAND,
694 {
695 name: "goal",
696 description: "Set a goal Codex keeps working toward until it is done",
697 input: { hint: "Objective, or pause, resume, clear" },
698 },
699 {
700 name: "feedback",
701 description: "Send this thread and Codex logs to OpenAI",
702 input: { hint: "Describe the issue (optional)" },
703 },
704 ],
705 probe: {
706 installed: true,
707 version: snapshot.version ?? null,
708 status: accountStatus.status,
709 auth: accountStatus.auth,
710 ...(accountStatus.message ? { message: accountStatus.message } : {}),
711 ...(managedAuth ? {} : { usageLimits }),
712 },
713 });
714});
715
716// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of
717// the per-instance-driver refactor. `CodexDriver.create()` builds a managed
718// snapshot per instance (each with its own `CodexSettings`) and hands the
719// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`.
720//
721// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are
722// re-exported for use by `CodexDriver`.
723export { makePendingCodexProvider };
apps/server/src/provider/Layers/CursorProvider.test.ts deleted-252
......@@ -1,252 +0,0 @@
1import type { SDKModel } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import type { CursorSettings } from "@t3tools/contracts";
6import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts";
7import { createModelCapabilities } from "@t3tools/shared/model";
8
9import {
10 buildCursorCapabilitiesFromSdkModel,
11 buildCursorDiscoveredModelsFromSdk,
12 buildCursorProviderSnapshot,
13 buildInitialCursorProviderSnapshot,
14 checkCursorProviderStatus,
15} from "./CursorProvider.ts";
16import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
17
18const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema);
19
20function selectDescriptor(
21 id: string,
22 label: string,
23 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
24) {
25 return {
26 id,
27 label,
28 type: "select" as const,
29 options: [...options],
30 ...(options.find((option) => option.isDefault)?.id
31 ? { currentValue: options.find((option) => option.isDefault)?.id }
32 : {}),
33 };
34}
35
36function booleanDescriptor(id: string, label: string, currentValue?: boolean) {
37 return {
38 id,
39 label,
40 type: "boolean" as const,
41 ...(typeof currentValue === "boolean" ? { currentValue } : {}),
42 };
43}
44
45const baseCursorSettings: CursorSettings = decodeCursorSettings({
46 enabled: true,
47 customModels: [],
48});
49
50const sdkParameterizedModel = {
51 id: "claude-opus-4-8",
52 displayName: "Opus 4.8",
53 parameters: [
54 {
55 id: "thinking",
56 displayName: "Thinking",
57 values: [{ value: "false" }, { value: "true" }],
58 },
59 {
60 id: "context",
61 displayName: "Context",
62 values: [
63 { value: "300k", displayName: "300K" },
64 { value: "1m", displayName: "1M" },
65 ],
66 },
67 {
68 id: "effort",
69 displayName: "Effort",
70 values: [
71 { value: "low", displayName: "Low" },
72 { value: "high", displayName: "High" },
73 ],
74 },
75 {
76 id: "fast",
77 displayName: "Fast",
78 values: [{ value: "false" }, { value: "true", displayName: "Fast" }],
79 },
80 ],
81 variants: [
82 {
83 displayName: "Opus 4.8",
84 isDefault: true,
85 params: [
86 { id: "thinking", value: "true" },
87 { id: "context", value: "1m" },
88 { id: "effort", value: "high" },
89 { id: "fast", value: "false" },
90 ],
91 },
92 ],
93} satisfies SDKModel;
94
95describe("buildInitialCursorProviderSnapshot", () => {
96 it.effect("uses SDK-specific pending status copy", () =>
97 Effect.gen(function* () {
98 const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings);
99
100 expect(provider).toMatchObject({
101 status: "warning",
102 message: "Checking Cursor SDK availability...",
103 });
104 }),
105 );
106});
107
108describe("buildCursorProviderSnapshot", () => {
109 it("downgrades ready status to warning when SDK model discovery returns no models", () => {
110 expect(
111 buildCursorProviderSnapshot({
112 checkedAt: "2026-01-01T00:00:00.000Z",
113 cursorSettings: baseCursorSettings,
114 parsed: {
115 version: null,
116 status: "ready",
117 auth: { status: "authenticated", type: "api-key", label: "Cursor API key" },
118 },
119 discoveryWarning: "Cursor SDK model discovery returned no built-in models.",
120 }),
121 ).toMatchObject({
122 status: "warning",
123 message: "Cursor SDK model discovery returned no built-in models.",
124 models: [],
125 supportsConversationRollback: false,
126 });
127 });
128});
129
130describe("Cursor SDK model discovery", () => {
131 it("maps native SDK parameter ids and default variant values to model capabilities", () => {
132 expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual(
133 createModelCapabilities({
134 optionDescriptors: [
135 selectDescriptor("effort", "Effort", [
136 { id: "low", label: "Low" },
137 { id: "high", label: "High", isDefault: true },
138 ]),
139 selectDescriptor("contextWindow", "Context", [
140 { id: "300k", label: "300K" },
141 { id: "1m", label: "1M", isDefault: true },
142 ]),
143 booleanDescriptor("fastMode", "Fast", false),
144 booleanDescriptor("thinking", "Thinking", true),
145 ],
146 }),
147 );
148 });
149
150 it("filters invalid and duplicate SDK model entries", () => {
151 expect(
152 buildCursorDiscoveredModelsFromSdk([
153 sdkParameterizedModel,
154 { ...sdkParameterizedModel, displayName: "Duplicate" },
155 { id: "", displayName: "Invalid" },
156 ]),
157 ).toEqual([
158 {
159 slug: "claude-opus-4-8",
160 name: "Opus 4.8",
161 isCustom: false,
162 capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel),
163 },
164 ]);
165 });
166});
167
168describe("checkCursorProviderStatus", () => {
169 it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () =>
170 Effect.gen(function* () {
171 const provider = yield* checkCursorProviderStatus(
172 {
173 ...baseCursorSettings,
174 customModels: ["internal/cursor-model"],
175 },
176 { CURSOR_API_KEY: "test-cursor-key" },
177 ).pipe(
178 Effect.provide(
179 CursorSdkCatalog.layerTest((apiKey) => {
180 expect(apiKey).toBe("test-cursor-key");
181 return Effect.succeed({
182 user: {
183 apiKeyName: "test-key",
184 userEmail: "cursor@example.com",
185 createdAt: "2026-01-01T00:00:00.000Z",
186 },
187 models: [sdkParameterizedModel],
188 });
189 }),
190 ),
191 );
192
193 expect(provider).toMatchObject({
194 status: "ready",
195 auth: {
196 status: "authenticated",
197 type: "api-key",
198 label: "Cursor API key (test-key)",
199 email: "cursor@example.com",
200 },
201 models: [
202 { slug: "claude-opus-4-8", isCustom: false },
203 { slug: "internal/cursor-model", isCustom: true },
204 ],
205 });
206 }),
207 );
208
209 it.effect("surfaces SDK authentication failures", () =>
210 Effect.gen(function* () {
211 const provider = yield* checkCursorProviderStatus(baseCursorSettings, {
212 CURSOR_API_KEY: "invalid-test-key",
213 }).pipe(
214 Effect.provide(
215 CursorSdkCatalog.layerTest(() =>
216 Effect.fail(
217 new CursorSdkCatalog.CursorSdkCatalogError({
218 authenticationFailure: true,
219 cause: new Error("unauthorized"),
220 }),
221 ),
222 ),
223 ),
224 );
225
226 expect(provider).toMatchObject({
227 status: "error",
228 auth: { status: "unauthenticated" },
229 message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.",
230 });
231 }),
232 );
233
234 it.effect("requires a Cursor API key without probing any external Cursor binary", () =>
235 Effect.gen(function* () {
236 const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe(
237 Effect.provide(
238 CursorSdkCatalog.layerTest(() =>
239 Effect.die("SDK catalog must not be used without CURSOR_API_KEY"),
240 ),
241 ),
242 );
243
244 expect(provider).toMatchObject({
245 installed: true,
246 status: "error",
247 auth: { status: "unauthenticated" },
248 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
249 });
250 }),
251 );
252});
apps/server/src/provider/Layers/CursorProvider.ts deleted-350
......@@ -1,350 +0,0 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import type {
3 CursorSettings,
4 ModelCapabilities,
5 ProviderOptionDescriptor,
6 ServerProviderAuth,
7 ServerProviderModel,
8 ServerProviderState,
9} from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Result from "effect/Result";
15
16import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "../cursorSdkModel.ts";
17import {
18 buildBooleanOptionDescriptor,
19 buildSelectOptionDescriptor,
20 buildServerProvider,
21 providerModelsFromSettings,
22 type ServerProviderDraft,
23} from "../providerSnapshot.ts";
24import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
25
26const CURSOR_PRESENTATION = {
27 displayName: "Cursor",
28 supportsConversationRollback: false,
29 showInteractionModeToggle: true,
30} as const;
31const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
32 optionDescriptors: [],
33});
34
35const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000;
36
37export function buildInitialCursorProviderSnapshot(
38 cursorSettings: CursorSettings,
39): Effect.Effect<ServerProviderDraft> {
40 return Effect.gen(function* () {
41 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
42 const models = getCursorFallbackModels(cursorSettings);
43
44 if (!cursorSettings.enabled) {
45 return buildServerProvider({
46 presentation: CURSOR_PRESENTATION,
47 enabled: false,
48 checkedAt,
49 models,
50 probe: {
51 installed: false,
52 version: null,
53 status: "warning",
54 auth: { status: "unknown" },
55 message: "Cursor is disabled in T3 Code settings.",
56 },
57 });
58 }
59
60 return buildServerProvider({
61 presentation: CURSOR_PRESENTATION,
62 enabled: true,
63 checkedAt,
64 models,
65 probe: {
66 installed: true,
67 version: null,
68 status: "warning",
69 auth: { status: "unknown" },
70 message: "Checking Cursor SDK availability...",
71 },
72 });
73 });
74}
75
76function getCursorFallbackModels(
77 cursorSettings: Pick<CursorSettings, "customModels">,
78): ReadonlyArray<ServerProviderModel> {
79 return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES);
80}
81
82function toTitleCaseWords(value: string): string {
83 const parts: Array<string> = [];
84 for (const part of value.split(/[\s_-]+/g)) {
85 if (part.length > 0) {
86 parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
87 }
88 }
89 return parts.join(" ");
90}
91
92function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined {
93 return model.variants
94 ?.find((variant) => variant.isDefault)
95 ?.params.find((parameter) => parameter.id === parameterId)?.value;
96}
97
98export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities {
99 const seen = new Set<string>();
100 const optionDescriptors: Array<ProviderOptionDescriptor> = [];
101 const parameters = (model.parameters ?? [])
102 .map((parameter, index) => ({ parameter, index }))
103 .toSorted(
104 (left, right) =>
105 cursorSdkParameterPriority(left.parameter.id) -
106 cursorSdkParameterPriority(right.parameter.id) || left.index - right.index,
107 );
108 for (const { parameter } of parameters) {
109 const nativeId = parameter.id.trim();
110 const id = cursorSdkProviderOptionId(nativeId);
111 if (!nativeId || !id || seen.has(id)) {
112 continue;
113 }
114 seen.add(id);
115
116 const values = parameter.values.flatMap((entry) => {
117 const value = entry.value.trim();
118 if (!value) {
119 return [];
120 }
121 return [
122 {
123 value,
124 label: entry.displayName?.trim() || value,
125 },
126 ];
127 });
128 if (values.length === 0) {
129 continue;
130 }
131
132 const label = parameter.displayName?.trim() || toTitleCaseWords(id);
133 const defaultValue = cursorSdkDefaultParameterValue(model, nativeId);
134 const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase()));
135 if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) {
136 if (defaultValue === "true" || defaultValue === "false") {
137 optionDescriptors.push(
138 buildBooleanOptionDescriptor({
139 id,
140 label,
141 currentValue: defaultValue === "true",
142 }),
143 );
144 } else {
145 optionDescriptors.push(buildBooleanOptionDescriptor({ id, label }));
146 }
147 continue;
148 }
149
150 optionDescriptors.push(
151 buildSelectOptionDescriptor({
152 id,
153 label,
154 options: values.map((entry) => ({
155 ...entry,
156 ...(entry.value === defaultValue ? { isDefault: true } : {}),
157 })),
158 }),
159 );
160 }
161
162 return createModelCapabilities({ optionDescriptors });
163}
164
165export function buildCursorDiscoveredModelsFromSdk(
166 models: ReadonlyArray<SDKModel>,
167): ReadonlyArray<ServerProviderModel> {
168 const seen = new Set<string>();
169 return models.flatMap((model) => {
170 const slug = model.id.trim();
171 const name = model.displayName.trim();
172 if (!slug || !name || seen.has(slug)) {
173 return [];
174 }
175 seen.add(slug);
176 return [
177 {
178 slug,
179 name,
180 isCustom: false,
181 capabilities: buildCursorCapabilitiesFromSdkModel(model),
182 } satisfies ServerProviderModel,
183 ];
184 });
185}
186
187function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth {
188 const email = user.userEmail?.trim();
189 const apiKeyName = user.apiKeyName.trim();
190 return {
191 status: "authenticated",
192 type,
193 label:
194 type === "browser"
195 ? "Cursor account"
196 : apiKeyName
197 ? `Cursor API key (${apiKeyName})`
198 : "Cursor API key",
199 ...(email ? { email } : {}),
200 };
201}
202
203interface CursorProviderProbeResult {
204 readonly version: string | null;
205 readonly status: Exclude<ServerProviderState, "disabled">;
206 readonly auth: ServerProviderAuth;
207 readonly message?: string;
208}
209
210function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
211 const parts: Array<string> = [];
212 for (const message of messages) {
213 const trimmed = message?.trim();
214 if (trimmed) {
215 parts.push(trimmed);
216 }
217 }
218 return parts.length > 0 ? parts.join(" ") : undefined;
219}
220
221export function buildCursorProviderSnapshot(input: {
222 readonly checkedAt: string;
223 readonly cursorSettings: CursorSettings;
224 readonly parsed: CursorProviderProbeResult;
225 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
226 readonly discoveryWarning?: string;
227}): ServerProviderDraft {
228 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
229 return buildServerProvider({
230 presentation: CURSOR_PRESENTATION,
231 enabled: input.cursorSettings.enabled,
232 checkedAt: input.checkedAt,
233 models: providerModelsFromSettings(
234 input.discoveredModels ?? [],
235 input.cursorSettings.customModels,
236 EMPTY_CAPABILITIES,
237 ),
238 probe: {
239 installed: true,
240 version: input.parsed.version,
241 status:
242 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
243 auth: input.parsed.auth,
244 ...(message ? { message } : {}),
245 },
246 });
247}
248
249export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
250 cursorSettings: CursorSettings,
251 environment?: NodeJS.ProcessEnv,
252 authenticationType: "api-key" | "browser" = "api-key",
253): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> {
254 const checkedAt = DateTime.formatIso(yield* DateTime.now);
255 const fallbackModels = getCursorFallbackModels(cursorSettings);
256
257 if (!cursorSettings.enabled) {
258 return buildServerProvider({
259 presentation: CURSOR_PRESENTATION,
260 enabled: false,
261 checkedAt,
262 models: fallbackModels,
263 probe: {
264 installed: false,
265 version: null,
266 status: "warning",
267 auth: { status: "unknown" },
268 message: "Cursor is disabled in T3 Code settings.",
269 },
270 });
271 }
272
273 const sdkApiKey = environment?.CURSOR_API_KEY?.trim();
274 if (!sdkApiKey) {
275 return buildServerProvider({
276 presentation: CURSOR_PRESENTATION,
277 enabled: cursorSettings.enabled,
278 checkedAt,
279 models: fallbackModels,
280 probe: {
281 installed: true,
282 version: null,
283 status: "error",
284 auth: { status: "unauthenticated" },
285 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
286 },
287 });
288 }
289
290 const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog;
291 const catalogResult = yield* sdkCatalog
292 .read(sdkApiKey)
293 .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result);
294
295 if (Result.isFailure(catalogResult)) {
296 yield* Effect.logWarning("Cursor SDK catalog probe failed", {
297 cause: catalogResult.failure.cause,
298 });
299 const authenticationFailure = catalogResult.failure.authenticationFailure;
300 return buildServerProvider({
301 presentation: CURSOR_PRESENTATION,
302 enabled: cursorSettings.enabled,
303 checkedAt,
304 models: fallbackModels,
305 probe: {
306 installed: true,
307 version: null,
308 status: "error",
309 auth: { status: authenticationFailure ? "unauthenticated" : "unknown" },
310 message: authenticationFailure
311 ? authenticationType === "browser"
312 ? "Cursor sign-in expired or was rejected. Sign in again in provider settings."
313 : "Cursor SDK authentication failed. Check CURSOR_API_KEY."
314 : "Cursor SDK catalog request failed. Check server logs for details.",
315 },
316 });
317 }
318
319 if (Option.isNone(catalogResult.success)) {
320 return buildServerProvider({
321 presentation: CURSOR_PRESENTATION,
322 enabled: cursorSettings.enabled,
323 checkedAt,
324 models: fallbackModels,
325 probe: {
326 installed: true,
327 version: null,
328 status: "error",
329 auth: { status: "unknown" },
330 message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`,
331 },
332 });
333 }
334
335 const snapshot = catalogResult.success.value;
336 const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models);
337 return buildCursorProviderSnapshot({
338 checkedAt,
339 cursorSettings,
340 parsed: {
341 version: null,
342 status: "ready",
343 auth: cursorSdkAuth(snapshot.user, authenticationType),
344 },
345 discoveredModels,
346 ...(discoveredModels.length === 0
347 ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." }
348 : {}),
349 });
350});
apps/server/src/provider/Layers/CursorSdkCatalog.test.ts deleted-66
......@@ -1,66 +0,0 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5
6import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
7
8const user = {
9 apiKeyName: "test-key",
10 userEmail: "cursor@example.com",
11 createdAt: "2026-01-01T00:00:00.000Z",
12} satisfies SDKUser;
13
14const model = {
15 id: "claude-opus-4-8",
16 displayName: "Opus 4.8",
17 parameters: [],
18} satisfies SDKModel;
19
20const catalogError = () =>
21 new CursorSdkCatalog.CursorSdkCatalogError({
22 authenticationFailure: false,
23 cause: new Error("catalog unavailable"),
24 });
25
26describe("CursorSdkCatalog", () => {
27 it.effect("keeps the user probe fresh while caching models by API key", () =>
28 Effect.gen(function* () {
29 let userCalls = 0;
30 let modelCalls = 0;
31 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
32 readUser: () => Effect.sync(() => ((userCalls += 1), user)),
33 readModels: () => Effect.sync(() => ((modelCalls += 1), [model])),
34 });
35
36 yield* catalog.read("first-key");
37 yield* catalog.read("first-key");
38 yield* catalog.read("second-key");
39
40 expect(userCalls).toBe(3);
41 expect(modelCalls).toBe(2);
42 }),
43 );
44
45 it.effect("does not cache failed or empty model discovery", () =>
46 Effect.gen(function* () {
47 let modelCalls = 0;
48 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
49 readUser: () => Effect.succeed(user),
50 readModels: () =>
51 Effect.suspend(() => {
52 modelCalls += 1;
53 if (modelCalls === 1) return Effect.fail(catalogError());
54 if (modelCalls === 2) return Effect.succeed([]);
55 return Effect.succeed([model]);
56 }),
57 });
58
59 expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true);
60 expect((yield* catalog.read("test-key")).models).toEqual([]);
61 expect((yield* catalog.read("test-key")).models).toEqual([model]);
62 expect((yield* catalog.read("test-key")).models).toEqual([model]);
63 expect(modelCalls).toBe(3);
64 }),
65 );
66});
apps/server/src/provider/Layers/CursorSdkCatalog.ts deleted-92
......@@ -1,92 +0,0 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10import { AuthenticationError, Cursor, CursorSdkError } from "../cursorSdk.ts";
11
12export interface CursorSdkCatalogSnapshot {
13 readonly user: SDKUser;
14 readonly models: ReadonlyArray<SDKModel>;
15}
16
17export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()(
18 "CursorSdkCatalogError",
19 {
20 authenticationFailure: Schema.Boolean,
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return this.authenticationFailure
26 ? "Cursor SDK authentication failed."
27 : "Cursor SDK catalog request failed.";
28 }
29}
30
31export interface CursorSdkCatalogShape {
32 readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
33}
34
35export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()(
36 "t3/provider/Layers/CursorSdkCatalog",
37) {}
38
39function isAuthenticationFailure(cause: unknown): boolean {
40 return (
41 cause instanceof AuthenticationError ||
42 (cause instanceof CursorSdkError && cause.status === 401)
43 );
44}
45
46export interface CursorSdkCatalogProbes {
47 readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
48 readonly readModels: (
49 apiKey: string,
50 ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>;
51}
52
53const sdkRequest = <A>(tryRequest: () => Promise<A>) =>
54 Effect.tryPromise({
55 try: tryRequest,
56 catch: (cause) =>
57 new CursorSdkCatalogError({
58 authenticationFailure: isAuthenticationFailure(cause),
59 cause,
60 }),
61 });
62
63const liveProbes: CursorSdkCatalogProbes = {
64 readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })),
65 readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })),
66};
67
68export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
69 probes: CursorSdkCatalogProbes = liveProbes,
70) {
71 const modelCache = yield* Cache.makeWith(probes.readModels, {
72 capacity: 32,
73 timeToLive: (exit) =>
74 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
75 });
76 return CursorSdkCatalog.of({
77 read: (apiKey) =>
78 Effect.all(
79 {
80 user: probes.readUser(apiKey),
81 models: Cache.get(modelCache, apiKey),
82 },
83 { concurrency: "unbounded" },
84 ),
85 });
86});
87
88export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog());
89
90export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> {
91 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));
92}
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts deleted-856
......@@ -1,856 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import { ThreadId } from "@t3tools/contracts";
7import { assert, describe, it } from "@effect/vitest";
8import * as Clock from "effect/Clock";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Logger from "effect/Logger";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14
15import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
16import {
17 makeEventNdjsonLogger,
18 makeEventNdjsonLogStore,
19 type PendingRecord,
20 writeBatchedMessages,
21} from "./EventNdjsonLogger.ts";
22
23const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
24const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
25
26function ownedLogPath(basePath: string, segment: string): string {
27 const basename = NodePath.basename(basePath);
28 const extension = NodePath.extname(basename);
29 const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename;
30 return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`);
31}
32
33function parseLogLine(line: string) {
34 const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line);
35 assert.notEqual(match, null);
36 if (!match) {
37 throw new Error(`invalid log line: ${line}`);
38 }
39 const observedAt = match[1];
40 const stream = match[2];
41 const payload = match[3];
42 if (!observedAt || !stream || payload === undefined) {
43 throw new Error(`invalid log line: ${line}`);
44 }
45 return {
46 observedAt,
47 stream,
48 payload,
49 };
50}
51
52describe("EventNdjsonLogger", () => {
53 it.effect("summarizes circular events without exposing their contents in diagnostics", () => {
54 const messages: Array<unknown> = [];
55 const logCapture = Logger.make<unknown, void>(({ message }) => {
56 if (Array.isArray(message)) {
57 messages.push(...message);
58 } else {
59 messages.push(message);
60 }
61 });
62 const secret = "secret-circular-event-value";
63
64 return Effect.gen(function* () {
65 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
66 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
67 const circular: Record<string, unknown> = { secret };
68 circular.self = circular;
69
70 try {
71 const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" });
72 assert.exists(logger);
73 if (!logger) return;
74 yield* logger.write(circular, ThreadId.make("thread-1"));
75 yield* logger.close();
76
77 const serialized = encodeUnknownJson(messages);
78 assert.notInclude(serialized, secret);
79 const line = parseLogLine(
80 NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(),
81 );
82 assert.equal(line.payload, '{"truncated":true}');
83 } finally {
84 NodeFS.rmSync(tempDir, { recursive: true, force: true });
85 }
86 }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false })));
87 });
88
89 it.effect("writes effect-style lines to thread-scoped files", () =>
90 Effect.gen(function* () {
91 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
92 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
93
94 try {
95 const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" });
96 assert.notEqual(logger, undefined);
97 if (!logger) {
98 return;
99 }
100
101 yield* logger.write(
102 { threadId: "provider-thread-1", id: "evt-1" },
103 ThreadId.make("thread-1"),
104 );
105 yield* logger.write(
106 { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" },
107 ThreadId.make("thread-2"),
108 );
109 yield* logger.close();
110
111 const threadOnePath = ownedLogPath(basePath, "thread-1");
112 const threadTwoPath = ownedLogPath(basePath, "thread-2");
113 assert.equal(NodeFS.existsSync(threadOnePath), true);
114 assert.equal(NodeFS.existsSync(threadTwoPath), true);
115
116 const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim());
117 const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim());
118
119 assert.equal(Number.isNaN(Date.parse(first.observedAt)), false);
120 assert.equal(first.stream, "NTIVE");
121 assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}');
122
123 assert.equal(Number.isNaN(Date.parse(second.observedAt)), false);
124 assert.equal(second.stream, "NTIVE");
125 assert.equal(
126 second.payload,
127 '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}',
128 );
129 } finally {
130 NodeFS.rmSync(tempDir, { recursive: true, force: true });
131 }
132 }),
133 );
134
135 it.effect(
136 "falls back to a global segment when orchestration thread id is missing or invalid",
137 () =>
138 Effect.gen(function* () {
139 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
140 const basePath = NodePath.join(tempDir, "provider-canonical.ndjson");
141
142 try {
143 const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" });
144 assert.notEqual(logger, undefined);
145 if (!logger) {
146 return;
147 }
148
149 yield* logger.write({ id: "evt-no-thread" }, null);
150 yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId);
151 yield* logger.close();
152
153 const globalPath = ownedLogPath(basePath, "_global");
154 assert.equal(NodeFS.existsSync(globalPath), true);
155 const lines = NodeFS.readFileSync(globalPath, "utf8")
156 .trim()
157 .split("\n")
158 .map((line) => parseLogLine(line));
159 assert.equal(lines.length, 2);
160 assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false);
161 assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false);
162 assert.equal(lines[0]?.stream, "ORCH");
163 assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}');
164 assert.equal(lines[1]?.stream, "ORCH");
165 assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}');
166 } finally {
167 NodeFS.rmSync(tempDir, { recursive: true, force: true });
168 }
169 }),
170 );
171
172 it.effect("shares one thread writer across native and canonical streams", () =>
173 Effect.gen(function* () {
174 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
175 const basePath = NodePath.join(tempDir, "events.log");
176
177 try {
178 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
179 const native = store.logger("native");
180 const canonical = store.logger("canonical");
181 const threadId = ThreadId.make("thread-shared");
182
183 yield* native.write({ id: "native-event" }, threadId);
184 yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId);
185 yield* store.close();
186
187 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8")
188 .trim()
189 .split("\n")
190 .map(parseLogLine);
191
192 assert.deepEqual(
193 lines.map(({ stream, payload }) => ({ stream, payload })),
194 [
195 { stream: "NTIVE", payload: '{"id":"native-event"}' },
196 {
197 stream: "CANON",
198 payload: '{"type":"item.completed","id":"canonical-event"}',
199 },
200 ],
201 );
202 } finally {
203 NodeFS.rmSync(tempDir, { recursive: true, force: true });
204 }
205 }),
206 );
207
208 it.effect("keeps shared store views non-owning when one adapter closes", () =>
209 Effect.gen(function* () {
210 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
211 const basePath = NodePath.join(tempDir, "events.log");
212
213 try {
214 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
215 const native = store.logger("native");
216 const canonical = store.logger("canonical");
217 const threadId = ThreadId.make("thread-shared-close");
218
219 yield* native.write({ id: "before-close" }, threadId);
220 yield* native.close();
221 yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId);
222 yield* store.close();
223
224 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8")
225 .trim()
226 .split("\n")
227 .map(parseLogLine);
228
229 assert.deepEqual(
230 lines.map(({ stream, payload }) => ({ stream, payload })),
231 [
232 { stream: "NTIVE", payload: '{"id":"before-close"}' },
233 {
234 stream: "CANON",
235 payload: '{"type":"item.completed","id":"after-close"}',
236 },
237 ],
238 );
239 } finally {
240 NodeFS.rmSync(tempDir, { recursive: true, force: true });
241 }
242 }),
243 );
244
245 it.effect("flushes an active batch without a permanent polling loop", () =>
246 Effect.gen(function* () {
247 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
248 const basePath = NodePath.join(tempDir, "events.log");
249 const threadPath = ownedLogPath(basePath, "thread-batched");
250
251 try {
252 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 });
253 yield* store
254 .logger("native")
255 .write({ id: "batched-event" }, ThreadId.make("thread-batched"));
256
257 assert.equal(NodeFS.existsSync(threadPath), false);
258 yield* TestClock.adjust(1_000);
259 assert.equal(NodeFS.existsSync(threadPath), true);
260 yield* store.close();
261 } finally {
262 NodeFS.rmSync(tempDir, { recursive: true, force: true });
263 }
264 }),
265 );
266
267 it.effect("does not strand a later batch after an interrupted write", () =>
268 Effect.gen(function* () {
269 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
270 const basePath = NodePath.join(tempDir, "events.log");
271 const threadPath = ownedLogPath(basePath, "thread-interrupted");
272
273 try {
274 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 });
275 const logger = store.logger("native");
276 const interruptedWrite = yield* logger
277 .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted"))
278 .pipe(Effect.forkChild);
279 yield* Effect.yieldNow;
280 yield* Fiber.interrupt(interruptedWrite);
281 yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted"));
282
283 yield* TestClock.adjust(1_000);
284
285 assert.equal(NodeFS.existsSync(threadPath), true);
286 assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}');
287 yield* store.close();
288 } finally {
289 NodeFS.rmSync(tempDir, { recursive: true, force: true });
290 }
291 }),
292 );
293
294 it.effect("drops transient provider events before serialization", () =>
295 Effect.gen(function* () {
296 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
297 const basePath = NodePath.join(tempDir, "events.log");
298
299 try {
300 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
301 const canonical = store.logger("canonical");
302 const native = store.logger("native");
303 const threadId = ThreadId.make("thread-filtered");
304 const circularDelta: Record<string, unknown> = { type: "content.delta" };
305 circularDelta["self"] = circularDelta;
306
307 yield* canonical.write(circularDelta, threadId);
308 yield* canonical.write({ type: "item.completed", id: "final" }, threadId);
309 yield* native.write({ type: "content.delta", id: "native-delta" }, threadId);
310 yield* native.write(
311 { method: "item/agentMessage/delta", payload: circularDelta },
312 threadId,
313 );
314 yield* native.write(
315 { method: "thread/realtime/outputAudio/delta", payload: circularDelta },
316 threadId,
317 );
318 yield* native.write(
319 { method: "thread/realtime/transcript/delta", payload: circularDelta },
320 threadId,
321 );
322 yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId);
323 yield* native.write(
324 {
325 event: {
326 direction: "incoming",
327 stage: "decoded",
328 payload: {
329 method: "turn/diff/updated",
330 get params() {
331 throw new Error("unused diff snapshots must not be traversed");
332 },
333 },
334 },
335 },
336 threadId,
337 );
338 yield* native.write(
339 {
340 event: {
341 method: "claude/stream_event/content_block_delta/text_delta",
342 payload: circularDelta,
343 },
344 },
345 threadId,
346 );
347 yield* native.write(
348 {
349 event: {
350 method: "session/update",
351 payload: { update: { sessionUpdate: "agent_message_chunk" } },
352 },
353 },
354 threadId,
355 );
356 yield* native.write(
357 {
358 event: {
359 direction: "incoming",
360 stage: "decoded",
361 payload: { method: "item/commandExecution/outputDelta", params: circularDelta },
362 },
363 },
364 threadId,
365 );
366 yield* native.write(
367 {
368 event: {
369 direction: "incoming",
370 stage: "decoded",
371 payload: {
372 type: "stream_event",
373 event: { type: "content_block_delta", delta: circularDelta },
374 },
375 },
376 },
377 threadId,
378 );
379 yield* native.write(
380 {
381 event: {
382 direction: "incoming",
383 stage: "raw",
384 get payload() {
385 throw new Error("raw frames must not be inspected");
386 },
387 },
388 },
389 threadId,
390 );
391 yield* native.write(
392 {
393 event: {
394 type: "message.part.updated",
395 payload: { properties: { part: { type: "text" } } },
396 },
397 },
398 threadId,
399 );
400 yield* native.write(
401 {
402 event: {
403 type: "message.part.updated",
404 payload: {
405 properties: {
406 part: { type: "tool", state: { status: "running", output: "progress" } },
407 },
408 },
409 },
410 },
411 threadId,
412 );
413 yield* native.write({ type: "turn.completed", id: "native-final" }, threadId);
414 yield* store.close();
415
416 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8")
417 .trim()
418 .split("\n")
419 .map(parseLogLine);
420
421 assert.deepEqual(
422 lines.map(({ stream, payload }) => ({ stream, payload })),
423 [
424 { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' },
425 { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' },
426 ],
427 );
428 } finally {
429 NodeFS.rmSync(tempDir, { recursive: true, force: true });
430 }
431 }),
432 );
433
434 it.effect("summarizes large histories without reading their items", () =>
435 Effect.gen(function* () {
436 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
437 const basePath = NodePath.join(tempDir, "events.log");
438 const turns = Array.from({ length: 10_000 });
439 Object.defineProperty(turns, 0, {
440 get: () => {
441 throw new Error("history must not be serialized");
442 },
443 });
444
445 try {
446 const store = yield* makeEventNdjsonLogStore(basePath);
447 yield* store.logger("native").write(
448 {
449 provider: "codex",
450 event: {
451 direction: "incoming",
452 stage: "decoded",
453 payload: { id: 42, result: { thread: { id: "native-thread", turns } } },
454 },
455 },
456 ThreadId.make("large-history"),
457 );
458 yield* store.close();
459
460 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8");
461 assert.isBelow(Buffer.byteLength(contents), 2_048);
462 const record = decodeUnknownJson(parseLogLine(contents.trim()).payload);
463 assert.nestedPropertyVal(record, "event.payload.id", 42);
464 assert.nestedPropertyVal(record, "event.payload.result.thread.id", "native-thread");
465 assert.nestedPropertyVal(record, "event.payload.result.thread.turns.itemCount", 10_000);
466 } finally {
467 NodeFS.rmSync(tempDir, { recursive: true, force: true });
468 }
469 }),
470 );
471
472 it.effect("bounds oversized records while retaining failure details", () =>
473 Effect.gen(function* () {
474 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
475 const basePath = NodePath.join(tempDir, "events.log");
476
477 try {
478 const store = yield* makeEventNdjsonLogStore(basePath);
479 const logger = store.logger("native");
480 const threadId = ThreadId.make("large-error");
481 const failure = {
482 method: "error",
483 params: {
484 threadId: "native-thread",
485 turnId: "native-turn",
486 error: { message: "The provider is unavailable.", code: "overloaded" },
487 output: "x".repeat(128 * 1_024),
488 },
489 };
490 yield* logger.write(failure, threadId);
491 yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId);
492 yield* store.close();
493
494 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8");
495 const records = contents
496 .trim()
497 .split("\n")
498 .map((line) => decodeUnknownJson(parseLogLine(line).payload));
499 assert.isBelow(Buffer.byteLength(contents), 64 * 1_024);
500 assert.equal(records.length, 2);
501 assert.nestedPropertyVal(records[0], "params.threadId", "native-thread");
502 assert.nestedPropertyVal(records[0], "params.turnId", "native-turn");
503 assert.nestedPropertyVal(
504 records[0],
505 "params.error.message",
506 "The provider is unavailable.",
507 );
508 assert.nestedPropertyVal(records[0], "params.error.code", "overloaded");
509 assert.propertyVal(records[1], "id", "escaped");
510 } finally {
511 NodeFS.rmSync(tempDir, { recursive: true, force: true });
512 }
513 }),
514 );
515
516 it.effect("bounds canonical diff snapshots before serializing their duplicate payloads", () =>
517 Effect.gen(function* () {
518 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
519 const basePath = NodePath.join(tempDir, "events.log");
520 const threadId = ThreadId.make("large-diff");
521 const diff = "diff-payload".repeat(128 * 1_024);
522 try {
523 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
524 yield* store.logger("canonical").write(
525 {
526 type: "turn.diff.updated",
527 threadId,
528 turnId: "native-turn",
529 raw: { method: "turn/diff/updated", payload: { diff } },
530 payload: { unifiedDiff: diff },
531 },
532 threadId,
533 );
534 yield* store.close();
535 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8");
536 assert.isBelow(Buffer.byteLength(contents), 2_048);
537 const record = decodeUnknownJson(parseLogLine(contents.trim()).payload);
538 assert.propertyVal(record, "type", "turn.diff.updated");
539 assert.propertyVal(record, "threadId", threadId);
540 assert.propertyVal(record, "turnId", "native-turn");
541 } finally {
542 NodeFS.rmSync(tempDir, { recursive: true, force: true });
543 }
544 }),
545 );
546
547 it.effect("keeps OpenCode tool input, final output, and errors in native logs", () =>
548 Effect.gen(function* () {
549 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
550 const basePath = NodePath.join(tempDir, "events.log");
551 const threadId = ThreadId.make("thread-tool-lifecycle");
552
553 try {
554 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
555 const native = store.logger("native");
556 const input = { command: "run-build" };
557 const states = [
558 { status: "pending", input },
559 { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } },
560 { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } },
561 { status: "unknown", input },
562 null,
563 ];
564 const events = states.map((state) => ({
565 event: {
566 type: "message.part.updated",
567 payload: { properties: { part: { type: "tool", state } } },
568 },
569 }));
570 for (const event of events) {
571 yield* native.write(event, threadId);
572 }
573 yield* store.close();
574
575 const payloads = NodeFS.readFileSync(
576 ownedLogPath(basePath, "thread-tool-lifecycle"),
577 "utf8",
578 )
579 .trim()
580 .split("\n")
581 .map((line) => parseLogLine(line).payload);
582 assert.deepEqual(
583 payloads,
584 events.map((event) => encodeUnknownJson(event)),
585 );
586 } finally {
587 NodeFS.rmSync(tempDir, { recursive: true, force: true });
588 }
589 }),
590 );
591
592 it.effect("contains hostile event accessors inside guarded serialization", () =>
593 Effect.gen(function* () {
594 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
595 const basePath = NodePath.join(tempDir, "events.log");
596
597 try {
598 const logger = yield* makeEventNdjsonLogger(basePath, {
599 stream: "canonical",
600 batchWindowMs: 0,
601 });
602 assert.exists(logger);
603 if (!logger) return;
604 const hostile = new Proxy(
605 { id: "hostile" },
606 {
607 get(_target, property) {
608 if (property === "type") throw new Error("blocked");
609 return undefined;
610 },
611 },
612 );
613
614 yield* logger.write(hostile, ThreadId.make("thread-hostile"));
615 yield* logger.close();
616
617 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8");
618 assert.notInclude(contents, "blocked");
619 } finally {
620 NodeFS.rmSync(tempDir, { recursive: true, force: true });
621 }
622 }),
623 );
624
625 it.effect("serializes concurrent first writes for the same segment", () =>
626 Effect.gen(function* () {
627 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
628 const basePath = NodePath.join(tempDir, "provider-canonical.ndjson");
629
630 try {
631 const logger = yield* makeEventNdjsonLogger(basePath, {
632 stream: "canonical",
633 batchWindowMs: 0,
634 });
635 assert.notEqual(logger, undefined);
636 if (!logger) {
637 return;
638 }
639
640 yield* Effect.all(
641 [
642 logger.write({ id: "evt-concurrent-1" }, null),
643 logger.write({ id: "evt-concurrent-2" }, null),
644 ],
645 { concurrency: "unbounded" },
646 );
647 yield* logger.close();
648
649 const globalPath = ownedLogPath(basePath, "_global");
650 assert.equal(NodeFS.existsSync(globalPath), true);
651 const lines = NodeFS.readFileSync(globalPath, "utf8")
652 .trim()
653 .split("\n")
654 .map((line) => parseLogLine(line));
655
656 assert.equal(lines.length, 2);
657 assert.deepEqual(lines.map((line) => line.payload).toSorted(), [
658 '{"id":"evt-concurrent-1"}',
659 '{"id":"evt-concurrent-2"}',
660 ]);
661 } finally {
662 NodeFS.rmSync(tempDir, { recursive: true, force: true });
663 }
664 }),
665 );
666
667 it.effect("rotates per-thread files when max size is exceeded", () =>
668 Effect.gen(function* () {
669 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
670 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
671
672 try {
673 const store = yield* makeEventNdjsonLogStore(basePath, {
674 maxBytes: 120,
675 maxFiles: 2,
676 batchWindowMs: 0,
677 });
678 const native = store.logger("native");
679 const canonical = store.logger("canonical");
680
681 for (let index = 0; index < 10; index += 1) {
682 const logger = index % 2 === 0 ? native : canonical;
683 yield* logger.write(
684 {
685 type: "session.started",
686 threadId: "provider-thread-rotate",
687 id: `evt-${index}`,
688 payload: "x".repeat(40),
689 },
690 ThreadId.make("thread-rotate"),
691 );
692 }
693 yield* store.close();
694
695 const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate"));
696 const matchingFiles = NodeFS.readdirSync(tempDir)
697 .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`))
698 .toSorted();
699
700 assert.equal(
701 matchingFiles.some((entry) => entry === `${fileStem}.1`),
702 true,
703 );
704 assert.equal(
705 matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`),
706 true,
707 );
708 assert.equal(
709 matchingFiles.some((entry) => entry === `${fileStem}.3`),
710 false,
711 );
712 } finally {
713 NodeFS.rmSync(tempDir, { recursive: true, force: true });
714 }
715 }),
716 );
717
718 it.effect("enforces aggregate age and byte retention on startup", () =>
719 Effect.gen(function* () {
720 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
721 const basePath = NodePath.join(tempDir, "events.log");
722 const expiredPath = ownedLogPath(basePath, "expired");
723 const oldPath = ownedLogPath(basePath, "old");
724 const newPath = ownedLogPath(basePath, "new");
725 const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log");
726 const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log");
727 const ignoredPath = NodePath.join(tempDir, "ignored.txt");
728
729 try {
730 yield* TestClock.setTime(1_800_000_000_000);
731 const now = yield* Clock.currentTimeMillis;
732 for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) {
733 NodeFS.writeFileSync(filePath, "x".repeat(40));
734 }
735 NodeFS.writeFileSync(
736 legacyLogPath,
737 "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n",
738 );
739 NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000);
740 NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000);
741 NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000);
742 NodeFS.utimesSync(newPath, now / 1_000, now / 1_000);
743
744 const store = yield* makeEventNdjsonLogStore(basePath, {
745 maxAgeMs: 10_000,
746 maxTotalBytes: 60,
747 });
748 yield* store.close();
749
750 assert.equal(NodeFS.existsSync(expiredPath), false);
751 assert.equal(NodeFS.existsSync(legacyLogPath), false);
752 assert.equal(NodeFS.existsSync(oldPath), false);
753 assert.equal(NodeFS.existsSync(newPath), true);
754 assert.equal(NodeFS.existsSync(unrelatedLogPath), true);
755 assert.equal(NodeFS.existsSync(ignoredPath), true);
756 } finally {
757 NodeFS.rmSync(tempDir, { recursive: true, force: true });
758 }
759 }),
760 );
761
762 it.effect("does not prune an active thread sink during an unrelated flush", () =>
763 Effect.gen(function* () {
764 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
765 const basePath = NodePath.join(tempDir, "events.log");
766 const activePath = ownedLogPath(basePath, "active");
767
768 try {
769 yield* TestClock.setTime(1_800_000_000_000);
770 const store = yield* makeEventNdjsonLogStore(basePath, {
771 batchWindowMs: 0,
772 maxAgeMs: 1,
773 retentionCheckIntervalMs: 1,
774 });
775 const logger = store.logger("native");
776
777 yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active"));
778 assert.equal(NodeFS.existsSync(activePath), true);
779
780 yield* TestClock.adjust("2 millis");
781 yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other"));
782
783 assert.equal(NodeFS.existsSync(activePath), true);
784 yield* store.close();
785 } finally {
786 NodeFS.rmSync(tempDir, { recursive: true, force: true });
787 }
788 }),
789 );
790
791 it("attributes batches that were written before a later chunk fails", () => {
792 const records: ReadonlyArray<PendingRecord> = [
793 {
794 stream: "native",
795 threadSegment: "thread",
796 line: "first",
797 bytes: 5,
798 },
799 {
800 stream: "canonical",
801 threadSegment: "thread",
802 line: "second",
803 bytes: 6,
804 },
805 ];
806 const attributed: Array<PendingRecord> = [];
807 let writes = 0;
808
809 assert.throws(() =>
810 writeBatchedMessages(
811 {
812 write: () => {
813 writes += 1;
814 if (writes === 2) throw new Error("simulated disk exhaustion");
815 },
816 },
817 records,
818 5,
819 (written) => attributed.push(...written),
820 ),
821 );
822 assert.deepEqual(attributed, [records[0]]);
823 });
824
825 it.effect("reports logical provider log writes to resource attribution", () =>
826 Effect.gen(function* () {
827 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
828 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
829
830 try {
831 const attribution = yield* ResourceAttribution.make();
832 const logger = yield* makeEventNdjsonLogger(basePath, {
833 stream: "native",
834 batchWindowMs: 0,
835 attribution,
836 });
837 assert.notEqual(logger, undefined);
838 if (!logger) {
839 return;
840 }
841
842 yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution"));
843 yield* logger.close();
844
845 const snapshot = yield* attribution.snapshot;
846 assert.equal(snapshot.entries.length, 1);
847 assert.equal(snapshot.entries[0]?.component, "provider-event-log");
848 assert.equal(snapshot.entries[0]?.operation, "native.append");
849 assert.equal(snapshot.entries[0]?.count, 1);
850 assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0);
851 } finally {
852 NodeFS.rmSync(tempDir, { recursive: true, force: true });
853 }
854 }),
855 );
856});
apps/server/src/provider/Layers/EventNdjsonLogger.ts deleted-805
......@@ -1,805 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Best-effort provider event logging with one shared writer per thread.
4 *
5 * Native and canonical views share batching, rotation, and retention state so
6 * they cannot race while appending to the same thread-scoped file.
7 */
8import * as NodeFS from "node:fs";
9import * as NodePath from "node:path";
10
11import type { ThreadId } from "@t3tools/contracts";
12import { RotatingFileSink } from "@t3tools/shared/logging";
13import { errorTag } from "@t3tools/shared/observability";
14import * as Clock from "effect/Clock";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";
20import * as SynchronizedRef from "effect/SynchronizedRef";
21
22import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
23import type { ResourceAttribution } from "../../resourceTelemetry/ResourceAttribution.ts";
24
25const MEBIBYTE = 1024 * 1024;
26const DAY_MS = 24 * 60 * 60 * 1_000;
27const DEFAULT_MAX_BYTES = 10 * MEBIBYTE;
28const DEFAULT_MAX_FILES = 10;
29const DEFAULT_BATCH_WINDOW_MS = 1_000;
30const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE;
31const DEFAULT_MAX_AGE_MS = 14 * DAY_MS;
32const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000;
33const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE;
34const DEFAULT_MAX_BUFFERED_RECORDS = 512;
35const MAX_RECORD_CHARACTERS = 64 * 1024;
36const MAX_RECORD_FIELDS = 1_024;
37const MAX_RECORD_DEPTH = 16;
38const GLOBAL_THREAD_SEGMENT = "_global";
39const LOG_SCOPE = "provider-observability";
40const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
41
42const transientCanonicalEventTypes = new Set([
43 "content.delta",
44 "hook.progress",
45 "item.updated",
46 "task.progress",
47 "thread.realtime.audio.delta",
48 "tool.progress",
49 "turn.proposed.delta",
50]);
51const transientNativeMethods = new Set([
52 "item/agentMessage/delta",
53 "item/commandExecution/outputDelta",
54 "item/fileChange/outputDelta",
55 "item/plan/delta",
56 "item/reasoning/summaryTextDelta",
57 "item/reasoning/textDelta",
58 "thread/realtime/outputAudio/delta",
59 "thread/realtime/transcript/delta",
60 "turn/diff/updated",
61]);
62const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
63
64export type EventNdjsonStream = "native" | "canonical" | "orchestration";
65
66export interface EventNdjsonLogger {
67 readonly filePath: string;
68 readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>;
69 readonly close: () => Effect.Effect<void>;
70}
71
72export interface EventNdjsonLogStore {
73 readonly filePath: string;
74 readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger;
75 readonly close: () => Effect.Effect<void>;
76}
77
78export interface EventNdjsonLogStoreOptions {
79 readonly maxBytes?: number;
80 readonly maxFiles?: number;
81 readonly batchWindowMs?: number;
82 readonly maxTotalBytes?: number;
83 readonly maxAgeMs?: number;
84 readonly retentionCheckIntervalMs?: number;
85 readonly maxBufferedBytes?: number;
86 readonly maxBufferedRecords?: number;
87 readonly attribution?: ResourceAttribution["Service"];
88}
89
90export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions {
91 readonly stream: EventNdjsonStream;
92}
93
94export class EventNdjsonLogConfigurationError extends Schema.TaggedError<EventNdjsonLogConfigurationError>()(
95 "EventNdjsonLogConfigurationError",
96 {
97 filePath: Schema.String,
98 option: Schema.String,
99 value: Schema.Number,
100 minimum: Schema.Number,
101 },
102) {
103 override get message(): string {
104 return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`;
105 }
106}
107
108export class EventNdjsonLogDirectoryError extends Schema.TaggedError<EventNdjsonLogDirectoryError>()(
109 "EventNdjsonLogDirectoryError",
110 {
111 directory: Schema.String,
112 cause: Schema.Defect(),
113 },
114) {
115 override get message(): string {
116 return `Failed to create provider event log directory '${this.directory}'`;
117 }
118}
119
120export type EventNdjsonLogStoreError =
121 | EventNdjsonLogConfigurationError
122 | EventNdjsonLogDirectoryError;
123
124interface ResolvedOptions {
125 readonly maxBytes: number;
126 readonly maxFiles: number;
127 readonly batchWindowMs: number;
128 readonly maxTotalBytes: number;
129 readonly maxAgeMs: number;
130 readonly retentionCheckIntervalMs: number;
131 readonly maxBufferedBytes: number;
132 readonly maxBufferedRecords: number;
133 readonly attribution: ResourceAttribution["Service"] | undefined;
134}
135
136export interface PendingRecord {
137 readonly stream: EventNdjsonStream;
138 readonly threadSegment: string;
139 readonly line: string;
140 readonly bytes: number;
141}
142
143interface StoreState {
144 readonly pending: Array<PendingRecord>;
145 readonly pendingBytes: number;
146 readonly sinks: ReadonlyMap<string, RotatingFileSink>;
147 readonly flushScheduled: boolean;
148 readonly closed: boolean;
149 readonly lastRetentionAt: number;
150}
151
152interface AttributionSummary {
153 readonly stream: EventNdjsonStream;
154 readonly count: number;
155 readonly logicalWriteBytes: number;
156}
157
158interface FileOperationFailure {
159 readonly filePath: string;
160 readonly cause: unknown;
161}
162
163interface RetentionResult {
164 readonly failures: ReadonlyArray<FileOperationFailure>;
165}
166
167interface DrainResult {
168 readonly attributions: ReadonlyArray<AttributionSummary>;
169 readonly failures: ReadonlyArray<FileOperationFailure>;
170}
171
172function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> {
173 return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE }));
174}
175
176function resolveThreadSegment(raw: string | null | undefined): string {
177 const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null;
178 return normalized ?? GLOBAL_THREAD_SEGMENT;
179}
180
181function resolveStreamLabel(stream: EventNdjsonStream): string {
182 return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON";
183}
184
185function providerLogPrefix(filePath: string): string {
186 const basename = NodePath.basename(filePath);
187 const extension = NodePath.extname(basename);
188 return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`;
189}
190
191function providerLogPath(directory: string, prefix: string, threadSegment: string): string {
192 return NodePath.join(directory, `${prefix}${threadSegment}.log`);
193}
194
195export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unknown): boolean {
196 if (stream === "orchestration" || typeof event !== "object" || event === null) {
197 return true;
198 }
199 try {
200 const type = Reflect.get(event, "type");
201 if (typeof type === "string" && transientCanonicalEventTypes.has(type)) {
202 return false;
203 }
204 if (stream !== "native") return true;
205
206 const nested = Reflect.get(event, "event");
207 const envelope = typeof nested === "object" && nested !== null ? nested : event;
208 // Decoded frames carry the same information as raw frames without another
209 // copy of every token delta. Decode failures have their own diagnostic frame.
210 if (Reflect.get(envelope, "stage") === "raw") return false;
211 const decodedPayload = Reflect.get(envelope, "payload");
212 const nativeEvent =
213 Reflect.get(envelope, "stage") === "decoded" &&
214 typeof decodedPayload === "object" &&
215 decodedPayload !== null
216 ? decodedPayload
217 : envelope;
218 const method = Reflect.get(nativeEvent, "method");
219 if (
220 typeof method === "string" &&
221 (transientNativeMethods.has(method) ||
222 method.startsWith("claude/stream_event/content_block_delta/"))
223 ) {
224 return false;
225 }
226
227 const nativeType = Reflect.get(nativeEvent, "type");
228 if (nativeType === "message.part.delta") return false;
229 if (nativeType === "stream_event") {
230 const streamEvent = Reflect.get(nativeEvent, "event");
231 if (
232 typeof streamEvent === "object" &&
233 streamEvent !== null &&
234 Reflect.get(streamEvent, "type") === "content_block_delta"
235 ) {
236 return false;
237 }
238 }
239
240 const payload = Reflect.get(nativeEvent, "payload");
241 if (typeof payload !== "object" || payload === null) return true;
242
243 if (method === "session/update") {
244 const update = Reflect.get(payload, "update");
245 if (typeof update !== "object" || update === null) return true;
246 const updateType = Reflect.get(update, "sessionUpdate");
247 return typeof updateType !== "string" || !transientAcpUpdates.has(updateType);
248 }
249
250 if (nativeType === "message.part.updated") {
251 const properties = Reflect.get(payload, "properties");
252 if (typeof properties !== "object" || properties === null) return true;
253 const part = Reflect.get(properties, "part");
254 if (typeof part !== "object" || part === null) return true;
255 const partType = Reflect.get(part, "type");
256 if (partType === "text" || partType === "reasoning") return false;
257 if (partType === "tool") {
258 // Running snapshots repeat growing output. Pending and terminal states stay in the log.
259 const state = Reflect.get(part, "state");
260 if (typeof state === "object" && state !== null) {
261 return Reflect.get(state, "status") !== "running";
262 }
263 }
264 return true;
265 }
266
267 return true;
268 } catch {
269 return true;
270 }
271}
272
273const summaryFields = [
274 "provider",
275 "protocol",
276 "kind",
277 "providerSessionId",
278 "direction",
279 "stage",
280 "type",
281 "subtype",
282 "method",
283 "id",
284 "threadId",
285 "turnId",
286 "requestId",
287 "session_id",
288 "status",
289 "is_error",
290 "api_error_status",
291 "terminal_reason",
292 "stop_reason",
293 "operation",
294 "code",
295 "willRetry",
296 "message",
297 "event",
298 "payload",
299 "params",
300 "result",
301 "thread",
302 "turn",
303 "error",
304 "turns",
305 "items",
306 "content",
307] as const;
308
309function summarizeProviderEvent(event: unknown): unknown {
310 let remainingFields = 128;
311 let remainingCharacters = 8 * 1024;
312 const summarize = (value: unknown, depth: number): unknown => {
313 if (typeof value === "string") {
314 if (value.length > Math.min(1_024, remainingCharacters)) {
315 return { omittedCharacters: value.length };
316 }
317 remainingCharacters -= value.length;
318 return value;
319 }
320 if (value === null || typeof value === "number" || typeof value === "boolean") return value;
321 if (typeof value !== "object") return undefined;
322 if (Array.isArray(value)) return { itemCount: value.length };
323 const summary: Record<string, unknown> = { truncated: true };
324 if (depth >= 6) return summary;
325 for (const key of summaryFields) {
326 if (remainingFields <= 0) break;
327 const nested = Reflect.get(value, key);
328 if (nested === undefined) continue;
329 remainingFields -= 1;
330 summary[key] = summarize(nested, depth + 1);
331 }
332 return summary;
333 };
334 try {
335 return summarize(event, 0);
336 } catch {
337 return { truncated: true };
338 }
339}
340
341/** Bounds traversal before adapters copy payloads or the logger encodes them. */
342export function boundProviderEventForLogging(event: unknown): unknown {
343 let remainingCharacters = MAX_RECORD_CHARACTERS;
344 let remainingFields = MAX_RECORD_FIELDS;
345 const ancestors = new WeakSet<object>();
346 const fits = (value: unknown, depth: number): boolean => {
347 if (typeof value === "string") {
348 remainingCharacters -= value.length;
349 return remainingCharacters >= 0;
350 }
351 if (typeof value !== "object" || value === null) return true;
352 if (depth > MAX_RECORD_DEPTH || ancestors.has(value)) return false;
353 if (Array.isArray(value) && value.length > remainingFields) return false;
354 ancestors.add(value);
355 for (const key in value) {
356 if (!Object.hasOwn(value, key)) continue;
357 remainingFields -= 1;
358 remainingCharacters -= key.length;
359 if (
360 remainingFields < 0 ||
361 remainingCharacters < 0 ||
362 !fits(Reflect.get(value, key), depth + 1)
363 )
364 return false;
365 }
366 ancestors.delete(value);
367 return true;
368 };
369 try {
370 if (fits(event, 0)) return event;
371 } catch {
372 // A failing accessor must not escape into provider processing.
373 }
374 return summarizeProviderEvent(event);
375}
376
377export function writeBatchedMessages(
378 sink: Pick<RotatingFileSink, "write">,
379 records: ReadonlyArray<PendingRecord>,
380 maxBytes: number,
381 onWritten: (records: ReadonlyArray<PendingRecord>) => void,
382): void {
383 let pendingRecords: Array<PendingRecord> = [];
384 let pendingBytes = 0;
385
386 const flush = () => {
387 if (pendingRecords.length === 0) return;
388 const writtenRecords = pendingRecords;
389 sink.write(writtenRecords.map((record) => record.line).join(""));
390 onWritten(writtenRecords);
391 pendingRecords = [];
392 pendingBytes = 0;
393 };
394
395 for (const record of records) {
396 if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) {
397 flush();
398 }
399 pendingRecords.push(record);
400 pendingBytes += record.bytes;
401 if (pendingBytes >= maxBytes) {
402 flush();
403 }
404 }
405 flush();
406}
407
408function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean {
409 if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false;
410 if (fileName.startsWith(filePrefix)) return true;
411
412 const descriptor = NodeFS.openSync(filePath, "r");
413 try {
414 const header = Buffer.alloc(256);
415 const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0);
416 return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead));
417 } finally {
418 NodeFS.closeSync(descriptor);
419 }
420}
421
422function enforceRetention(input: {
423 readonly directory: string;
424 readonly maxTotalBytes: number;
425 readonly maxAgeMs: number;
426 readonly activeFilePaths: ReadonlySet<string>;
427 readonly filePrefix: string;
428 readonly now: number;
429}): RetentionResult {
430 const failures: Array<FileOperationFailure> = [];
431 const files: Array<{ filePath: string; mtimeMs: number; size: number }> = [];
432
433 let entries: ReadonlyArray<NodeFS.Dirent>;
434 try {
435 entries = NodeFS.readdirSync(input.directory, { withFileTypes: true });
436 } catch (cause) {
437 return { failures: [{ filePath: input.directory, cause }] };
438 }
439
440 for (const entry of entries) {
441 if (!entry.isFile()) continue;
442 const filePath = NodePath.join(input.directory, entry.name);
443 try {
444 if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue;
445 const stat = NodeFS.statSync(filePath);
446 files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
447 } catch (cause) {
448 failures.push({ filePath, cause });
449 }
450 }
451
452 let totalBytes = files.reduce((total, file) => total + file.size, 0);
453 const remove = (file: (typeof files)[number]) => {
454 if (input.activeFilePaths.has(file.filePath)) return false;
455 try {
456 NodeFS.rmSync(file.filePath, { force: true });
457 totalBytes -= file.size;
458 return true;
459 } catch (cause) {
460 failures.push({ filePath: file.filePath, cause });
461 return false;
462 }
463 };
464
465 const retained = files.filter((file) => {
466 if (input.now - file.mtimeMs <= input.maxAgeMs) return true;
467 return !remove(file);
468 });
469
470 for (const file of retained.toSorted(
471 (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath),
472 )) {
473 if (totalBytes <= input.maxTotalBytes) break;
474 remove(file);
475 }
476
477 return { failures };
478}
479
480function validateOption(input: {
481 readonly filePath: string;
482 readonly option: string;
483 readonly value: number;
484 readonly minimum: number;
485}): EventNdjsonLogConfigurationError | undefined {
486 if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined;
487 return new EventNdjsonLogConfigurationError(input);
488}
489
490function resolveOptions(
491 filePath: string,
492 options: EventNdjsonLogStoreOptions,
493): Effect.Effect<ResolvedOptions, EventNdjsonLogConfigurationError> {
494 const resolved = {
495 maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
496 maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
497 batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS,
498 maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
499 maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
500 retentionCheckIntervalMs:
501 options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS,
502 maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
503 maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS,
504 attribution: options.attribution,
505 } satisfies ResolvedOptions;
506
507 const validations = [
508 ["maxBytes", resolved.maxBytes, 1],
509 ["maxFiles", resolved.maxFiles, 1],
510 ["batchWindowMs", resolved.batchWindowMs, 0],
511 ["maxTotalBytes", resolved.maxTotalBytes, 1],
512 ["maxAgeMs", resolved.maxAgeMs, 1],
513 ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1],
514 ["maxBufferedBytes", resolved.maxBufferedBytes, 1],
515 ["maxBufferedRecords", resolved.maxBufferedRecords, 1],
516 ] as const;
517
518 for (const [option, value, minimum] of validations) {
519 const error = validateOption({ filePath, option, value, minimum });
520 if (error) return Effect.fail(error);
521 }
522 return Effect.succeed(resolved);
523}
524
525function drainPending(input: {
526 readonly directory: string;
527 readonly options: ResolvedOptions;
528 readonly state: StoreState;
529 readonly filePrefix: string;
530 readonly now: number;
531 readonly timerFired: boolean;
532 readonly close: boolean;
533}): readonly [DrainResult, StoreState] {
534 if (input.state.closed) {
535 return [{ attributions: [], failures: [] }, input.state];
536 }
537
538 const sinks = new Map(input.state.sinks);
539 const failures: Array<FileOperationFailure> = [];
540 const attributionByStream = new Map<
541 EventNdjsonStream,
542 { count: number; logicalWriteBytes: number }
543 >();
544 const recordsBySegment = new Map<string, Array<PendingRecord>>();
545
546 for (const record of input.state.pending) {
547 const records = recordsBySegment.get(record.threadSegment) ?? [];
548 records.push(record);
549 recordsBySegment.set(record.threadSegment, records);
550 }
551
552 for (const [threadSegment, records] of recordsBySegment) {
553 const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment);
554 let sink = sinks.get(threadSegment);
555 if (!sink) {
556 try {
557 sink = new RotatingFileSink({
558 filePath,
559 maxBytes: input.options.maxBytes,
560 maxFiles: input.options.maxFiles,
561 throwOnError: true,
562 });
563 sinks.set(threadSegment, sink);
564 } catch (cause) {
565 failures.push({ filePath, cause });
566 continue;
567 }
568 }
569
570 try {
571 writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => {
572 for (const record of writtenRecords) {
573 const current = attributionByStream.get(record.stream) ?? {
574 count: 0,
575 logicalWriteBytes: 0,
576 };
577 attributionByStream.set(record.stream, {
578 count: current.count + 1,
579 logicalWriteBytes: current.logicalWriteBytes + record.bytes,
580 });
581 }
582 });
583 } catch (cause) {
584 sinks.delete(threadSegment);
585 failures.push({ filePath, cause });
586 }
587 }
588
589 const retentionDue =
590 input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs;
591 const retention = retentionDue
592 ? enforceRetention({
593 directory: input.directory,
594 maxTotalBytes: input.options.maxTotalBytes,
595 maxAgeMs: input.options.maxAgeMs,
596 activeFilePaths: new Set(
597 Array.from(sinks.keys(), (threadSegment) =>
598 providerLogPath(input.directory, input.filePrefix, threadSegment),
599 ),
600 ),
601 filePrefix: input.filePrefix,
602 now: input.now,
603 })
604 : { failures: [] };
605
606 return [
607 {
608 attributions: Array.from(attributionByStream, ([stream, value]) => ({
609 stream,
610 ...value,
611 })),
612 failures: [...failures, ...retention.failures],
613 },
614 {
615 pending: [],
616 pendingBytes: 0,
617 sinks,
618 flushScheduled: input.timerFired ? false : input.state.flushScheduled,
619 closed: input.close,
620 lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt,
621 },
622 ];
623}
624
625const serializeEvent = Effect.fnUntraced(function* (event: unknown) {
626 return yield* encodeUnknownJsonString(event).pipe(
627 Effect.catch((error) =>
628 logWarning("failed to serialize provider event log record", {
629 errorTag: errorTag(error),
630 }).pipe(Effect.as(undefined)),
631 ),
632 );
633});
634
635export const makeEventNdjsonLogStore = Effect.fnUntraced(function* (
636 filePath: string,
637 options: EventNdjsonLogStoreOptions = {},
638): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> {
639 const resolved = yield* resolveOptions(filePath, options);
640 const directory = NodePath.dirname(filePath);
641 const filePrefix = providerLogPrefix(filePath);
642
643 yield* Effect.try({
644 try: () => NodeFS.mkdirSync(directory, { recursive: true }),
645 catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }),
646 });
647
648 const initializedAt = yield* Clock.currentTimeMillis;
649 const initialRetention = yield* Effect.sync(() =>
650 enforceRetention({
651 directory,
652 maxTotalBytes: resolved.maxTotalBytes,
653 maxAgeMs: resolved.maxAgeMs,
654 activeFilePaths: new Set(),
655 filePrefix,
656 now: initializedAt,
657 }),
658 );
659 for (const failure of initialRetention.failures) {
660 yield* logWarning("provider event log retention failed", {
661 filePath: failure.filePath,
662 errorTag: errorTag(failure.cause),
663 });
664 }
665
666 const stateRef = yield* SynchronizedRef.make<StoreState>({
667 pending: [],
668 pendingBytes: 0,
669 sinks: new Map(),
670 flushScheduled: false,
671 closed: false,
672 lastRetentionAt: initializedAt,
673 });
674 const timerScope = yield* Scope.make();
675
676 const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) {
677 const startedAt = yield* Clock.currentTimeMillis;
678 const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
679 Effect.sync(() =>
680 drainPending({
681 directory,
682 options: resolved,
683 state,
684 filePrefix,
685 now: startedAt,
686 timerFired,
687 close,
688 }),
689 ),
690 );
691
692 for (const failure of result.failures) {
693 yield* logWarning("provider event log write or retention failed", {
694 filePath: failure.filePath,
695 errorTag: errorTag(failure.cause),
696 });
697 }
698
699 if (resolved.attribution && result.attributions.length > 0) {
700 const completedAt = yield* Clock.currentTimeMillis;
701 const durationMs = Math.max(0, completedAt - startedAt);
702 const totalBytes = result.attributions.reduce(
703 (total, entry) => total + entry.logicalWriteBytes,
704 0,
705 );
706 yield* Effect.forEach(
707 result.attributions,
708 (entry) =>
709 resolved.attribution?.record({
710 component: "provider-event-log",
711 operation: `${entry.stream}.append`,
712 logicalWriteBytes: entry.logicalWriteBytes,
713 count: entry.count,
714 durationMs:
715 totalBytes === 0
716 ? 0
717 : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)),
718 }) ?? Effect.void,
719 { discard: true },
720 );
721 }
722 });
723
724 const scheduleFlush = Effect.fnUntraced(function* () {
725 yield* Effect.forkIn(
726 Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))),
727 timerScope,
728 { startImmediately: true },
729 );
730 });
731
732 const close = Effect.fnUntraced(function* () {
733 yield* flush(false, true);
734 yield* Scope.close(timerScope, Exit.void);
735 });
736
737 const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>();
738 const logger = (stream: EventNdjsonStream): EventNdjsonLogger => {
739 const existing = loggerViews.get(stream);
740 if (existing) return existing;
741
742 const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) {
743 if (!shouldPersistProviderEvent(stream, event)) return;
744 let payload = yield* serializeEvent(boundProviderEventForLogging(event));
745 if (payload === undefined) return;
746 // Escaping can expand strings beyond their input size. Keep that bounded
747 // serialization out of the file too, while retaining routing/error fields.
748 if (Buffer.byteLength(payload) > MAX_RECORD_CHARACTERS) {
749 payload = yield* serializeEvent(summarizeProviderEvent(event));
750 if (payload === undefined) return;
751 }
752
753 const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
754 const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`;
755 const bytes = Buffer.byteLength(line);
756 const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => {
757 if (state.closed) {
758 return Effect.succeed([{ flush: false }, state] as const);
759 }
760 const pending = state.pending;
761 pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes });
762 const pendingBytes = state.pendingBytes + bytes;
763 const flush =
764 resolved.batchWindowMs === 0 ||
765 pending.length >= resolved.maxBufferedRecords ||
766 pendingBytes >= resolved.maxBufferedBytes;
767 const schedule = !flush && !state.flushScheduled;
768 const nextState = {
769 ...state,
770 pending,
771 pendingBytes,
772 flushScheduled: state.flushScheduled || schedule,
773 };
774 return (schedule ? scheduleFlush() : Effect.void).pipe(
775 Effect.as([{ flush }, nextState] as const),
776 );
777 }).pipe(Effect.uninterruptible);
778
779 if (action.flush) {
780 yield* flush(false, false);
781 }
782 });
783
784 const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger;
785 loggerViews.set(stream, view);
786 return view;
787 };
788
789 return { filePath, logger, close } satisfies EventNdjsonLogStore;
790});
791
792export const makeEventNdjsonLogger = Effect.fnUntraced(function* (
793 filePath: string,
794 options: EventNdjsonLoggerOptions,
795): Effect.fn.Return<EventNdjsonLogger | undefined> {
796 const store = yield* makeEventNdjsonLogStore(filePath, options).pipe(
797 Effect.catch((error) =>
798 logWarning(error.message, { error }).pipe(
799 Effect.as<EventNdjsonLogStore | undefined>(undefined),
800 ),
801 ),
802 );
803 if (!store) return undefined;
804 return { ...store.logger(options.stream), close: store.close };
805});
apps/server/src/provider/Layers/GrokProvider.test.ts deleted-845
......@@ -1,845 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file.
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Schema from "effect/Schema";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import { GrokSettings } from "@t3tools/contracts";
12
13import {
14 buildGrokModelCapabilities,
15 buildGrokModelsFromSessionModelState,
16 buildInitialGrokProviderSnapshot,
17 checkGrokProviderStatus,
18 grokSlashCommandsFromInitialize,
19 parseGrokModelsCliOutput,
20} from "./GrokProvider.ts";
21import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
22import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts";
23
24const decodeGrokSettings = Schema.decodeSync(GrokSettings);
25const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
26
27const LOGGED_IN_MODELS_OUTPUT = [
28 "You are logged in with grok.com.",
29 "",
30 "Default model: grok-4.6",
31 "",
32 "Available models:",
33 " * grok-4.6 (default)",
34 " - grok-4.5",
35 "",
36].join("\n");
37
38const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace(
39 "You are logged in with grok.com.",
40 "You are not authenticated.",
41);
42
43describe("parseGrokModelsCliOutput", () => {
44 it("reads login state and model slugs, marking the default", () => {
45 const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT);
46 expect(parsed.authenticated).toBe(true);
47 expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
48 ["grok-4.6", true],
49 ["grok-4.5", false],
50 ]);
51 });
52
53 it("detects a logged-out CLI even though it exits 0", () => {
54 expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false);
55 });
56
57 it("returns unknown auth for unrecognized output", () => {
58 expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull();
59 });
60});
61
62describe("grokSlashCommandsFromInitialize", () => {
63 it("publishes native ACP commands and input hints without permission overrides", () => {
64 const commands = grokSlashCommandsFromInitialize({
65 protocolVersion: 1,
66 _meta: {
67 availableCommands: [
68 { name: "compact", description: "Compress history", input: { hint: "what to preserve" } },
69 {
70 name: "always-approve",
71 description: "Skip permission prompts",
72 input: { hint: "on|off" },
73 },
74 { name: "context", description: "Show context usage", input: null },
75 { name: "session-info", description: "Show session details" },
76 { name: "deep-research", description: "Research a topic", input: { hint: "<query>" } },
77 { name: "workflow", description: "Manage workflows", input: { hint: "runs" } },
78 { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } },
79 ],
80 },
81 });
82 expect(commands.map((command) => command.name)).toEqual([
83 "compact",
84 "session-info",
85 "deep-research",
86 "workflow",
87 "goal",
88 ]);
89 expect(commands[0]?.input).toEqual({ hint: "what to preserve" });
90 expect(commands[2]).toEqual({
91 name: "deep-research",
92 description: "Research a topic",
93 input: { hint: "<query>" },
94 });
95 });
96
97 it("keeps valid commands when other metadata entries are malformed", () => {
98 const commands = grokSlashCommandsFromInitialize({
99 protocolVersion: 1,
100 _meta: {
101 availableCommands: [
102 null,
103 { name: "broken", description: 42 },
104 { name: " ", description: "Empty name" },
105 { name: " session-info ", description: " Session details " },
106 { name: "session-info", description: "Updated session details" },
107 ],
108 },
109 });
110 expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]);
111 expect(commands[1]?.description).toBe("Updated session details");
112 });
113
114 it("keeps compact available for older agents without command metadata", () => {
115 for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) {
116 expect(
117 grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map(
118 (command) => command.name,
119 ),
120 ).toEqual(["compact"]);
121 }
122 });
123});
124
125describe("buildGrokModelsFromSessionModelState", () => {
126 it("marks the agent's current model as default and keeps reasoning options", () => {
127 const models = buildGrokModelsFromSessionModelState({
128 currentModelId: "grok-4.6",
129 availableModels: [
130 {
131 modelId: "grok-4.6",
132 name: "Grok 4.6",
133 _meta: {
134 supportsReasoningEffort: true,
135 reasoningEffort: "high",
136 reasoningEfforts: [{ value: "high", label: "High", default: true }],
137 },
138 },
139 { modelId: "grok-4.5", name: "Grok 4.5" },
140 ],
141 });
142 expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
143 ["grok-4.6", true],
144 ["grok-4.5", false],
145 ]);
146 expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1);
147 });
148});
149
150describe("buildGrokModelCapabilities", () => {
151 it("preserves ACP-provided reasoning labels and the active default", () => {
152 const capabilities = buildGrokModelCapabilities({
153 modelId: "grok-4.6",
154 name: "Grok 4.6",
155 _meta: {
156 supportsReasoningEffort: true,
157 reasoningEffort: "xhigh",
158 reasoningEfforts: [
159 { value: "xhigh", label: "Extra High Effort", default: true },
160 { value: "high", label: "High Effort", default: true },
161 { value: "medium", label: "Medium Effort" },
162 { value: "low", label: "Low Effort" },
163 ],
164 },
165 });
166
167 expect(capabilities.optionDescriptors).toEqual([
168 {
169 id: "reasoningEffort",
170 label: "Reasoning",
171 type: "select",
172 currentValue: "xhigh",
173 options: [
174 { id: "xhigh", label: "Extra High Effort", isDefault: true },
175 { id: "high", label: "High Effort" },
176 { id: "medium", label: "Medium Effort" },
177 { id: "low", label: "Low Effort" },
178 ],
179 },
180 ]);
181 });
182
183 it("uses raw ACP values when option labels are omitted", () => {
184 const capabilities = buildGrokModelCapabilities({
185 modelId: "grok-4.6",
186 name: "Grok 4.6",
187 _meta: {
188 supportsReasoningEffort: true,
189 reasoningEffort: "xhigh",
190 reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }],
191 },
192 });
193
194 expect(capabilities.optionDescriptors).toEqual([
195 {
196 id: "reasoningEffort",
197 label: "Reasoning",
198 type: "select",
199 currentValue: "xhigh",
200 options: [
201 { id: "xhigh", label: "xhigh" },
202 { id: "medium", label: "medium" },
203 ],
204 },
205 ]);
206 });
207
208 it("keeps ACP current effort separate from its collapsed advertised default", () => {
209 const capabilities = buildGrokModelCapabilities({
210 modelId: "grok-4.6",
211 name: "Grok 4.6",
212 _meta: {
213 supportsReasoningEffort: true,
214 reasoningEffort: "medium",
215 reasoningEfforts: [
216 { value: "xhigh", label: "Extra High Effort", default: true },
217 { value: "high", label: "High Effort", default: true },
218 { value: "medium", label: "Medium Effort" },
219 ],
220 },
221 });
222
223 expect(capabilities.optionDescriptors).toEqual([
224 {
225 id: "reasoningEffort",
226 label: "Reasoning",
227 type: "select",
228 currentValue: "medium",
229 options: [
230 { id: "xhigh", label: "Extra High Effort", isDefault: true },
231 { id: "high", label: "High Effort" },
232 { id: "medium", label: "Medium Effort" },
233 ],
234 },
235 ]);
236 });
237
238 it("preserves ACP descriptions and falls back from invalid values to valid ids", () => {
239 const capabilities = buildGrokModelCapabilities({
240 modelId: "grok-4.6",
241 name: "Grok 4.6",
242 _meta: {
243 supportsReasoningEffort: true,
244 reasoningEffort: "high",
245 reasoningEfforts: [
246 {
247 id: "high",
248 value: "not a token",
249 label: "High Effort",
250 description: "Higher implementation quality",
251 default: true,
252 },
253 { id: "bad id", value: "also invalid", label: "Invalid" },
254 ],
255 },
256 });
257
258 expect(capabilities.optionDescriptors).toEqual([
259 {
260 id: "reasoningEffort",
261 label: "Reasoning",
262 type: "select",
263 currentValue: "high",
264 options: [
265 {
266 id: "high",
267 label: "High Effort",
268 description: "Higher implementation quality",
269 isDefault: true,
270 },
271 ],
272 },
273 ]);
274 });
275
276 it("accepts an advertised ACP menu when the support flag is omitted", () => {
277 const capabilities = buildGrokModelCapabilities({
278 modelId: "grok-4.6",
279 name: "Grok 4.6",
280 _meta: {
281 reasoningEffort: "high",
282 reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
283 },
284 });
285
286 expect(capabilities.optionDescriptors).toHaveLength(1);
287 });
288
289 it("honors an explicit ACP opt-out even when a menu is present", () => {
290 const capabilities = buildGrokModelCapabilities({
291 modelId: "grok-4.6",
292 name: "Grok 4.6",
293 _meta: {
294 supportsReasoningEffort: false,
295 reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
296 },
297 });
298
299 expect(capabilities.optionDescriptors).toEqual([]);
300 });
301
302 it("does not synthesize a reasoning menu when ACP omits it", () => {
303 expect(
304 buildGrokModelCapabilities({
305 modelId: "grok-4.6",
306 name: "Grok 4.6",
307 _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" },
308 }).optionDescriptors,
309 ).toEqual([]);
310 });
311
312 it("keeps non-reasoning Grok models free of reasoning controls", () => {
313 expect(
314 buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors,
315 ).toEqual([]);
316 });
317});
318
319describe("buildInitialGrokProviderSnapshot", () => {
320 it.effect("returns a disabled snapshot when settings.enabled is false", () =>
321 Effect.gen(function* () {
322 const snapshot = yield* buildInitialGrokProviderSnapshot(
323 decodeGrokSettings({ enabled: false }),
324 );
325 expect(snapshot.enabled).toBe(false);
326 expect(snapshot.status).toBe("disabled");
327 expect(snapshot.installed).toBe(false);
328 expect(snapshot.message).toContain("disabled");
329 }),
330 );
331
332 it.effect("returns a disabled snapshot by default — Grok is opt-in", () =>
333 Effect.gen(function* () {
334 const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({}));
335 expect(snapshot.enabled).toBe(false);
336 expect(snapshot.status).toBe("disabled");
337 }),
338 );
339
340 it.effect("returns a pending snapshot when enabled", () =>
341 Effect.gen(function* () {
342 const snapshot = yield* buildInitialGrokProviderSnapshot(
343 decodeGrokSettings({ enabled: true }),
344 );
345 expect(snapshot.enabled).toBe(true);
346 expect(snapshot.installed).toBe(true);
347 expect(snapshot.status).toBe("warning");
348 expect(snapshot.version).toBeNull();
349 expect(snapshot.message).toContain("Checking Grok");
350 expect(snapshot.requiresNewThreadForModelChange).toBeUndefined();
351 expect(snapshot.supportsConversationRollback).toBe(false);
352 }),
353 );
354});
355
356it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
357 it.effect("reports the binary as missing when the binary path does not resolve", () =>
358 Effect.gen(function* () {
359 const snapshot = yield* checkGrokProviderStatus(
360 decodeGrokSettings({
361 enabled: true,
362 binaryPath: "/definitely/not/installed/grok-binary",
363 }),
364 );
365 expect(snapshot.enabled).toBe(true);
366 expect(snapshot.installed).toBe(false);
367 expect(snapshot.status).toBe("error");
368 expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/);
369 }),
370 );
371
372 it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () =>
373 Effect.gen(function* () {
374 const secretStderr = "broken grok install: secret-token-value";
375 const snapshot = yield* Effect.scoped(
376 Effect.gen(function* () {
377 const fs = yield* FileSystem.FileSystem;
378 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" });
379 const grokPath = writeFakeCli({
380 directory: dir,
381 name: "grok",
382 source: [
383 // @effect-diagnostics-next-line preferSchemaOverJson:off
384 `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`,
385 "process.exit(2);",
386 "",
387 ].join("\n"),
388 });
389
390 return yield* checkGrokProviderStatus(
391 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
392 );
393 }),
394 );
395
396 expect(snapshot.enabled).toBe(true);
397 expect(snapshot.installed).toBe(true);
398 expect(snapshot.status).toBe("error");
399 expect(snapshot.message).toBe("Grok CLI is installed but failed to run.");
400 expect(snapshot.message).not.toContain(secretStderr);
401 }),
402 );
403
404 // A stand-in for the Grok CLI: `--version` and `models` print canned text,
405 // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata.
406 const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) =>
407 Effect.gen(function* () {
408 const fs = yield* FileSystem.FileSystem;
409 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" });
410 const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts");
411 return writeFakeCli({
412 directory: dir,
413 name: "grok",
414 source: [
415 'if (process.argv[2] === "--version") {',
416 ' process.stdout.write("grok 1.0.13\\n");',
417 " process.exit(0);",
418 "}",
419 'if (process.argv[2] === "models") {',
420 // @effect-diagnostics-next-line preferSchemaOverJson:off
421 ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`,
422 " process.exit(0);",
423 "}",
424 'if (process.argv[2] !== "agent") process.exit(1);',
425 ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]),
426 "",
427 ].join("\n"),
428 });
429 });
430
431 it.effect("reports ready with ACP-discovered models when logged in", () =>
432 Effect.gen(function* () {
433 const snapshot = yield* Effect.scoped(
434 Effect.gen(function* () {
435 const grokPath = yield* writeFakeGrokCli({
436 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
437 acp: true,
438 });
439 return yield* checkGrokProviderStatus(
440 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
441 { ...process.env, XAI_API_KEY: "" },
442 );
443 }),
444 );
445
446 expect(snapshot.status).toBe("ready");
447 expect(snapshot.version).toBe("1.0.13");
448 expect(snapshot.auth).toEqual({
449 status: "authenticated",
450 type: "cached_token",
451 label: "Grok account",
452 });
453 // The mock agent advertises grok-4.6 with reasoning options in initialize._meta.
454 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]);
455 expect(snapshot.models[0]?.isDefault).toBe(true);
456 expect(
457 snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [],
458 ).toEqual(["reasoningEffort"]);
459 }),
460 );
461
462 it.effect("reports unauthenticated from `grok models` without starting a session", () =>
463 Effect.gen(function* () {
464 const snapshot = yield* Effect.scoped(
465 Effect.gen(function* () {
466 const grokPath = yield* writeFakeGrokCli({
467 modelsOutput: LOGGED_OUT_MODELS_OUTPUT,
468 acp: true,
469 });
470 return yield* checkGrokProviderStatus(
471 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
472 { ...process.env, XAI_API_KEY: "" },
473 );
474 }),
475 );
476
477 expect(snapshot.status).toBe("error");
478 expect(snapshot.auth.status).toBe("unauthenticated");
479 expect(snapshot.message).toContain("grok login");
480 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]);
481 }),
482 );
483
484 it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () =>
485 Effect.gen(function* () {
486 const snapshot = yield* Effect.scoped(
487 Effect.gen(function* () {
488 const grokPath = yield* writeFakeGrokCli({
489 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
490 acp: false,
491 });
492 return yield* checkGrokProviderStatus(
493 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
494 { ...process.env, XAI_API_KEY: "" },
495 );
496 }),
497 );
498
499 expect(snapshot.status).toBe("warning");
500 expect(snapshot.installed).toBe(true);
501 expect(snapshot.auth.status).toBe("authenticated");
502 expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
503 ["grok-4.6", true],
504 ["grok-4.5", false],
505 ]);
506 expect(snapshot.message).toContain("ACP initialize failed");
507 expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]);
508 }),
509 );
510
511 it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () =>
512 Effect.gen(function* () {
513 const snapshot = yield* Effect.scoped(
514 Effect.gen(function* () {
515 const grokPath = yield* writeFakeGrokCli({
516 modelsOutput: LOGGED_OUT_MODELS_OUTPUT,
517 acp: false,
518 });
519 return yield* checkGrokProviderStatus(
520 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
521 { ...process.env, XAI_API_KEY: "xai-test-key" },
522 );
523 }),
524 );
525
526 expect(snapshot.auth).toEqual({
527 status: "authenticated",
528 type: "api_key",
529 label: "xAI API key",
530 });
531 expect(snapshot.status).toBe("warning");
532 }),
533 );
534});
535
536describe("Grok usage limits", () => {
537 const checkedAt = "2026-09-16T00:00:00.000Z";
538
539 it("uses the reported subscription percentage and weekly reset", () => {
540 const limits = grokUsageResponseToLimits(
541 {
542 config: {
543 creditUsagePercent: 100,
544 currentPeriod: {
545 type: "USAGE_PERIOD_TYPE_WEEKLY",
546 end: "2026-09-18T03:10:30.159171+00:00",
547 },
548 },
549 },
550 checkedAt,
551 );
552 expect(limits.windows).toEqual([
553 {
554 id: "subscription",
555 kind: "weekly",
556 label: "Weekly",
557 usedPercent: 100,
558 resetsAt: "2026-09-18T03:10:30.159Z",
559 },
560 ]);
561 });
562
563 it("does not invent allowance or reset dates when billing omits them", () => {
564 for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) {
565 const limits = grokUsageResponseToLimits(response, checkedAt);
566 expect(limits.windows).toEqual([]);
567 // Nothing metered yet, which xAI reports by omitting the field until
568 // usage registers. Marking it `unsupported` would drop the account from
569 // the Limits view for good; leaving the marker off keeps it listed.
570 expect(limits.unavailable).toBeUndefined();
571 }
572 expect(
573 grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows,
574 ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]);
575 expect(
576 grokUsageResponseToLimits(
577 {
578 config: {
579 creditUsagePercent: 120,
580 currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" },
581 },
582 },
583 checkedAt,
584 ).windows,
585 ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]);
586 });
587});
588
589it.layer(NodeServices.layer)("readGrokAccount", (it) => {
590 it.effect("reads the configured Grok home and prefers the current login scope to legacy", () =>
591 Effect.gen(function* () {
592 const fs = yield* FileSystem.FileSystem;
593 const directory = yield* fs.makeTempDirectoryScoped();
594 yield* fs.writeFileString(
595 NodePath.join(directory, "auth.json"),
596 '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}',
597 );
598 const { usageLimits: limits } = yield* readGrokAccount({
599 GROK_HOME: directory,
600 HOME: "/unrelated-home",
601 }).pipe(
602 Effect.provideService(
603 HttpClient.HttpClient,
604 HttpClient.make((request) => {
605 expect(request.method).toBe("GET");
606 expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits");
607 expect(request.headers.authorization).toBe("Bearer session-token");
608 return Effect.succeed(
609 HttpClientResponse.fromWeb(
610 request,
611 Response.json({ config: { creditUsagePercent: 37 } }),
612 ),
613 );
614 }),
615 ),
616 );
617 expect(limits.windows[0]?.usedPercent).toBe(37);
618 }).pipe(Effect.scoped),
619 );
620
621 it.effect(
622 "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope",
623 () =>
624 readGrokAccount({
625 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}',
626 }).pipe(
627 Effect.provideService(
628 FileSystem.FileSystem,
629 FileSystem.makeNoop({
630 readFileString: (path) =>
631 path.endsWith("auth.json")
632 ? Effect.die("must not read stored credentials")
633 : Effect.succeed(""),
634 }),
635 ),
636 Effect.provideService(
637 HttpClient.HttpClient,
638 HttpClient.make((request) => {
639 expect(request.headers.authorization).toBe("Bearer legacy-token");
640 return Effect.succeed(
641 HttpClientResponse.fromWeb(
642 request,
643 Response.json({ config: { creditUsagePercent: 12 } }),
644 ),
645 );
646 }),
647 ),
648 Effect.tap(({ usageLimits }) =>
649 Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)),
650 ),
651 ),
652 );
653
654 it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () =>
655 Effect.gen(function* () {
656 for (const environment of [
657 {
658 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
659 XAI_API_KEY: "different-api-account",
660 },
661 { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' },
662 { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' },
663 { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' },
664 {
665 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}',
666 GROK_OIDC_ISSUER: "https://custom.example",
667 },
668 {
669 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
670 GROK_MODELS_BASE_URL: "https://custom.example/v1",
671 },
672 {
673 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
674 GROK_OAUTH2_PRINCIPAL_TYPE: "Team",
675 },
676 {
677 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}',
678 GROK_OAUTH2_PRINCIPAL_ID: "team-id",
679 },
680 {
681 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}',
682 GROK_CONFIG: '{"auth_provider_command":"custom-auth"}',
683 },
684 {
685 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}',
686 GROK_CONFIG_PATH: "/custom-config.toml",
687 },
688 ]) {
689 const { usageLimits: limits } = yield* readGrokAccount({
690 HOME: "/definitely/not/a/grok-home",
691 ...environment,
692 }).pipe(
693 Effect.provideService(
694 HttpClient.HttpClient,
695 HttpClient.make(() => Effect.die("must not request another account's quota")),
696 ),
697 );
698 expect(limits.windows).toEqual([]);
699 expect(limits.unavailable?.reason).toBe("unsupported");
700 }
701 }),
702 );
703
704 it.effect(
705 "reports a missing login as unsupported and malformed credentials as a sanitized failure",
706 () =>
707 Effect.gen(function* () {
708 const fs = yield* FileSystem.FileSystem;
709 const directory = yield* fs.makeTempDirectoryScoped();
710 const client = HttpClient.make(() => Effect.die("must not request without credentials"));
711 const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe(
712 Effect.provideService(HttpClient.HttpClient, client),
713 );
714 expect(missing.unavailable?.reason).toBe("unsupported");
715 for (const contents of [
716 "private-token-invalid-json",
717 '{"https://accounts.x.ai/sign-in":{"key":42}}',
718 ]) {
719 const { usageLimits: malformed } = yield* readGrokAccount({
720 GROK_HOME: directory,
721 GROK_AUTH: contents,
722 }).pipe(Effect.provideService(HttpClient.HttpClient, client));
723 expect(malformed.unavailable).toEqual({
724 reason: "probeFailed",
725 message: "Grok could not read usage limits.",
726 });
727 }
728 }).pipe(Effect.scoped),
729 );
730
731 it.effect(
732 "does not request subscription limits for custom account or endpoint configuration",
733 () =>
734 Effect.gen(function* () {
735 for (const config of [
736 '[auth]\nprovider_command = "custom-auth"',
737 '[grok_com_config]\nissuer = "https://custom.example"',
738 'endpoints.proxy = "https://custom.example"',
739 ]) {
740 const { usageLimits: limits } = yield* readGrokAccount({
741 GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}',
742 }).pipe(
743 Effect.provideService(
744 FileSystem.FileSystem,
745 FileSystem.makeNoop({
746 readFileString: (path) => {
747 expect(path.endsWith("config.toml")).toBe(true);
748 return Effect.succeed(config);
749 },
750 }),
751 ),
752 Effect.provideService(
753 HttpClient.HttpClient,
754 HttpClient.make(() => Effect.die("must not request another account's quota")),
755 ),
756 );
757 expect(limits.windows).toEqual([]);
758 expect(limits.unavailable?.reason).toBe("unsupported");
759 }
760 }),
761 );
762
763 it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () =>
764 Effect.gen(function* () {
765 for (const response of [
766 new Response("private response", { status: 401 }),
767 Response.json({ config: { creditUsagePercent: "private-value" } }),
768 ]) {
769 const { email, usageLimits: limits } = yield* readGrokAccount({
770 HOME: "/definitely/not/a/grok-home",
771 GROK_AUTH:
772 '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}',
773 }).pipe(
774 Effect.provideService(
775 HttpClient.HttpClient,
776 HttpClient.make((request) =>
777 Effect.succeed(HttpClientResponse.fromWeb(request, response)),
778 ),
779 ),
780 );
781 expect(email).toBe("someone@example.com");
782 expect(limits.windows).toEqual([]);
783 expect(limits.unavailable).toEqual({
784 reason: "probeFailed",
785 message: "Grok could not read usage limits.",
786 });
787 }
788 }),
789 );
790});
791
792it.layer(NodeServices.layer)("readGrokAccount email", (it) => {
793 it.effect("names the account whose limits were read, and nothing for other auth", () =>
794 Effect.gen(function* () {
795 const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"';
796 const cases = [
797 [
798 { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` },
799 "Someone@Example.com",
800 ],
801 [
802 {
803 GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`,
804 },
805 "current@example.com",
806 ],
807 [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined],
808 [
809 {
810 GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`,
811 XAI_API_KEY: "api-key",
812 },
813 undefined,
814 ],
815 [
816 {
817 GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`,
818 },
819 undefined,
820 ],
821 [{ GROK_AUTH: "not-json" }, undefined],
822 ] as const;
823 for (const [environment, expected] of cases) {
824 const { email, usageLimits } = yield* readGrokAccount({
825 HOME: "/definitely/not/a/grok-home",
826 ...environment,
827 }).pipe(
828 Effect.provideService(
829 HttpClient.HttpClient,
830 HttpClient.make((request) =>
831 Effect.succeed(
832 HttpClientResponse.fromWeb(
833 request,
834 Response.json({ config: { creditUsagePercent: 25 } }),
835 ),
836 ),
837 ),
838 ),
839 );
840 expect(email).toBe(expected);
841 if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25);
842 }
843 }),
844 );
845});
apps/server/src/provider/Layers/GrokProvider.ts deleted-586
......@@ -1,586 +0,0 @@
1import {
2 type CustomModelSetting,
3 type GrokSettings,
4 type ModelCapabilities,
5 type ServerProvider,
6 type ServerProviderAuth,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import type * as EffectAcpSchema from "effect-acp/compat";
11import { causeErrorTag } from "@t3tools/shared/observability";
12import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import { HttpClient } from "effect/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/process";
21import { createModelCapabilities } from "@t3tools/shared/model";
22import { resolveSpawnCommand } from "@t3tools/shared/shell";
23
24import {
25 AUTH_PROBE_TIMEOUT_MS,
26 buildServerProvider,
27 COMPACT_SLASH_COMMAND,
28 isCommandMissingCause,
29 parseGenericCliVersion,
30 providerModelsFromSettings,
31 spawnAndCollect,
32 type ServerProviderDraft,
33} from "../providerSnapshot.ts";
34import {
35 enrichProviderSnapshotWithVersionAdvisory,
36 type ProviderMaintenanceCapabilities,
37} from "../providerMaintenance.ts";
38import {
39 GROK_DEFAULT_MODEL_SLUG,
40 GROK_SUPPORTED_RUNTIME_MODES,
41 isValidGrokReasoningEffortToken,
42 makeGrokAcpRuntime,
43 resolveGrokAcpBaseModelId,
44} from "../acp/GrokAcpSupport.ts";
45import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts";
46import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
47
48const GROK_PRESENTATION = {
49 displayName: "Grok",
50 supportsConversationRollback: false,
51 showInteractionModeToggle: false,
52 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
53} as const;
54const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
55 optionDescriptors: [],
56});
57
58const VERSION_PROBE_TIMEOUT_MS = 4_000;
59// `initialize` is a single local round trip, so this is generous even on slow machines.
60const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000;
61const GROK_API_KEY_ENV = "XAI_API_KEY";
62
63const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
64 {
65 slug: GROK_DEFAULT_MODEL_SLUG,
66 name: "Grok Build",
67 isCustom: false,
68 capabilities: EMPTY_CAPABILITIES,
69 },
70];
71
72export function buildInitialGrokProviderSnapshot(
73 grokSettings: GrokSettings,
74): Effect.Effect<ServerProviderDraft> {
75 return Effect.gen(function* () {
76 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
77 const models = grokModelsFromSettings(grokSettings.customModels);
78
79 if (!grokSettings.enabled) {
80 return buildServerProvider({
81 presentation: GROK_PRESENTATION,
82 enabled: false,
83 checkedAt,
84 models,
85 probe: {
86 installed: false,
87 version: null,
88 status: "warning",
89 auth: { status: "unknown" },
90 message: "Grok is disabled in T3 Code settings.",
91 },
92 });
93 }
94
95 return buildServerProvider({
96 presentation: GROK_PRESENTATION,
97 enabled: true,
98 checkedAt,
99 models,
100 probe: {
101 installed: true,
102 version: null,
103 status: "warning",
104 auth: { status: "unknown" },
105 message: "Checking Grok CLI availability...",
106 },
107 });
108 });
109}
110
111function grokModelsFromSettings(
112 customModels: ReadonlyArray<CustomModelSetting> | undefined,
113 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
114): ReadonlyArray<ServerProviderModel> {
115 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
116}
117
118function nonEmptyString(value: unknown): string | undefined {
119 return typeof value === "string" ? value.trim() || undefined : undefined;
120}
121
122function isRecord(value: unknown): value is Record<string, unknown> {
123 return typeof value === "object" && value !== null && !Array.isArray(value);
124}
125
126function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
127 readonly options: ReadonlyArray<{
128 value: string;
129 label: string;
130 description?: string;
131 isDefault?: boolean;
132 }>;
133 readonly currentValue: string | undefined;
134} {
135 const meta = model._meta;
136 if (!meta || meta.supportsReasoningEffort === false) {
137 return { options: [], currentValue: undefined };
138 }
139
140 const currentEffort = nonEmptyString(meta.reasoningEffort);
141 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
142 const seen = new Set<string>();
143 const options: Array<{
144 value: string;
145 label: string;
146 description?: string;
147 advertisedDefault: boolean;
148 }> = [];
149
150 for (const entry of advertisedOptions) {
151 if (!isRecord(entry)) {
152 continue;
153 }
154 const rawValue = nonEmptyString(entry.value);
155 const rawId = nonEmptyString(entry.id);
156 const value =
157 rawValue && isValidGrokReasoningEffortToken(rawValue)
158 ? rawValue
159 : rawId && isValidGrokReasoningEffortToken(rawId)
160 ? rawId
161 : undefined;
162 if (value === undefined || seen.has(value)) {
163 continue;
164 }
165 seen.add(value);
166 const description = nonEmptyString(entry.description);
167 options.push({
168 value,
169 label: nonEmptyString(entry.label) ?? value,
170 ...(description ? { description } : {}),
171 advertisedDefault: entry.default === true || entry.isDefault === true,
172 });
173 }
174
175 const currentValue =
176 currentEffort && options.some((option) => option.value === currentEffort)
177 ? currentEffort
178 : undefined;
179 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
180 const selectedDefault =
181 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
182 advertisedDefaults[0]?.value;
183 return {
184 options: options.map(({ value, label, description }) => ({
185 value,
186 label,
187 ...(description ? { description } : {}),
188 ...(value === selectedDefault ? { isDefault: true } : {}),
189 })),
190 currentValue: currentValue ?? selectedDefault,
191 };
192}
193
194export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
195 const reasoning = grokReasoningOptionsFromModel(model);
196 return reasoning.options.length > 0
197 ? createModelCapabilities({
198 optionDescriptors: [
199 {
200 id: "reasoningEffort",
201 label: "Reasoning",
202 type: "select",
203 options: reasoning.options.map((option) => ({
204 id: option.value,
205 label: option.label,
206 ...(option.description ? { description: option.description } : {}),
207 ...(option.isDefault ? { isDefault: true } : {}),
208 })),
209 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
210 },
211 ],
212 })
213 : EMPTY_CAPABILITIES;
214}
215
216/** Models advertised by the ACP agent, with the session's current model marked as default. */
217export function buildGrokModelsFromSessionModelState(
218 modelState: EffectAcpSchema.SessionModelState | null | undefined,
219): ReadonlyArray<ServerProviderModel> {
220 if (!modelState || modelState.availableModels.length === 0) {
221 return [];
222 }
223 const currentModelId = modelState.currentModelId.trim();
224 const seen = new Set<string>();
225 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
226 const slug = resolveGrokAcpBaseModelId(model.modelId);
227 if (!slug || seen.has(slug)) {
228 return [];
229 }
230 seen.add(slug);
231 return [
232 {
233 slug,
234 name: model.name.trim() || slug,
235 isCustom: false,
236 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
237 capabilities: buildGrokModelCapabilities(model),
238 },
239 ];
240 });
241}
242
243export interface GrokModelsCliOutput {
244 /** True or false when the CLI printed a login line, null when it printed neither. */
245 readonly authenticated: boolean | null;
246 readonly models: ReadonlyArray<ServerProviderModel>;
247}
248
249/**
250 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
251 * text is the only signal. Current output looks like:
252 *
253 * You are logged in with grok.com.
254 * Default model: grok-4.6
255 * Available models:
256 * * grok-4.6 (default)
257 * - grok-4.5
258 */
259export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
260 const authenticated = /you are logged in/i.test(output)
261 ? true
262 : /not authenticated|not logged in/i.test(output)
263 ? false
264 : null;
265
266 const seen = new Set<string>();
267 const models: ServerProviderModel[] = [];
268 for (const line of output.split(/\r?\n/)) {
269 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
270 if (!bullet?.[1]) {
271 continue;
272 }
273 const slug = resolveGrokAcpBaseModelId(bullet[1]);
274 if (seen.has(slug)) {
275 continue;
276 }
277 seen.add(slug);
278 models.push({
279 slug,
280 name: displayNameFromGrokModelSlug(slug),
281 isCustom: false,
282 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
283 capabilities: EMPTY_CAPABILITIES,
284 });
285 }
286 return { authenticated, models };
287}
288
289function displayNameFromGrokModelSlug(slug: string): string {
290 return slug
291 .split(/[-_]/g)
292 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
293 .join(" ");
294}
295
296const runGrokCliCommand = (
297 grokSettings: GrokSettings,
298 args: ReadonlyArray<string>,
299 environment: NodeJS.ProcessEnv,
300) =>
301 Effect.gen(function* () {
302 const command = grokSettings.binaryPath || "grok";
303 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
304 return yield* spawnAndCollect(
305 command,
306 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
307 env: environment,
308 shell: spawnCommand.shell,
309 }),
310 );
311 });
312
313const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
314const decodeAvailableCommand = Schema.decodeUnknownOption(
315 Schema.Struct({
316 name: Schema.String,
317 description: Schema.String,
318 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
319 }),
320);
321
322export function grokSlashCommandsFromInitialize(
323 initialized: EffectAcpSchema.InitializeResponse,
324): ReadonlyArray<ServerProviderSlashCommand> {
325 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
326 const byName = new Map<string, ServerProviderSlashCommand>([
327 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
328 ]);
329 for (const entry of Option.getOrElse(commands, () => [])) {
330 const decoded = decodeAvailableCommand(entry);
331 if (Option.isNone(decoded)) continue;
332 const command = decoded.value;
333 const name = command.name.trim();
334 // Permission changes must go through T3 so the client and provider agree.
335 if (!name || name.toLowerCase() === "always-approve") continue;
336 // Grok advertises /context, but its ACP handler completes without emitting output.
337 if (name.toLowerCase() === "context") continue;
338 const description = command.description.trim();
339 const hint = command.input?.hint.trim();
340 byName.set(name, {
341 name,
342 ...(description ? { description } : {}),
343 ...(hint ? { input: { hint } } : {}),
344 });
345 }
346 return [...byName.values()];
347}
348
349/**
350 * Reads model and command metadata from `initialize._meta`. This never calls `authenticate`
351 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
352 */
353const discoverGrokMetadataViaAcpInitialize = (
354 grokSettings: GrokSettings,
355 environment: NodeJS.ProcessEnv,
356) =>
357 Effect.gen(function* () {
358 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
359 const acp = yield* makeGrokAcpRuntime({
360 grokSettings,
361 environment,
362 childProcessSpawner,
363 cwd: process.cwd(),
364 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
365 });
366 const initialized = yield* acp.initialize();
367 return {
368 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
369 slashCommands: grokSlashCommandsFromInitialize(initialized),
370 };
371 }).pipe(Effect.scoped);
372
373export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
374 grokSettings: GrokSettings,
375 environment: NodeJS.ProcessEnv = process.env,
376 cwd?: string,
377): Effect.fn.Return<
378 ServerProviderDraft,
379 never,
380 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
381> {
382 const checkedAt = DateTime.formatIso(yield* DateTime.now);
383 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
384
385 if (!grokSettings.enabled) {
386 return buildServerProvider({
387 presentation: GROK_PRESENTATION,
388 enabled: false,
389 checkedAt,
390 models: fallbackModels,
391 probe: {
392 installed: false,
393 version: null,
394 status: "warning",
395 auth: { status: "unknown" },
396 message: "Grok is disabled in T3 Code settings.",
397 },
398 });
399 }
400
401 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
402 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
403 Effect.result,
404 );
405
406 if (Result.isFailure(versionResult)) {
407 const error = versionResult.failure;
408 yield* Effect.logWarning("Grok CLI health check failed.", {
409 errorTag: error._tag,
410 });
411 return buildServerProvider({
412 presentation: GROK_PRESENTATION,
413 enabled: grokSettings.enabled,
414 checkedAt,
415 models: fallbackModels,
416 probe: {
417 installed: !isCommandMissingCause(error),
418 version: null,
419 status: "error",
420 auth: { status: "unknown" },
421 message: isCommandMissingCause(error)
422 ? "Grok CLI (`grok`) is not installed or not on PATH."
423 : "Failed to execute Grok CLI health check.",
424 },
425 });
426 }
427
428 if (Option.isNone(versionResult.success)) {
429 return buildServerProvider({
430 presentation: GROK_PRESENTATION,
431 enabled: grokSettings.enabled,
432 checkedAt,
433 models: fallbackModels,
434 probe: {
435 installed: true,
436 version: null,
437 status: "error",
438 auth: { status: "unknown" },
439 message: "Grok CLI is installed but timed out while running `grok --version`.",
440 },
441 });
442 }
443
444 const versionOutput = versionResult.success.value;
445 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
446 if (versionOutput.code !== 0) {
447 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
448 exitCode: versionOutput.code,
449 stdoutLength: versionOutput.stdout.length,
450 stderrLength: versionOutput.stderr.length,
451 });
452 return buildServerProvider({
453 presentation: GROK_PRESENTATION,
454 enabled: grokSettings.enabled,
455 checkedAt,
456 models: fallbackModels,
457 probe: {
458 installed: true,
459 version,
460 status: "error",
461 auth: { status: "unknown" },
462 message: "Grok CLI is installed but failed to run.",
463 },
464 });
465 }
466
467 // `grok models` reports login state and model slugs without starting the agent.
468 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
469 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
470 Effect.result,
471 );
472 // Only a clean exit is parsed. Failed invocations print help or error text that
473 // must not be read as model slugs or as a login verdict.
474 const modelsOutput =
475 Result.isSuccess(modelsResult) &&
476 Option.isSome(modelsResult.success) &&
477 modelsResult.success.value.code === 0
478 ? modelsResult.success.value
479 : undefined;
480 const cliModels: GrokModelsCliOutput = modelsOutput
481 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
482 : { authenticated: null, models: [] };
483 if (!modelsOutput) {
484 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
485 errorTag: Result.isFailure(modelsResult)
486 ? modelsResult.failure._tag
487 : Option.isNone(modelsResult.success)
488 ? "Timeout"
489 : `ExitCode${modelsResult.success.value.code}`,
490 });
491 }
492
493 const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim()
494 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
495 : cliModels.authenticated === true
496 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
497 : cliModels.authenticated === false
498 ? { status: "unauthenticated" }
499 : { status: "unknown" };
500
501 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
502 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
503 Effect.orElseSucceed(() => []),
504 );
505
506 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe(
507 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
508 Effect.exit,
509 );
510 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
511 const acpModels = acpMetadata?.models ?? [];
512 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
513 if (acpFailed) {
514 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
515 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
516 });
517 }
518
519 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
520 const models =
521 discoveredModels.length > 0
522 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
523 : fallbackModels;
524
525 if (auth.status === "unauthenticated") {
526 return buildServerProvider({
527 presentation: GROK_PRESENTATION,
528 enabled: grokSettings.enabled,
529 checkedAt,
530 models,
531 skills,
532 probe: {
533 installed: true,
534 version,
535 status: "error",
536 auth,
537 message: "Grok CLI is installed but not logged in. Run `grok login`.",
538 },
539 });
540 }
541
542 return buildServerProvider({
543 presentation: GROK_PRESENTATION,
544 enabled: grokSettings.enabled,
545 checkedAt,
546 models,
547 skills,
548 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
549 probe: {
550 installed: true,
551 version,
552 // A failed metadata probe degrades the model picker, it does not make chats fail.
553 status: acpFailed ? "warning" : "ready",
554 auth,
555 ...(acpFailed
556 ? {
557 message:
558 "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.",
559 }
560 : {}),
561 },
562 });
563});
564
565export const enrichGrokSnapshot = (input: {
566 readonly snapshot: ServerProvider;
567 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
568 readonly enableProviderUpdateChecks?: boolean;
569 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
570 readonly httpClient: HttpClient.HttpClient;
571}): Effect.Effect<void> => {
572 const { snapshot, publishSnapshot } = input;
573
574 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
575 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
576 }).pipe(
577 Effect.provideService(HttpClient.HttpClient, input.httpClient),
578 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
579 Effect.catchCause((cause) =>
580 Effect.logWarning("Grok version advisory enrichment failed", {
581 errorTag: causeErrorTag(cause),
582 }),
583 ),
584 Effect.asVoid,
585 );
586};
apps/server/src/provider/Layers/OpenCodeProvider.test.ts deleted-873
......@@ -1,873 +0,0 @@
1import * as NodeAssert from "node:assert/strict";
2import * as NodeCrypto from "node:crypto";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as TestClock from "effect/testing/TestClock";
13import { HttpClient, HttpClientResponse } from "effect/http";
14import { beforeEach } from "vite-plus/test";
15
16import { OpenCodeSettings } from "@t3tools/contracts";
17import * as ServerConfig from "../../config.ts";
18import * as OpenCodeRuntime from "../opencodeRuntime.ts";
19import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
20import {
21 checkOpenCodeProviderStatus,
22 loadOpenCode2Workspace,
23 makeOpenCode2ModelLoader,
24 type OpenCode2Model,
25 type OpenCode2Workspace,
26 openCode2CommandsToServerProviderSlashCommands,
27 openCode2SkillsToServerProviderSkills,
28 openCodeCommandsToServerProviderSlashCommands,
29} from "./OpenCodeProvider.ts";
30import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts";
31import { probeOpenCodeRuntime } from "../opencodeVersionProbe.ts";
32import {
33 OPENCODE_1_RESPONSES,
34 OPENCODE_2_RESPONSES,
35 replayOpenCodeServer,
36} from "../testFixtures/opencodeProbeResponses.ts";
37const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
38
39const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
40
41it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () =>
42 Effect.gen(function* () {
43 const resetsAt = "2026-09-17T12:00:00.000Z";
44 const limits = yield* readOpenCodeGoUsageLimits({
45 enabled: true,
46 serverUrl: "",
47 environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" },
48 }).pipe(
49 Effect.provideService(
50 FileSystem.FileSystem,
51 FileSystem.makeNoop({
52 readFileString: (path) => {
53 NodeAssert.equal(path, "/instance/data/opencode/auth.json");
54 return Effect.succeed(
55 JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }),
56 );
57 },
58 }),
59 ),
60 Effect.provideService(
61 HttpClient.HttpClient,
62 HttpClient.make((request) => {
63 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
64 NodeAssert.equal(request.headers.authorization, "Bearer instance-key");
65 return Effect.succeed(
66 HttpClientResponse.fromWeb(
67 request,
68 Response.json({
69 usage: {
70 rolling: { percent: 0, resetsAt },
71 weekly: { percent: 10, resetsAt },
72 monthly: { percent: 125, resetsAt },
73 },
74 }),
75 ),
76 );
77 }),
78 ),
79 Effect.provide(NodeServices.layer),
80 );
81 NodeAssert.equal(limits.unavailable, undefined);
82 NodeAssert.equal(
83 limits.credentialFingerprint,
84 NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"),
85 );
86 NodeAssert.deepEqual(
87 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
88 kind,
89 usedPercent,
90 reset,
91 })),
92 [
93 { kind: "session", usedPercent: 0, reset: resetsAt },
94 { kind: "weekly", usedPercent: 10, reset: resetsAt },
95 { kind: "monthly", usedPercent: 100, reset: resetsAt },
96 ],
97 );
98 }),
99);
100
101it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
102 Effect.gen(function* () {
103 for (const settings of [
104 { enabled: true, serverUrl: "https://remote.example" },
105 { enabled: false, serverUrl: "" },
106 ]) {
107 const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe(
108 Effect.provideService(
109 FileSystem.FileSystem,
110 FileSystem.makeNoop({
111 readFileString: () => Effect.die("unexpected credential read"),
112 }),
113 ),
114 Effect.provideService(
115 HttpClient.HttpClient,
116 HttpClient.make(() => Effect.die("unexpected usage request")),
117 ),
118 Effect.provide(NodeServices.layer),
119 );
120 NodeAssert.equal(limits.unavailable?.reason, "unsupported");
121 }
122 }),
123);
124
125it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () =>
126 Effect.gen(function* () {
127 for (const [status, reason] of [
128 [403, "unsupported"],
129 [401, "probeFailed"],
130 [200, "probeFailed"],
131 ] as const) {
132 const limits = yield* readOpenCodeGoUsageLimits({
133 enabled: true,
134 serverUrl: "",
135 environment: {
136 OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}',
137 },
138 }).pipe(
139 Effect.provideService(
140 FileSystem.FileSystem,
141 FileSystem.makeNoop({
142 readFileString: () => Effect.die("inline credentials must bypass disk"),
143 }),
144 ),
145 Effect.provideService(
146 HttpClient.HttpClient,
147 HttpClient.make((request) =>
148 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))),
149 ),
150 ),
151 Effect.provide(NodeServices.layer),
152 );
153 NodeAssert.equal(limits.unavailable?.reason, reason);
154 NodeAssert.equal(limits.credentialFingerprint, undefined);
155 NodeAssert.deepEqual(limits.windows, []);
156 }
157 }),
158);
159
160/**
161 * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag
162 * are deleted. The snapshot-producing logic they wrapped now lives in the
163 * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which
164 * drivers call directly when building their per-instance snapshot
165 * `ServerProviderShape`. Tests mirror that shape: build a settings payload,
166 * invoke the check, assert on the returned snapshot.
167 */
168
169const runtimeMock = {
170 state: {
171 runVersionError: null as Error | null,
172 runVersionPending: false,
173 versionStdout: DEFAULT_VERSION_STDOUT,
174 inventoryError: null as Error | null,
175 connectionError: null as Error | null,
176 inventoryCwd: null as string | null,
177 closeCalls: 0,
178 sdkClientInputs: [] as Array<{
179 baseUrl: string;
180 directory: string;
181 serverPassword?: string;
182 }>,
183 inventory: {
184 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
185 agents: [] as unknown[],
186 skills: [] as unknown[],
187 } as unknown,
188 },
189 reset() {
190 this.state.runVersionError = null;
191 this.state.runVersionPending = false;
192 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
193 this.state.inventoryError = null;
194 this.state.connectionError = null;
195 this.state.inventoryCwd = null;
196 this.state.closeCalls = 0;
197 this.state.sdkClientInputs.length = 0;
198 this.state.inventory = {
199 providerList: { connected: [], all: [] as unknown[], default: {} },
200 agents: [] as unknown[],
201 skills: [] as unknown[],
202 };
203 },
204};
205
206const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
207 startOpenCodeServerProcess: ({ serverPassword, environment }) =>
208 Effect.gen(function* () {
209 yield* Effect.addFinalizer(() =>
210 Effect.sync(() => {
211 runtimeMock.state.closeCalls += 1;
212 }),
213 );
214 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
215 external: false,
216 ...(serverPassword !== undefined ? { serverPassword } : {}),
217 ...(environment !== undefined ? { environment } : {}),
218 });
219 return {
220 url: "http://127.0.0.1:4301",
221 ...(effectiveServerPassword !== undefined
222 ? { serverPassword: effectiveServerPassword }
223 : {}),
224 version: "1.14.19",
225 isRunning: Effect.succeed(true),
226 exitCode: Effect.never,
227 };
228 }),
229 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
230 Effect.gen(function* () {
231 if (runtimeMock.state.connectionError) {
232 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
233 operation: "global.health",
234 detail: runtimeMock.state.connectionError.message,
235 cause: runtimeMock.state.connectionError,
236 });
237 }
238 if (!serverUrl) {
239 yield* Effect.addFinalizer(() =>
240 Effect.sync(() => {
241 runtimeMock.state.closeCalls += 1;
242 }),
243 );
244 }
245 return {
246 url: serverUrl ?? "http://127.0.0.1:4301",
247 ...(serverPassword ? { serverPassword } : {}),
248 version: "1.14.19",
249 exitCode: null,
250 external: Boolean(serverUrl),
251 };
252 }),
253 runOpenCodeCommand: () =>
254 runtimeMock.state.runVersionPending
255 ? Effect.never
256 : runtimeMock.state.runVersionError
257 ? Effect.fail(
258 new OpenCodeRuntime.OpenCodeRuntimeError({
259 operation: "runOpenCodeCommand",
260 detail: runtimeMock.state.runVersionError.message,
261 cause: runtimeMock.state.runVersionError,
262 }),
263 )
264 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
265 createOpenCodeSdkClient: (input) => {
266 runtimeMock.state.sdkClientInputs.push(input);
267 return {} as unknown as ReturnType<
268 OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]
269 >;
270 },
271 loadOpenCodeInventory: () =>
272 runtimeMock.state.inventoryError
273 ? Effect.fail(
274 new OpenCodeRuntime.OpenCodeRuntimeError({
275 operation: "loadOpenCodeInventory",
276 detail: runtimeMock.state.inventoryError.message,
277 cause: runtimeMock.state.inventoryError,
278 }),
279 )
280 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory),
281 loadInventoryFromCli: ({ cwd }) => {
282 runtimeMock.state.inventoryCwd = cwd;
283 return runtimeMock.state.inventoryError
284 ? Effect.fail(
285 new OpenCodeRuntime.OpenCodeRuntimeError({
286 operation: "loadInventoryFromCli",
287 detail: runtimeMock.state.inventoryError.message,
288 cause: runtimeMock.state.inventoryError,
289 }),
290 )
291 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory);
292 },
293 loadOpenCodeSkills: () => Effect.succeed([]),
294 loadSkillsFromCli: () => Effect.succeed([]),
295};
296
297beforeEach(() => {
298 runtimeMock.reset();
299});
300
301it("keeps native and MCP commands while preserving compaction and separate skills", () => {
302 NodeAssert.deepEqual(
303 openCodeCommandsToServerProviderSlashCommands([
304 { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] },
305 { name: "review", source: "command", hints: [] },
306 { name: "compact", source: "command", hints: [] },
307 { name: "skill", source: "skill", hints: [] },
308 { name: "mcp:search", source: "mcp", hints: ["query"] },
309 ]).slice(1),
310 [
311 { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } },
312 { name: "mcp:search", input: { hint: "query" } },
313 ],
314 );
315});
316
317const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
318 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
319 Layer.provideMerge(NodeServices.layer),
320);
321
322const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
323 decodeOpenCodeSettings({
324 enabled: true,
325 binaryPath: "opencode",
326 serverUrl: "",
327 serverPassword: "",
328 customModels: [],
329 ...overrides,
330 });
331
332const checkProvider = Effect.fn("checkProvider")(function* (
333 settings: OpenCodeSettings,
334 cwd = process.cwd(),
335 environment?: NodeJS.ProcessEnv,
336 server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
337 openCode2Models: Effect.Effect<
338 ReadonlyArray<OpenCode2Model>,
339 OpenCodeRuntime.OpenCodeRuntimeError
340 > = Effect.succeed([]),
341) {
342 return yield* Effect.scoped(
343 Effect.gen(function* () {
344 const serverOwner = yield* OpenCodeServerOwner.make({
345 binaryPath: settings.binaryPath,
346 directory: cwd,
347 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
348 ...(environment ? { environment } : {}),
349 });
350 const probe = probeOpenCodeRuntime(settings, environment).pipe(
351 Effect.provideService(HttpClient.HttpClient, server),
352 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
353 );
354 return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe(
355 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
356 );
357 }),
358 );
359});
360
361it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
362 it.effect("shows a codex-style missing binary message", () =>
363 Effect.gen(function* () {
364 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
365 const snapshot = yield* checkProvider(makeOpenCodeSettings());
366
367 NodeAssert.equal(snapshot.status, "error");
368 NodeAssert.equal(snapshot.installed, false);
369 NodeAssert.equal(
370 snapshot.message,
371 "OpenCode CLI (`opencode`) is not installed or not on PATH.",
372 );
373 }),
374 );
375
376 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
377 Effect.gen(function* () {
378 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
379 const snapshot = yield* checkProvider(makeOpenCodeSettings());
380
381 NodeAssert.equal(snapshot.status, "error");
382 NodeAssert.equal(snapshot.installed, true);
383 NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
384 }),
385 );
386
387 it.effect("times out a hanging local CLI version probe", () =>
388 Effect.gen(function* () {
389 runtimeMock.state.runVersionPending = true;
390 const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
391
392 yield* Effect.yieldNow;
393 yield* TestClock.adjust("4 seconds");
394 const snapshot = yield* Fiber.join(probeFiber);
395
396 NodeAssert.equal(snapshot.status, "error");
397 NodeAssert.equal(snapshot.installed, true);
398 NodeAssert.equal(
399 snapshot.message,
400 "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
401 );
402 }).pipe(Effect.provide(TestClock.layer())),
403 );
404
405 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
406 Effect.gen(function* () {
407 runtimeMock.state.inventory = {
408 providerList: {
409 connected: ["openai"],
410 all: [
411 {
412 id: "openai",
413 name: "OpenAI",
414 models: {
415 "gpt-5.4": {
416 id: "gpt-5.4",
417 name: "GPT-5.4",
418 variants: {
419 none: {},
420 low: {},
421 medium: {},
422 high: {},
423 xhigh: {},
424 },
425 },
426 },
427 },
428 ],
429 default: {},
430 },
431 agents: [
432 { name: "build", hidden: false, mode: "primary" },
433 { name: "plan", hidden: false, mode: "primary" },
434 ],
435 };
436
437 const snapshot = yield* checkProvider(makeOpenCodeSettings());
438 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
439
440 NodeAssert.ok(model);
441 const variantDescriptor = model.capabilities?.optionDescriptors?.find(
442 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
443 );
444 NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select");
445 NodeAssert.equal(
446 variantDescriptor.options.find((option) => option.isDefault === true)?.id,
447 "medium",
448 );
449 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
450 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
451 );
452 NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select");
453 NodeAssert.equal(
454 agentDescriptor.options.find((option) => option.isDefault === true)?.id,
455 "build",
456 );
457 }),
458 );
459
460 it.effect("includes OpenCode skills in the provider snapshot", () =>
461 Effect.gen(function* () {
462 runtimeMock.state.inventory = {
463 providerList: {
464 connected: ["openai"],
465 all: [
466 {
467 id: "openai",
468 name: "OpenAI",
469 models: {
470 "gpt-5.4": {
471 id: "gpt-5.4",
472 name: "GPT-5.4",
473 variants: {},
474 },
475 },
476 },
477 ],
478 default: {},
479 },
480 agents: [],
481 skills: [
482 {
483 name: "openclaw-review",
484 description: "Review OpenClaw workflow changes.",
485 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
486 },
487 {
488 name: "openclaw-triage",
489 description: "Triage OpenClaw routing issues.",
490 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
491 },
492 {
493 name: "missing-location",
494 description: "This incomplete SDK row should be skipped.",
495 location: "",
496 },
497 ],
498 };
499
500 const snapshot = yield* checkProvider(makeOpenCodeSettings());
501
502 NodeAssert.deepEqual(
503 snapshot.skills.map((skill) => ({
504 name: skill.name,
505 path: skill.path,
506 enabled: skill.enabled,
507 shortDescription: skill.shortDescription,
508 })),
509 [
510 {
511 name: "openclaw-review",
512 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
513 enabled: true,
514 shortDescription: "Review OpenClaw workflow changes.",
515 },
516 {
517 name: "openclaw-triage",
518 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
519 enabled: true,
520 shortDescription: "Triage OpenClaw routing issues.",
521 },
522 ],
523 );
524 }),
525 );
526
527 it.effect("loads local inventory from a scoped OpenCode server", () =>
528 Effect.gen(function* () {
529 yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" }));
530
531 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
532 {
533 baseUrl: "http://127.0.0.1:4301",
534 directory: process.cwd(),
535 serverPassword: "secret-password",
536 },
537 ]);
538 NodeAssert.equal(runtimeMock.state.closeCalls, 1);
539 NodeAssert.equal(runtimeMock.state.inventoryCwd, null);
540 }),
541 );
542
543 it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
544 Effect.gen(function* () {
545 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
546 const snapshot = yield* checkProvider(
547 makeOpenCodeSettings(),
548 process.cwd(),
549 undefined,
550 undefined,
551 Effect.succeed([
552 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
553 {
554 providerID: "opencode",
555 id: "space-bunny-free",
556 name: "Space Bunny Free",
557 variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
558 },
559 ]),
560 );
561
562 NodeAssert.equal(snapshot.status, "ready");
563 NodeAssert.equal(snapshot.version, "2.0.18");
564 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
565 "approval-required",
566 "auto-accept-edits",
567 "auto",
568 "full-access",
569 ]);
570 // Plan mode runs as OpenCode's plan agent, and every session can compact.
571 NodeAssert.equal(snapshot.showInteractionModeToggle, true);
572 NodeAssert.deepEqual(
573 snapshot.slashCommands.map((command) => command.name),
574 ["compact"],
575 );
576 NodeAssert.deepEqual(
577 snapshot.models.map((model) => model.slug),
578 ["opencode/big-pickle", "opencode/space-bunny-free"],
579 );
580 const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
581 ?.capabilities?.optionDescriptors?.[0];
582 NodeAssert.deepEqual(
583 variant?.type === "select" ? variant.options.map((option) => option.id) : [],
584 ["low", "medium", "high"],
585 );
586 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
587 }),
588 );
589
590 it.effect("reports a failed OpenCode 2 model list without the server's response", () =>
591 Effect.gen(function* () {
592 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
593 const snapshot = yield* checkProvider(
594 makeOpenCodeSettings(),
595 process.cwd(),
596 undefined,
597 undefined,
598 Effect.fail(
599 new OpenCodeRuntime.OpenCodeRuntimeError({
600 operation: "model.list",
601 detail: 'status=500 body={"token":"leaked-response-body"}',
602 }),
603 ),
604 );
605
606 NodeAssert.equal(snapshot.status, "error");
607 NodeAssert.equal(snapshot.message, "OpenCode could not load its model list.");
608 // A failed catalog read still leaves the session able to compact.
609 NodeAssert.deepEqual(
610 snapshot.slashCommands.map((command) => command.name),
611 ["compact"],
612 );
613 }),
614 );
615
616 it.effect("uses an environment-only password for local inventory", () =>
617 Effect.gen(function* () {
618 yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
619 OPENCODE_SERVER_PASSWORD: "environment-password",
620 });
621
622 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
623 {
624 baseUrl: "http://127.0.0.1:4301",
625 directory: process.cwd(),
626 serverPassword: "environment-password",
627 },
628 ]);
629 }),
630 );
631
632 it.effect("uses the settings password when local environment auth differs", () =>
633 Effect.gen(function* () {
634 yield* checkProvider(
635 makeOpenCodeSettings({ serverPassword: "settings-password" }),
636 process.cwd(),
637 { OPENCODE_SERVER_PASSWORD: "environment-password" },
638 );
639
640 NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password");
641 }),
642 );
643
644 it.effect("reports local model inventory failures without treating them as empty", () =>
645 Effect.gen(function* () {
646 runtimeMock.state.inventoryError = new Error("opencode models failed");
647 const snapshot = yield* checkProvider(makeOpenCodeSettings());
648
649 NodeAssert.equal(snapshot.status, "error");
650 NodeAssert.equal(snapshot.installed, true);
651 NodeAssert.equal(snapshot.models.length, 0);
652 NodeAssert.equal(
653 snapshot.message,
654 "Failed to load OpenCode provider inventory: opencode models failed",
655 );
656 }),
657 );
658});
659
660it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
661 it.effect("does not send a local environment password to a configured server", () =>
662 Effect.gen(function* () {
663 const snapshot = yield* checkProvider(
664 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
665 process.cwd(),
666 { OPENCODE_SERVER_PASSWORD: "local-secret" },
667 );
668
669 NodeAssert.equal(snapshot.version, "1.14.19");
670 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
671 {
672 baseUrl: "http://127.0.0.1:9999",
673 directory: process.cwd(),
674 },
675 ]);
676 }),
677 );
678
679 it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () =>
680 Effect.gen(function* () {
681 const settings = makeOpenCodeSettings({
682 serverUrl: "http://127.0.0.1:9999",
683 serverPassword: "secret-password",
684 });
685 const snapshot = yield* checkProvider(
686 settings,
687 process.cwd(),
688 undefined,
689 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
690 );
691
692 NodeAssert.equal(snapshot.status, "warning");
693 NodeAssert.equal(snapshot.version, "2.0.18");
694 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
695 "approval-required",
696 "auto-accept-edits",
697 "auto",
698 "full-access",
699 ]);
700 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
701 }),
702 );
703
704 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
705 Effect.gen(function* () {
706 const snapshot = yield* checkProvider(
707 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }),
708 process.cwd(),
709 undefined,
710 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
711 );
712
713 NodeAssert.equal(snapshot.status, "error");
714 NodeAssert.equal(
715 snapshot.message,
716 "OpenCode server rejected authentication. Check the server URL and password.",
717 );
718 }),
719 );
720
721 it.effect("rejects an unsupported server before loading inventory", () =>
722 Effect.gen(function* () {
723 runtimeMock.state.connectionError = new Error(
724 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
725 );
726 const snapshot = yield* checkProvider(
727 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
728 );
729
730 NodeAssert.equal(snapshot.status, "error");
731 NodeAssert.equal(snapshot.models.length, 0);
732 NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
733 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
734 }),
735 );
736
737 it.effect("surfaces a friendly auth error for configured servers", () =>
738 Effect.gen(function* () {
739 runtimeMock.state.connectionError = new Error("401 Unauthorized");
740 const snapshot = yield* checkProvider(
741 makeOpenCodeSettings({
742 serverUrl: "http://127.0.0.1:9999",
743 serverPassword: "secret-password",
744 }),
745 );
746
747 NodeAssert.equal(snapshot.status, "error");
748 NodeAssert.equal(snapshot.installed, true);
749 NodeAssert.equal(
750 snapshot.message,
751 "OpenCode server rejected authentication. Check the server URL and password.",
752 );
753 }),
754 );
755
756 it.effect("surfaces a friendly connection error for configured servers", () =>
757 Effect.gen(function* () {
758 runtimeMock.state.connectionError = new Error(
759 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
760 );
761 const snapshot = yield* checkProvider(
762 makeOpenCodeSettings({
763 serverUrl: "http://127.0.0.1:9999",
764 serverPassword: "secret-password",
765 }),
766 );
767
768 NodeAssert.equal(snapshot.status, "error");
769 NodeAssert.equal(snapshot.installed, true);
770 NodeAssert.equal(
771 snapshot.message,
772 "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.",
773 );
774 }),
775 );
776});
777
778const bigPickle: OpenCode2Model = {
779 providerID: "opencode",
780 id: "big-pickle",
781 name: "Big Pickle",
782 variants: [],
783};
784
785it.effect("waits for a fresh OpenCode 2 server to list its models", () =>
786 Effect.gen(function* () {
787 // A fresh server lists nothing until its catalog loads.
788 const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]];
789 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle]));
790 const fiber = yield* load.pipe(Effect.forkChild);
791 yield* TestClock.adjust("1 second");
792 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
793 }).pipe(Effect.provide(TestClock.layer())),
794);
795
796it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () =>
797 Effect.gen(function* () {
798 let listed: ReadonlyArray<OpenCode2Model> = [bigPickle];
799 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed));
800 NodeAssert.deepEqual(yield* load, [bigPickle]);
801 listed = [];
802 const fiber = yield* load.pipe(Effect.forkChild);
803 yield* TestClock.adjust("6 seconds");
804 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
805 }).pipe(Effect.provide(TestClock.layer())),
806);
807
808// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29).
809const builtinCommands = [
810 { name: "init", description: "guided AGENTS.md setup" },
811 { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" },
812];
813const builtinSkills = [
814 { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" },
815 { id: "report", name: "Report", path: "/builtin/report.md" },
816];
817const scanned: OpenCode2Workspace = {
818 commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
819 skills: [
820 ...builtinSkills,
821 {
822 id: "greet",
823 name: "greet",
824 path: "/work/.opencode/skills/greet/SKILL.md",
825 description: "Greets the user with the secret word MANGO.",
826 },
827 ],
828};
829
830it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () =>
831 Effect.gen(function* () {
832 // A directory the server has not served yet lists no commands until its scan ends.
833 const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
834 const scanEnded = yield* Deferred.make<void>();
835 const load = yield* loadOpenCode2Workspace(
836 Effect.sync(() => reads.shift()!),
837 Deferred.await(scanEnded),
838 ).pipe(Effect.forkChild);
839 yield* Effect.yieldNow;
840 NodeAssert.equal(reads.length, 1);
841 yield* Deferred.succeed(scanEnded, undefined);
842 NodeAssert.deepEqual(yield* Fiber.join(load), scanned);
843 }),
844);
845
846it.effect("takes a served OpenCode 2 directory's first listing as is", () =>
847 Effect.gen(function* () {
848 const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never);
849 NodeAssert.deepEqual(workspace, scanned);
850 }),
851);
852
853it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => {
854 NodeAssert.deepEqual(
855 openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [
856 skill.name,
857 skill.displayName,
858 skill.path,
859 ]),
860 [
861 ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"],
862 ["opencode", "OpenCode", "/builtin/opencode.md"],
863 ["report", "Report", "/builtin/report.md"],
864 ],
865 );
866 NodeAssert.deepEqual(
867 openCode2CommandsToServerProviderSlashCommands([
868 ...scanned.commands,
869 { name: "compact", description: "a workspace command named like T3's own" },
870 ]).map((command) => command.name),
871 ["compact", "init", "review", "hello"],
872 );
873});
apps/server/src/provider/Layers/OpenCodeProvider.ts deleted-747
......@@ -1,747 +0,0 @@
1import {
2 type ModelCapabilities,
3 type OpenCodeSettings,
4 type ServerProviderModel,
5 type ServerProviderSkill,
6 type ServerProviderSlashCommand,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14import { createModelCapabilities } from "@t3tools/shared/model";
15import { compareSemverVersions } from "@t3tools/shared/semver";
16import {
17 buildServerProvider,
18 COMPACT_SLASH_COMMAND,
19 nonEmptyTrimmed,
20 providerModelsFromSettings,
21 type ServerProviderDraft,
22} from "../providerSnapshot.ts";
23import * as OpenCodeRuntime from "../opencodeRuntime.ts";
24import type { ProbedOpenCode } from "../opencodeVersionProbe.ts";
25import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
26import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
27
28const OPENCODE_PRESENTATION = {
29 displayName: "OpenCode",
30 showInteractionModeToggle: false,
31} as const;
32
33class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
34 readonly cause?: unknown;
35 readonly detail: string;
36}> {}
37
38function normalizeProbeMessage(message: string): string | undefined {
39 const trimmed = message.trim();
40 if (trimmed.length === 0) {
41 return undefined;
42 }
43 if (
44 trimmed === "An error occurred in Effect.tryPromise" ||
45 trimmed === "An error occurred in Effect.try"
46 ) {
47 return undefined;
48 }
49 return trimmed;
50}
51
52function normalizedErrorMessage(cause: unknown): string | undefined {
53 if (cause instanceof OpenCodeProbeError) {
54 return normalizeProbeMessage(cause.detail);
55 }
56
57 if (!(cause instanceof Error)) {
58 return undefined;
59 }
60
61 return normalizeProbeMessage(cause.message);
62}
63
64function formatOpenCodeProbeError(input: {
65 readonly cause: unknown;
66 readonly isExternalServer: boolean;
67 readonly phase: "version" | "inventory";
68 readonly serverUrl: string;
69}): { readonly installed: boolean; readonly message: string } {
70 const detail = normalizedErrorMessage(input.cause);
71 const lower = detail?.toLowerCase() ?? "";
72
73 if (input.isExternalServer) {
74 if (
75 lower.includes("401") ||
76 lower.includes("403") ||
77 lower.includes("unauthorized") ||
78 lower.includes("forbidden")
79 ) {
80 return {
81 installed: true,
82 message: "OpenCode server rejected authentication. Check the server URL and password.",
83 };
84 }
85
86 if (
87 lower.includes("econnrefused") ||
88 lower.includes("enotfound") ||
89 lower.includes("fetch failed") ||
90 lower.includes("networkerror") ||
91 lower.includes("timed out") ||
92 lower.includes("timeout") ||
93 lower.includes("socket hang up")
94 ) {
95 return {
96 installed: true,
97 message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`,
98 };
99 }
100
101 return {
102 installed: true,
103 message: detail ?? "Failed to connect to the configured OpenCode server.",
104 };
105 }
106
107 if (lower.includes("enoent") || lower.includes("notfound")) {
108 return {
109 installed: false,
110 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
111 };
112 }
113
114 if (lower.includes("quarantine")) {
115 return {
116 installed: true,
117 message:
118 "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.",
119 };
120 }
121
122 if (lower.includes("invalid code signature") || lower.includes("corrupted")) {
123 return {
124 installed: true,
125 message:
126 "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.",
127 };
128 }
129
130 const failureLabel =
131 input.phase === "inventory"
132 ? "Failed to load OpenCode provider inventory"
133 : "Failed to execute OpenCode CLI health check";
134 return {
135 installed: true,
136 message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`,
137 };
138}
139
140function titleCaseSlug(value: string): string {
141 const segments: Array<string> = [];
142 for (const segment of value.split(/[-_/]+/)) {
143 if (segment.length > 0) {
144 segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
145 }
146 }
147 return segments.join(" ");
148}
149
150function inferDefaultVariant(
151 providerID: string,
152 variants: ReadonlyArray<string>,
153): string | undefined {
154 if (variants.length === 1) {
155 return variants[0];
156 }
157 if (providerID === "anthropic" || providerID.startsWith("google")) {
158 return variants.includes("high") ? "high" : undefined;
159 }
160 if (providerID === "openai" || providerID === "opencode") {
161 return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
162 }
163 return undefined;
164}
165
166function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
167 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
168}
169
170const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
171 optionDescriptors: [
172 {
173 id: "variant",
174 label: "Reasoning",
175 type: "select",
176 options: [
177 { id: "low", label: "Low" },
178 { id: "medium", label: "Medium", isDefault: true },
179 { id: "high", label: "High" },
180 { id: "xhigh", label: "Extra High" },
181 ],
182 currentValue: "medium",
183 },
184 {
185 id: "agent",
186 label: "Agent",
187 type: "select",
188 options: [
189 { id: "build", label: "Build", isDefault: true },
190 { id: "plan", label: "Plan" },
191 ],
192 currentValue: "build",
193 },
194 ],
195});
196
197function openCodeCapabilitiesForModel(input: {
198 readonly providerID: string;
199 readonly model: ProviderListResponse["all"][number]["models"][string];
200 readonly agents: ReadonlyArray<Agent>;
201}): ModelCapabilities {
202 const rawVariantValues = Object.keys(input.model.variants ?? {});
203 // When a model advertises no variants, synthesize the standard reasoning
204 // levels so the composer still offers a Reasoning selector (mirrors the
205 // Codex/Grok experience where reasoning is always configurable). The set
206 // covers the common OpenCode variant spectrum; `inferDefaultVariant`
207 // picks the provider-appropriate default (e.g. medium for openai/opencode).
208 const variantValues =
209 rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"];
210 const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
211 const variantOptions = variantValues.map((value) =>
212 defaultVariant === value
213 ? { id: value, label: titleCaseSlug(value), isDefault: true as const }
214 : { id: value, label: titleCaseSlug(value) },
215 );
216 const primaryAgents = input.agents.filter(
217 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
218 );
219 const defaultAgent = inferDefaultAgent(primaryAgents);
220 const agentOptions = primaryAgents.map((agent) =>
221 defaultAgent === agent.name
222 ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const }
223 : { id: agent.name, label: titleCaseSlug(agent.name) },
224 );
225 return createModelCapabilities({
226 optionDescriptors: [
227 ...(variantOptions.length > 0
228 ? [
229 {
230 id: "variant",
231 label: "Reasoning",
232 type: "select" as const,
233 options: variantOptions,
234 ...(defaultVariant ? { currentValue: defaultVariant } : {}),
235 },
236 ]
237 : []),
238 ...(agentOptions.length > 0
239 ? [
240 {
241 id: "agent",
242 label: "Agent",
243 type: "select" as const,
244 options: agentOptions,
245 ...(defaultAgent ? { currentValue: defaultAgent } : {}),
246 },
247 ]
248 : []),
249 ],
250 });
251}
252
253function flattenOpenCodeModels(
254 input: OpenCodeRuntime.OpenCodeInventory,
255): ReadonlyArray<ServerProviderModel> {
256 const connected = new Set(input.providerList.connected);
257 const models: Array<ServerProviderModel> = [];
258
259 for (const provider of input.providerList.all) {
260 if (!connected.has(provider.id)) {
261 continue;
262 }
263
264 for (const model of Object.values(provider.models)) {
265 const name = nonEmptyTrimmed(model.name);
266 if (!name) {
267 continue;
268 }
269
270 const subProvider = nonEmptyTrimmed(provider.name);
271 models.push({
272 slug: `${provider.id}/${model.id}`,
273 name,
274 ...(subProvider ? { subProvider } : {}),
275 isCustom: false,
276 capabilities: openCodeCapabilitiesForModel({
277 providerID: provider.id,
278 model,
279 agents: input.agents,
280 }),
281 });
282 }
283 }
284
285 return models.toSorted((left, right) => left.name.localeCompare(right.name));
286}
287
288function trimOptional(value: string | null | undefined): string | undefined {
289 const trimmed = value?.trim();
290 return trimmed && trimmed.length > 0 ? trimmed : undefined;
291}
292
293export function openCodeSkillsToServerProviderSkills(
294 input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined,
295): ReadonlyArray<ServerProviderSkill> {
296 const skills: ServerProviderSkill[] = [];
297 for (const skill of input ?? []) {
298 const name = trimOptional(skill.name);
299 const path = trimOptional(skill.location);
300 if (!name || !path) {
301 continue;
302 }
303
304 const description = trimOptional(skill.description);
305 skills.push({
306 name,
307 path,
308 enabled: true,
309 ...(description ? { description, shortDescription: description } : {}),
310 });
311 }
312
313 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
314}
315
316export function openCodeCommandsToServerProviderSlashCommands(
317 input: OpenCodeRuntime.OpenCodeInventory["commands"],
318): ReadonlyArray<ServerProviderSlashCommand> {
319 const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
320 const names = new Set([COMPACT_SLASH_COMMAND.name]);
321 for (const command of input ?? []) {
322 const name = trimOptional(command.name);
323 if (!name || names.has(name) || command.source === "skill") continue;
324 names.add(name);
325 const description = trimOptional(command.description);
326 const hint = trimOptional(command.hints.join(" "));
327 commands.push({
328 name,
329 ...(description ? { description } : {}),
330 ...(hint ? { input: { hint } } : {}),
331 });
332 }
333 return commands;
334}
335
336export const makePendingOpenCodeProvider = (
337 openCodeSettings: OpenCodeSettings,
338): Effect.Effect<ServerProviderDraft> =>
339 Effect.gen(function* () {
340 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
341 const models = providerModelsFromSettings(
342 [],
343 openCodeSettings.customModels,
344 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
345 );
346
347 if (!openCodeSettings.enabled) {
348 return buildServerProvider({
349 presentation: OPENCODE_PRESENTATION,
350 enabled: false,
351 checkedAt,
352 models,
353 probe: {
354 installed: false,
355 version: null,
356 status: "warning",
357 auth: { status: "unknown" },
358 message:
359 openCodeSettings.serverUrl.trim().length > 0
360 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
361 : "OpenCode is disabled in T3 Code settings.",
362 },
363 });
364 }
365
366 return buildServerProvider({
367 presentation: OPENCODE_PRESENTATION,
368 enabled: true,
369 checkedAt,
370 models,
371 probe: {
372 installed: false,
373 version: null,
374 status: "warning",
375 auth: { status: "unknown" },
376 message: "OpenCode provider status has not been checked in this session yet.",
377 },
378 });
379 });
380
381/** One model an OpenCode 2 server lists, as the status check reads it. */
382export interface OpenCode2Model {
383 readonly providerID: string;
384 readonly id: string;
385 readonly name: string;
386 readonly variants: ReadonlyArray<{ readonly id: string }>;
387}
388
389/**
390 * A server loads its catalog lazily and lists nothing for its first few
391 * hundred milliseconds, and a local instance starts a fresh server for each
392 * status check. So an empty list is read again for a while, and the last
393 * non-empty list stands in if the catalog still has not loaded.
394 */
395export const makeOpenCode2ModelLoader = <E>(
396 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
397) =>
398 Effect.sync(() => {
399 let lastLoaded: ReadonlyArray<OpenCode2Model> = [];
400 return list.pipe(
401 Effect.repeat({
402 until: (models) => models.length > 0,
403 schedule: Schedule.spaced("250 millis"),
404 }),
405 Effect.timeoutOption("5 seconds"),
406 Effect.map((loaded) => {
407 if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value;
408 return lastLoaded;
409 }),
410 );
411 });
412
413/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */
414export interface OpenCode2Workspace {
415 readonly skills: ReadonlyArray<{
416 readonly id: string;
417 readonly name: string;
418 readonly path: string;
419 readonly description?: string | undefined;
420 }>;
421 readonly commands: ReadonlyArray<{
422 readonly name: string;
423 readonly description?: string | undefined;
424 }>;
425}
426
427/**
428 * A server scans a directory it has not served yet in the background: its
429 * first read lists no commands at all (not even the built-ins), and
430 * `command.updated` plus `skill.updated` for the directory end the scan. A
431 * directory it already serves lists everything at once. `scanned` must be
432 * listening before `read` runs, since the read is what starts the scan.
433 */
434export const loadOpenCode2Workspace = <E>(
435 read: Effect.Effect<OpenCode2Workspace, E>,
436 scanned: Effect.Effect<void>,
437) =>
438 Effect.gen(function* () {
439 const first = yield* read;
440 if (first.commands.length > 0) return first;
441 yield* scanned.pipe(Effect.timeoutOption("10 seconds"));
442 return yield* read;
443 });
444
445export function openCode2SkillsToServerProviderSkills(
446 skills: OpenCode2Workspace["skills"],
447): ReadonlyArray<ServerProviderSkill> {
448 return skills
449 .map((skill) => {
450 const description = trimOptional(skill.description);
451 return {
452 name: skill.id,
453 path: skill.path,
454 enabled: true,
455 ...(skill.name === skill.id ? {} : { displayName: skill.name }),
456 ...(description ? { description, shortDescription: description } : {}),
457 };
458 })
459 .toSorted((left, right) => left.name.localeCompare(right.name));
460}
461
462export function openCode2CommandsToServerProviderSlashCommands(
463 commands: OpenCode2Workspace["commands"],
464): ReadonlyArray<ServerProviderSlashCommand> {
465 const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
466 const names = new Set([COMPACT_SLASH_COMMAND.name]);
467 for (const command of commands) {
468 const name = trimOptional(command.name);
469 if (!name || names.has(name)) continue;
470 names.add(name);
471 const description = trimOptional(command.description);
472 slashCommands.push({ name, ...(description ? { description } : {}) });
473 }
474 return slashCommands;
475}
476
477/**
478 * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised:
479 * OpenCode has no reviewer that approves routine actions. Plan mode is its
480 * `plan` agent, so the composer's mode toggle drives it.
481 */
482const OPENCODE_2_PRESENTATION = {
483 ...OPENCODE_PRESENTATION,
484 showInteractionModeToggle: true,
485 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"],
486} as const;
487
488function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities {
489 const variants = model.variants.map((variant) => variant.id);
490 const defaultVariant = inferDefaultVariant(model.providerID, variants);
491 return createModelCapabilities({
492 optionDescriptors:
493 variants.length === 0
494 ? []
495 : [
496 {
497 id: "variant",
498 label: "Reasoning",
499 type: "select",
500 options: variants.map((id) => ({
501 id,
502 label: titleCaseSlug(id),
503 ...(id === defaultVariant ? { isDefault: true } : {}),
504 })),
505 ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }),
506 },
507 ],
508 });
509}
510
511const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* (
512 settings: OpenCodeSettings,
513 version: string,
514 checkedAt: string,
515 loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>,
516) {
517 const result = yield* Effect.exit(loadModels);
518 const probe = (status: "ready" | "warning" | "error", message: string) => ({
519 installed: true,
520 version,
521 status,
522 auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) },
523 message,
524 });
525 if (result._tag === "Failure") {
526 // The detail can carry a response body; it stays in the log, not the status.
527 yield* Effect.logWarning("OpenCode 2 model list failed", result.cause);
528 return buildServerProvider({
529 presentation: OPENCODE_2_PRESENTATION,
530 enabled: true,
531 checkedAt,
532 models: providerModelsFromSettings(
533 [],
534 settings.customModels,
535 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
536 ),
537 // Compaction does not depend on the model catalog.
538 slashCommands: [COMPACT_SLASH_COMMAND],
539 probe: probe("error", "OpenCode could not load its model list."),
540 });
541 }
542 const models = providerModelsFromSettings(
543 result.value
544 .map((model) => ({
545 slug: `${model.providerID}/${model.id}`,
546 name: model.name,
547 isCustom: false,
548 capabilities: openCode2ModelCapabilities(model),
549 }))
550 .toSorted((left, right) => left.name.localeCompare(right.name)),
551 settings.customModels,
552 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
553 );
554 return buildServerProvider({
555 presentation: OPENCODE_2_PRESENTATION,
556 enabled: true,
557 checkedAt,
558 models,
559 // Every session can compact; a workspace snapshot adds that directory's commands.
560 slashCommands: [COMPACT_SLASH_COMMAND],
561 probe:
562 result.value.length > 0
563 ? probe(
564 "ready",
565 `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`,
566 )
567 : probe("warning", "OpenCode 2 is running, but it did not list any models yet."),
568 });
569});
570
571/**
572 * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary,
573 * the version endpoints for a configured server. The status check refreshes it, so an in-place
574 * upgrade re-routes the instance.
575 */
576export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
577 openCodeSettings: OpenCodeSettings,
578 cwd: string,
579 probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>,
580 loadOpenCode2Models: Effect.Effect<
581 ReadonlyArray<OpenCode2Model>,
582 OpenCodeRuntime.OpenCodeRuntimeError
583 >,
584): Effect.fn.Return<
585 ServerProviderDraft,
586 never,
587 OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner
588> {
589 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
590 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
591 const checkedAt = DateTime.formatIso(yield* DateTime.now);
592 const customModels = openCodeSettings.customModels;
593 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
594
595 const fallback = (
596 cause: unknown,
597 version: string | null = null,
598 phase: "version" | "inventory" = "version",
599 ) => {
600 const failure = formatOpenCodeProbeError({
601 cause,
602 isExternalServer,
603 phase,
604 serverUrl: openCodeSettings.serverUrl,
605 });
606 return buildServerProvider({
607 presentation: OPENCODE_PRESENTATION,
608 enabled: openCodeSettings.enabled,
609 checkedAt,
610 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
611 probe: {
612 installed: failure.installed,
613 version,
614 status: "error",
615 auth: { status: "unknown" },
616 message: failure.message,
617 },
618 });
619 };
620
621 if (!openCodeSettings.enabled) {
622 return buildServerProvider({
623 presentation: OPENCODE_PRESENTATION,
624 enabled: false,
625 checkedAt,
626 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
627 probe: {
628 installed: false,
629 version: null,
630 status: "warning",
631 auth: { status: "unknown" },
632 message: isExternalServer
633 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
634 : "OpenCode is disabled in T3 Code settings.",
635 },
636 });
637 }
638
639 const probedExit = yield* Effect.exit(
640 probeRuntime.pipe(
641 Effect.mapError(
642 (cause) =>
643 new OpenCodeProbeError({
644 cause,
645 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
646 }),
647 ),
648 ),
649 );
650 if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause));
651 const probed = probedExit.value;
652 if (probed.generation === "v2") {
653 return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models);
654 }
655 let version: string | null = probed.version;
656 if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) {
657 return buildServerProvider({
658 presentation: OPENCODE_PRESENTATION,
659 enabled: openCodeSettings.enabled,
660 checkedAt,
661 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
662 probe: {
663 installed: true,
664 version: probed.version,
665 status: "error",
666 auth: { status: "unknown" },
667 message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
668 },
669 });
670 }
671
672 const loadInventory = (server: {
673 readonly url: string;
674 readonly serverPassword?: string;
675 readonly version: string;
676 }) =>
677 openCodeRuntime
678 .loadOpenCodeInventory(
679 openCodeRuntime.createOpenCodeSdkClient({
680 baseUrl: server.url,
681 directory: cwd,
682 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
683 }),
684 )
685 .pipe(Effect.map((inventory) => ({ inventory, version: server.version })));
686 const inventoryEffect = isExternalServer
687 ? openCodeRuntime
688 .connectToOpenCodeServer({
689 binaryPath: openCodeSettings.binaryPath,
690 directory: cwd,
691 serverUrl: openCodeSettings.serverUrl,
692 ...(openCodeSettings.serverPassword
693 ? { serverPassword: openCodeSettings.serverPassword }
694 : {}),
695 })
696 .pipe(Effect.flatMap(loadInventory), Effect.scoped)
697 : serverOwner.withServer(loadInventory);
698 const inventoryExit = yield* Effect.exit(
699 inventoryEffect.pipe(
700 Effect.mapError(
701 (cause) =>
702 new OpenCodeProbeError({
703 cause,
704 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
705 }),
706 ),
707 ),
708 );
709 if (inventoryExit._tag === "Failure") {
710 return fallback(Cause.squash(inventoryExit.cause), version, "inventory");
711 }
712
713 version = inventoryExit.value.version;
714
715 const models = providerModelsFromSettings(
716 flattenOpenCodeModels(inventoryExit.value.inventory),
717 customModels,
718 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
719 );
720 const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills);
721 const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
722 return buildServerProvider({
723 presentation: OPENCODE_PRESENTATION,
724 enabled: true,
725 checkedAt,
726 models,
727 skills,
728 slashCommands: openCodeCommandsToServerProviderSlashCommands(
729 inventoryExit.value.inventory.commands,
730 ),
731 probe: {
732 installed: true,
733 version,
734 status: connectedCount > 0 ? "ready" : "warning",
735 auth: {
736 status: connectedCount > 0 ? "authenticated" : "unknown",
737 type: "opencode",
738 },
739 message:
740 connectedCount > 0
741 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
742 : isExternalServer
743 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
744 : "OpenCode is available, but it did not report any connected upstream providers.",
745 },
746 });
747});
apps/server/src/provider/Layers/PiProvider.test.ts deleted-80
......@@ -1,80 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcess, ChildProcessSpawner } from "effect/process";
7
8import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts";
9
10const encoder = new TextEncoder();
11
12function processHandle(input: {
13 readonly stdout?: string;
14 readonly stderr?: string;
15 readonly exitCode?: number;
16}) {
17 const bytes = (value: string | undefined) =>
18 value === undefined || value.length === 0
19 ? Stream.empty
20 : Stream.succeed(encoder.encode(value));
21 return ChildProcessSpawner.makeHandle({
22 pid: ChildProcessSpawner.ProcessId(900_000_001),
23 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
24 isRunning: Effect.succeed(false),
25 kill: () => Effect.void,
26 unref: Effect.succeed(Effect.void),
27 stdin: Sink.drain,
28 stdout: bytes(input.stdout),
29 stderr: bytes(input.stderr),
30 all: Stream.empty,
31 getInputFd: () => Sink.drain,
32 getOutputFd: () => Stream.empty,
33 });
34}
35
36function piProbeSpawner(version: string) {
37 return ChildProcessSpawner.make((command) => {
38 const args = ChildProcess.isStandardCommand(command) ? command.args : [];
39 return Effect.succeed(
40 args.includes("--version")
41 ? processHandle({ stdout: `pi ${version}\n` })
42 : processHandle({ stderr: "RPC startup failed", exitCode: 1 }),
43 );
44 });
45}
46
47const settings = {
48 enabled: true,
49 binaryPath: "pi",
50 launchArgs: "",
51 customModels: [],
52} as const;
53
54describe("PiProvider", () => {
55 it.effect("requires the first published Pi version with entries and settlement hooks", () =>
56 Effect.gen(function* () {
57 const snapshot = yield* checkPiProviderStatus(settings).pipe(
58 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")),
59 );
60 assert.equal(snapshot.status, "error");
61 assert.equal(snapshot.version, "0.80.3");
62 assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`);
63 }).pipe(Effect.provide(NodeServices.layer)),
64 );
65
66 it.effect("keeps compatible Pi selectable when optional discovery fails", () =>
67 Effect.gen(function* () {
68 const snapshot = yield* checkPiProviderStatus(settings).pipe(
69 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")),
70 );
71 assert.equal(snapshot.status, "ready");
72 assert.equal(snapshot.auth.status, "unknown");
73 assert.deepEqual(
74 snapshot.models.map((model) => model.slug),
75 ["default"],
76 );
77 assert.include(snapshot.message ?? "", "could not refresh its models and commands");
78 }).pipe(Effect.provide(NodeServices.layer)),
79 );
80});
apps/server/src/provider/Layers/PiProvider.ts deleted-441
......@@ -1,441 +0,0 @@
1/**
2 * PiProvider — snapshot/probe layer for the Pi coding agent.
3 *
4 * Health is probed with `pi --version`. Models, the user's default model, and
5 * the user's commands (extension slash commands, prompt templates, skills)
6 * are discovered through a short-lived ephemeral RPC session
7 * (`pi --mode rpc --no-session`), so everything the user configured in
8 * `~/.pi/agent` — custom providers, models.json entries, extensions, skills —
9 * shows up in T3 without any hardcoded catalog.
10 */
11import {
12 type CustomModelSetting,
13 type PiSettings,
14 type ServerProvider,
15 type ServerProviderModel,
16} from "@t3tools/contracts";
17import { causeErrorTag } from "@t3tools/shared/observability";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
19import { compareSemverVersions } from "@t3tools/shared/semver";
20import * as DateTime from "effect/DateTime";
21import * as Effect from "effect/Effect";
22import * as Stream from "effect/Stream";
23import * as Exit from "effect/Exit";
24import * as Option from "effect/Option";
25import * as Result from "effect/Result";
26import { HttpClient } from "effect/http";
27import { ChildProcess, ChildProcessSpawner } from "effect/process";
28
29import {
30 buildPiRpcLaunch,
31 resolvePiLaunchArgs,
32} from "../../orchestration-v2/Adapters/piT3McpInjection.ts";
33import {
34 makePiRpcConnection,
35 piRecordField as recordField,
36 piRecordString as recordString,
37} from "../../orchestration-v2/Adapters/PiRpc.ts";
38import {
39 buildServerProvider,
40 isCommandMissingCause,
41 parseGenericCliVersion,
42 providerModelsFromSettings,
43 spawnAndCollect,
44 type ServerProviderDraft,
45} from "../providerSnapshot.ts";
46import {
47 enrichProviderSnapshotWithVersionAdvisory,
48 type ProviderMaintenanceCapabilities,
49} from "../providerMaintenance.ts";
50import {
51 EMPTY_PI_MODEL_CAPABILITIES,
52 thinkingCapabilitiesForPiModel,
53} from "./piThinkingCapabilities.ts";
54import {
55 parsePiDiscoveredCommands,
56 withPiBuiltinSlashCommands,
57 type PiDiscoveredCommands,
58} from "../PiCommands.ts";
59
60const PI_PRESENTATION = {
61 displayName: "Pi",
62 showInteractionModeToggle: false,
63 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"],
64 // The adapter reports context usage from Pi's streaming usage while a
65 // turn runs, so clients can reserve the meter before the first settle.
66 reportsContextWindow: true,
67 requiresNewThreadForModelChange: false,
68} as const;
69
70const VERSION_PROBE_TIMEOUT_MS = 4_000;
71const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000;
72/**
73 * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4.
74 * Version 0.80.5 was the first published package containing both hooks. T3
75 * needs them for rollback boundaries and reliable turn terminalization.
76 */
77export const MINIMUM_PI_VERSION = "0.80.5";
78
79/** Deferring to the user's own settings.json default model. */
80const PI_DEFAULT_MODEL: ServerProviderModel = {
81 slug: "default",
82 name: "Pi default",
83 isCustom: false,
84 capabilities: EMPTY_PI_MODEL_CAPABILITIES,
85};
86
87interface PiDiscovery extends PiDiscoveredCommands {
88 readonly models: ReadonlyArray<ServerProviderModel>;
89 readonly authenticated: boolean;
90}
91
92function piModelsFromSettings(
93 customModels: ReadonlyArray<CustomModelSetting> | undefined,
94 discovered: ReadonlyArray<ServerProviderModel> = [],
95): ReadonlyArray<ServerProviderModel> {
96 return providerModelsFromSettings(
97 [PI_DEFAULT_MODEL, ...discovered],
98 customModels ?? [],
99 EMPTY_PI_MODEL_CAPABILITIES,
100 );
101}
102
103function parseDiscoveredModels(
104 data: unknown,
105 defaultThinkingLevel: unknown,
106): ReadonlyArray<ServerProviderModel> {
107 const models = recordField(data, "models");
108 if (!Array.isArray(models)) return [];
109 const seen = new Set<string>();
110 const parsed: Array<ServerProviderModel> = [];
111 for (const model of models) {
112 const provider = recordString(model, "provider");
113 const id = recordString(model, "id");
114 if (provider === undefined || id === undefined) continue;
115 const slug = `${provider}/${id}`;
116 if (seen.has(slug)) continue;
117 seen.add(slug);
118 parsed.push({
119 slug,
120 name: recordString(model, "name") ?? slug,
121 isCustom: false,
122 capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel),
123 });
124 }
125 return parsed;
126}
127
128const discoverPiViaRpc = (
129 piSettings: PiSettings,
130 environment: NodeJS.ProcessEnv,
131 launchArgs: ReadonlyArray<string>,
132 cwd?: string,
133) =>
134 Effect.gen(function* () {
135 const launch = buildPiRpcLaunch({
136 launchArgs,
137 environment,
138 mcpSession: undefined,
139 extensionPath: undefined,
140 ephemeral: true,
141 });
142 const connection = yield* makePiRpcConnection({
143 command: piSettings.binaryPath || "pi",
144 args: launch.args,
145 cwd,
146 env: launch.env,
147 });
148 yield* Stream.fromQueue(connection.events).pipe(
149 Stream.runDrain,
150 Effect.ignore,
151 Effect.forkScoped,
152 );
153 const stateData = yield* connection.request({ type: "get_state" });
154 const modelsData = yield* connection.request({ type: "get_available_models" });
155 const commandsData = yield* connection
156 .request({ type: "get_commands" })
157 .pipe(Effect.orElseSucceed(() => undefined));
158 const discoveredModels = parseDiscoveredModels(
159 modelsData,
160 recordString(stateData, "thinkingLevel"),
161 );
162 const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData);
163 return {
164 models: discoveredModels,
165 slashCommands: withPiBuiltinSlashCommands(slashCommands),
166 skills,
167 authenticated: discoveredModels.length > 0,
168 } satisfies PiDiscovery;
169 }).pipe(Effect.scoped);
170
171const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) =>
172 Effect.gen(function* () {
173 const command = piSettings.binaryPath || "pi";
174 const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], {
175 env: environment,
176 });
177 return yield* spawnAndCollect(
178 command,
179 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
180 env: environment,
181 shell: spawnCommand.shell,
182 }),
183 );
184 });
185
186export function buildInitialPiProviderSnapshot(
187 piSettings: PiSettings,
188): Effect.Effect<ServerProviderDraft> {
189 return Effect.gen(function* () {
190 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
191 const models = piModelsFromSettings(piSettings.customModels);
192 if (!piSettings.enabled) {
193 return buildServerProvider({
194 presentation: PI_PRESENTATION,
195 enabled: false,
196 checkedAt,
197 models,
198 probe: {
199 installed: false,
200 version: null,
201 status: "warning",
202 auth: { status: "unknown" },
203 message: "Pi is disabled in T3 Code settings.",
204 },
205 });
206 }
207 return buildServerProvider({
208 presentation: PI_PRESENTATION,
209 enabled: true,
210 checkedAt,
211 models,
212 probe: {
213 installed: true,
214 version: null,
215 status: "warning",
216 auth: { status: "unknown" },
217 message: "Checking Pi CLI availability...",
218 },
219 });
220 });
221}
222
223export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* (
224 piSettings: PiSettings,
225 environment: NodeJS.ProcessEnv = process.env,
226 cwd?: string,
227): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> {
228 const checkedAt = DateTime.formatIso(yield* DateTime.now);
229 const fallbackModels = piModelsFromSettings(piSettings.customModels);
230
231 if (!piSettings.enabled) {
232 return buildServerProvider({
233 presentation: PI_PRESENTATION,
234 enabled: false,
235 checkedAt,
236 models: fallbackModels,
237 probe: {
238 installed: false,
239 version: null,
240 status: "warning",
241 auth: { status: "unknown" },
242 message: "Pi is disabled in T3 Code settings.",
243 },
244 });
245 }
246
247 const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe(
248 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
249 Effect.result,
250 );
251
252 if (Result.isFailure(versionResult)) {
253 const error = versionResult.failure;
254 yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag });
255 return buildServerProvider({
256 presentation: PI_PRESENTATION,
257 enabled: piSettings.enabled,
258 checkedAt,
259 models: fallbackModels,
260 probe: {
261 installed: !isCommandMissingCause(error),
262 version: null,
263 status: "error",
264 auth: { status: "unknown" },
265 message: isCommandMissingCause(error)
266 ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`."
267 : "Failed to execute Pi CLI health check.",
268 },
269 });
270 }
271
272 if (Option.isNone(versionResult.success)) {
273 return buildServerProvider({
274 presentation: PI_PRESENTATION,
275 enabled: piSettings.enabled,
276 checkedAt,
277 models: fallbackModels,
278 probe: {
279 installed: true,
280 version: null,
281 status: "error",
282 auth: { status: "unknown" },
283 message: "Pi CLI is installed but timed out while running `pi --version`.",
284 },
285 });
286 }
287
288 const versionOutput = versionResult.success.value;
289 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
290 if (versionOutput.code !== 0) {
291 return buildServerProvider({
292 presentation: PI_PRESENTATION,
293 enabled: piSettings.enabled,
294 checkedAt,
295 models: fallbackModels,
296 probe: {
297 installed: true,
298 version,
299 status: "error",
300 auth: { status: "unknown" },
301 message: "Pi CLI is installed but failed to run.",
302 },
303 });
304 }
305
306 if (version === null) {
307 return buildServerProvider({
308 presentation: PI_PRESENTATION,
309 enabled: piSettings.enabled,
310 checkedAt,
311 models: fallbackModels,
312 probe: {
313 installed: true,
314 version: null,
315 status: "error",
316 auth: { status: "unknown" },
317 message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`,
318 },
319 });
320 }
321
322 if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) {
323 return buildServerProvider({
324 presentation: PI_PRESENTATION,
325 enabled: piSettings.enabled,
326 checkedAt,
327 models: fallbackModels,
328 probe: {
329 installed: true,
330 version,
331 status: "error",
332 auth: { status: "unknown" },
333 message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`,
334 },
335 });
336 }
337
338 const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs);
339 if (!resolvedLaunchArgs.ok) {
340 return buildServerProvider({
341 presentation: PI_PRESENTATION,
342 enabled: piSettings.enabled,
343 checkedAt,
344 models: fallbackModels,
345 probe: {
346 installed: true,
347 version,
348 status: "error",
349 auth: { status: "unknown" },
350 message: resolvedLaunchArgs.message,
351 },
352 });
353 }
354
355 const discoveryExit = yield* discoverPiViaRpc(
356 piSettings,
357 environment,
358 resolvedLaunchArgs.args,
359 cwd,
360 ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit);
361 if (Exit.isFailure(discoveryExit)) {
362 yield* Effect.logWarning("Pi RPC discovery failed.", {
363 errorTag: causeErrorTag(discoveryExit.cause),
364 });
365 return buildServerProvider({
366 presentation: PI_PRESENTATION,
367 enabled: piSettings.enabled,
368 checkedAt,
369 models: fallbackModels,
370 probe: {
371 installed: true,
372 version,
373 status: "ready",
374 auth: { status: "unknown" },
375 message:
376 "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.",
377 },
378 });
379 }
380 if (Option.isNone(discoveryExit.value)) {
381 return buildServerProvider({
382 presentation: PI_PRESENTATION,
383 enabled: piSettings.enabled,
384 checkedAt,
385 models: fallbackModels,
386 probe: {
387 installed: true,
388 version,
389 status: "ready",
390 auth: { status: "unknown" },
391 message:
392 "Pi is available, but model and command discovery needs interactive input. The live session will handle it.",
393 },
394 });
395 }
396
397 const discovery = discoveryExit.value.value;
398 const models = piModelsFromSettings(piSettings.customModels, discovery.models);
399 return buildServerProvider({
400 presentation: PI_PRESENTATION,
401 enabled: piSettings.enabled,
402 checkedAt,
403 models,
404 slashCommands: discovery.slashCommands,
405 skills: discovery.skills,
406 probe: {
407 installed: true,
408 version,
409 status: discovery.authenticated ? "ready" : "warning",
410 auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" },
411 ...(discovery.authenticated
412 ? {}
413 : {
414 message:
415 "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.",
416 }),
417 },
418 });
419});
420
421export const enrichPiSnapshot = (input: {
422 readonly snapshot: ServerProvider;
423 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
424 readonly enableProviderUpdateChecks?: boolean;
425 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
426 readonly httpClient: HttpClient.HttpClient;
427}): Effect.Effect<void> => {
428 const { snapshot, publishSnapshot } = input;
429 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
430 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
431 }).pipe(
432 Effect.provideService(HttpClient.HttpClient, input.httpClient),
433 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
434 Effect.catchCause((cause) =>
435 Effect.logWarning("Pi version advisory enrichment failed", {
436 errorTag: causeErrorTag(cause),
437 }),
438 ),
439 Effect.asVoid,
440 );
441};
apps/server/src/provider/Layers/ProviderAuthService.test.ts deleted-980
......@@ -1,980 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 ProviderSessionId,
6 ProviderSetupError,
7 ProviderThreadId,
8 ThreadId,
9 type OrchestrationV2ProviderSession,
10 type OrchestrationV2ThreadShell,
11 type ProviderAuthState,
12} from "@t3tools/contracts";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Deferred from "effect/Deferred";
16import * as Fiber from "effect/Fiber";
17import * as Layer from "effect/Layer";
18import * as PubSub from "effect/PubSub";
19import * as Queue from "effect/Queue";
20import * as Stream from "effect/Stream";
21import * as SubscriptionRef from "effect/SubscriptionRef";
22
23import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts";
24import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts";
25import { AcpProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/AcpAdapterV2.ts";
26import type { ProviderInstance } from "../ProviderDriver.ts";
27import type { ProviderAuthController } from "../Services/ProviderAuthService.ts";
28import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
29import { makeProviderAuthService } from "./ProviderAuthService.ts";
30
31const instanceId = ProviderInstanceId.make("antigravity-personal");
32const otherInstanceId = ProviderInstanceId.make("antigravity-work");
33const unsupportedInstanceId = ProviderInstanceId.make("codex");
34const driverKind = ProviderDriverKind.make("antigravity");
35const owner = "paired-client-owner";
36const otherOwner = "paired-client-other";
37const flowId = "test-sign-in-flow";
38const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code";
39const now = "2026-09-02T00:00:00.000Z";
40const idleAuthState: ProviderAuthState = {
41 instanceId,
42 phase: "idle",
43 flowId: null,
44 authorizationUrl: null,
45 expiresAt: null,
46 message: null,
47};
48const waitingAuthState: ProviderAuthState = {
49 ...idleAuthState,
50 phase: "waiting",
51 flowId,
52 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state",
53 expiresAt: "2026-09-02T00:05:00.000Z",
54};
55
56function makeInstance(input: {
57 instanceId: ProviderInstanceId;
58 enabled: boolean;
59 auth?: ProviderAuthController;
60}): ProviderInstance {
61 return {
62 ...input,
63 driverKind,
64 displayName: undefined,
65 continuationIdentity: { driverKind, continuationKey: input.instanceId },
66 get snapshot(): never {
67 throw new Error("Auth routing must not refresh the provider snapshot.");
68 },
69 get orchestrationAdapter(): never {
70 throw new Error("Auth routing must not start an adapter session.");
71 },
72 get textGeneration(): never {
73 throw new Error("Auth routing must not generate text.");
74 },
75 };
76}
77
78const nowUtc = DateTime.makeUnsafe(now);
79
80/**
81 * A thread bound to a provider instance with (or without) a live native
82 * thread. The auth service reads only the routing fields, so the rest of the
83 * shell row is left out.
84 */
85function makeThread(
86 thread: string,
87 input: { readonly providerInstanceId?: ProviderInstanceId; readonly active?: boolean } = {},
88): OrchestrationV2ThreadShell {
89 const shell: Pick<
90 OrchestrationV2ThreadShell,
91 "id" | "providerInstanceId" | "activeProviderThreadId"
92 > = {
93 id: ThreadId.make(thread),
94 providerInstanceId: input.providerInstanceId ?? instanceId,
95 activeProviderThreadId:
96 input.active === false ? null : ProviderThreadId.make(`${thread}:native`),
97 };
98 return shell as unknown as OrchestrationV2ThreadShell;
99}
100
101function makeSession(
102 session: string,
103 status: OrchestrationV2ProviderSession["status"] = "ready",
104 providerInstanceId = instanceId,
105): OrchestrationV2ProviderSession {
106 return {
107 id: ProviderSessionId.make(session),
108 driver: driverKind,
109 providerInstanceId,
110 status,
111 cwd: "/workspace",
112 model: null,
113 capabilities: AcpProviderCapabilitiesV2,
114 createdAt: nowUtc,
115 updatedAt: nowUtc,
116 lastError: null,
117 };
118}
119
120function sessionThreads(list: ReadonlyArray<OrchestrationV2ProviderSession>) {
121 return {
122 threads: list.map((session) => makeThread(session.id)),
123 sessions: new Map(list.map((session) => [ThreadId.make(session.id), [session]])),
124 };
125}
126
127const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* (
128 input: {
129 enabled?: boolean;
130 threads?: ReadonlyArray<OrchestrationV2ThreadShell>;
131 sessions?: ReadonlyMap<ThreadId, ReadonlyArray<OrchestrationV2ProviderSession>>;
132 shellError?: boolean;
133 stopError?: boolean;
134 logoutError?: ProviderSetupError;
135 sharedCredentials?: boolean;
136 sharedBusy?: boolean;
137 sharedBusyEffect?: Effect.Effect<boolean>;
138 responds?: boolean;
139 beforeLogout?: Effect.Effect<void>;
140 beforeStop?: Effect.Effect<void>;
141 beforeListSessions?: Effect.Effect<void>;
142 onLookup?: Effect.Effect<void>;
143 onListThreads?: (instances: ProviderInstance[]) => void;
144 } = {},
145) {
146 const actions: string[] = [];
147 const registryChanges = yield* PubSub.unbounded<void>();
148 const threads = input.threads ?? [];
149 const sessions = new Map(
150 [...(input.sessions?.entries() ?? [])].map(([threadId, list]) => [threadId, [...list]]),
151 );
152 const released: string[] = [];
153 const idle = idleAuthState;
154 let state = idle;
155 let flowOwner: string | undefined;
156 let gateClosed = false;
157
158 const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* (
159 ownerSessionId: string,
160 requestedFlowId: string,
161 operation: string,
162 ) {
163 if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) {
164 return yield* new ProviderSetupError({
165 instanceId,
166 operation,
167 detail: "This sign-in belongs to another client or has expired.",
168 });
169 }
170 });
171
172 const auth: ProviderAuthController = {
173 ...(input.sharedCredentials
174 ? { credentialBinding: { owner: "provider" as const, key: "shared" } }
175 : {}),
176 ...(input.responds
177 ? {
178 respond: Effect.fn(function* (ownerSessionId, request) {
179 yield* checkOwner(ownerSessionId, request.flowId, "respond");
180 actions.push(`respond:${request.response.type}`);
181 return state;
182 }),
183 }
184 : {}),
185 start: Effect.fn(function* (ownerSessionId, stopSessions) {
186 gateClosed = true;
187 actions.push("close-gate");
188 yield* stopSessions ?? Effect.void;
189 flowOwner = ownerSessionId;
190 state = waitingAuthState;
191 actions.push("start-sign-in");
192 return state;
193 }),
194 complete: Effect.fn(function* (ownerSessionId, request) {
195 yield* checkOwner(ownerSessionId, request.flowId, "complete");
196 if (request.callbackUrl !== callbackUrl) {
197 return yield* new ProviderSetupError({
198 instanceId,
199 operation: "complete",
200 detail: "The redirect URL does not match this sign-in.",
201 });
202 }
203 state = { ...idle, flowId, phase: "succeeded" };
204 return state;
205 }),
206 cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) {
207 yield* checkOwner(ownerSessionId, requestedFlowId, "cancel");
208 state = { ...idle, flowId, phase: "cancelled" };
209 return state;
210 }),
211 logout: Effect.fn(function* (stopSessions) {
212 gateClosed = true;
213 actions.push("close-gate");
214 yield* stopSessions;
215 if (input.logoutError) return yield* input.logoutError;
216 yield* input.beforeLogout ?? Effect.void;
217 actions.push("native-logout");
218 state = idle;
219 flowOwner = undefined;
220 return state;
221 }),
222 subscribe: (ownerSessionId) =>
223 Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))),
224 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
225 };
226 const instances = [
227 makeInstance({ instanceId, enabled: input.enabled ?? true, auth }),
228 makeInstance({ instanceId: unsupportedInstanceId, enabled: true }),
229 ...(input.sharedCredentials
230 ? [
231 makeInstance({
232 instanceId: otherInstanceId,
233 enabled: true,
234 auth: {
235 ...auth,
236 isChangingCredentials:
237 input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false),
238 invalidate: Effect.sync(() => {
239 actions.push("invalidate-shared");
240 }),
241 },
242 }),
243 ]
244 : []),
245 ];
246 const service = yield* makeProviderAuthService.pipe(
247 Effect.provide(
248 Layer.mergeAll(
249 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
250 getInstance: (id) =>
251 Effect.gen(function* () {
252 const found = instances.find((instance) => instance.instanceId === id);
253 yield* input.onLookup ?? Effect.void;
254 return found;
255 }),
256 listInstances: Effect.succeed(instances),
257 subscribeChanges: PubSub.subscribe(registryChanges),
258 }),
259 Layer.mock(ProjectionStore.ProjectionStoreV2)({
260 getRecoveryThreadIds: () =>
261 Effect.gen(function* () {
262 assert.isTrue(gateClosed);
263 actions.push("list-threads");
264 input.onListThreads?.(instances);
265 yield* input.beforeListSessions ?? Effect.void;
266 return yield* input.shellError
267 ? Effect.fail(
268 new ProjectionStore.ProjectionStoreReadError({
269 threadId: ThreadId.make("shell"),
270 cause: new Error("private database diagnostics"),
271 }),
272 )
273 : Effect.succeed(threads.map((thread) => thread.id));
274 }),
275 getThreadRecords: (threadId) =>
276 Effect.sync(() => {
277 actions.push(`sessions:${threadId}`);
278 return {
279 providerSessions: sessions.get(threadId) ?? [],
280 } as never;
281 }),
282 }),
283 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
284 release: ({ providerSessionId, reason }) =>
285 Effect.gen(function* () {
286 assert.isTrue(gateClosed);
287 actions.push(`stop:${providerSessionId}`);
288 yield* input.beforeStop ?? Effect.void;
289 if (input.stopError) {
290 return yield* Effect.fail(
291 new ProviderSessionManager.ProviderSessionReleaseError({
292 providerSessionId,
293 reason,
294 cause: new Error("private process diagnostics"),
295 }),
296 );
297 }
298 released.push(providerSessionId);
299 for (const [threadId, list] of sessions) {
300 const remaining = list.filter((session) => session.id !== providerSessionId);
301 if (remaining.length === 0) sessions.delete(threadId);
302 else sessions.set(threadId, remaining);
303 }
304 }),
305 }),
306 ),
307 ),
308 );
309 return {
310 service,
311 actions,
312 released,
313 sessions,
314 auth,
315 addInstance: (instance: ProviderInstance) => instances.push(instance),
316 replaceInstance: (replacement: ProviderInstance) => {
317 const index = instances.findIndex(
318 (instance) => instance.instanceId === replacement.instanceId,
319 );
320 assert.isAtLeast(index, 0);
321 instances[index] = replacement;
322 },
323 replaceAuth: (next: ProviderAuthController) => {
324 instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next });
325 },
326 };
327});
328
329const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")(
330 function* (flowOwner: string) {
331 const state = yield* SubscriptionRef.make(idleAuthState);
332 const close = yield* Deferred.make<void>();
333 const closedSubscriptions = yield* Queue.unbounded<string>();
334 const unused = () => Effect.die("Unexpected auth operation in a subscription test.");
335 const auth: ProviderAuthController = {
336 start: unused,
337 complete: unused,
338 cancel: unused,
339 logout: unused,
340 subscribe: (ownerSessionId) =>
341 SubscriptionRef.changes(state).pipe(
342 Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)),
343 Stream.interruptWhen(Deferred.await(close)),
344 Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)),
345 ),
346 };
347 return {
348 auth,
349 state,
350 closedSubscriptions,
351 close: Deferred.succeed(close, undefined),
352 };
353 },
354);
355
356const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")(
357 function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) {
358 const changes = yield* PubSub.unbounded<void>();
359 let current: ProviderInstance | undefined = initial;
360 let pendingReplacement = replaceDuringFirstLookup;
361 let subscribed = false;
362 const service = yield* makeProviderAuthService.pipe(
363 Effect.provide(
364 Layer.mergeAll(
365 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
366 subscribeChanges: Effect.gen(function* () {
367 const subscription = yield* PubSub.subscribe(changes);
368 subscribed = true;
369 return subscription;
370 }),
371 getInstance: () =>
372 Effect.gen(function* () {
373 const instance = current;
374 if (pendingReplacement) {
375 assert.isTrue(subscribed, "Registry changes must be subscribed before lookup.");
376 current = pendingReplacement;
377 pendingReplacement = undefined;
378 yield* PubSub.publish(changes, undefined);
379 }
380 return instance;
381 }),
382 }),
383 Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
384 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}),
385 ),
386 ),
387 );
388 return {
389 service,
390 replace: Effect.fn(function* (replacement: ProviderInstance | undefined) {
391 current = replacement;
392 yield* PubSub.publish(changes, undefined);
393 }),
394 };
395 },
396);
397
398const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* (
399 stream: Stream.Stream<ProviderAuthState, ProviderSetupError>,
400) {
401 const states = yield* Queue.unbounded<ProviderAuthState>();
402 const fiber = yield* stream.pipe(
403 Stream.runForEach((state) => Queue.offer(states, state)),
404 Effect.forkScoped,
405 );
406 return { states, fiber };
407});
408
409describe("ProviderAuthService", () => {
410 it.effect(
411 "stops sessions sharing credentials and invalidates their processes before logout",
412 () =>
413 Effect.gen(function* () {
414 const { service, actions, sessions } = yield* makeHarness({
415 sharedCredentials: true,
416 ...sessionThreads([
417 makeSession("target"),
418 makeSession("shared", "ready", otherInstanceId),
419 makeSession("unrelated", "ready", unsupportedInstanceId),
420 ]),
421 });
422 yield* service.logout({ instanceId });
423 assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]);
424 assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared"));
425 assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout"));
426 }),
427 );
428 it.effect("rejects overlapping changes to a shared sign-in", () =>
429 Effect.gen(function* () {
430 const { service, actions } = yield* makeHarness({
431 sharedCredentials: true,
432 sharedBusy: true,
433 });
434 for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) {
435 const error = yield* task.pipe(Effect.flip);
436 assert.include(error.detail, "shared sign-in");
437 }
438 assert.deepStrictEqual(actions, []);
439 }),
440 );
441 it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () =>
442 Effect.gen(function* () {
443 const { service, actions } = yield* makeHarness({ responds: true });
444 yield* service.start({ instanceId }, owner);
445 const request = {
446 instanceId,
447 flowId,
448 interactionId: "consent",
449 response: { type: "browser" as const, action: "accept" as const },
450 };
451 const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip);
452 assert.strictEqual(rejected.operation, "respond");
453 yield* service.respond(request, owner);
454 assert.strictEqual(actions.at(-1), "respond:browser");
455 const unsupported = yield* makeHarness();
456 const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip);
457 assert.include(error.detail, "does not accept");
458 }),
459 );
460 it.effect("stops routed sessions before sign-in, including for a disabled instance", () =>
461 Effect.gen(function* () {
462 const { service, actions, released } = yield* makeHarness({
463 enabled: false,
464 threads: [makeThread("active")],
465 sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]),
466 });
467 const state = yield* service.start({ instanceId }, owner);
468
469 assert.strictEqual(state.instanceId, instanceId);
470 assert.strictEqual(state.phase, "waiting");
471 assert.deepStrictEqual(released, ["active-session"]);
472 assert.deepStrictEqual(actions, [
473 "close-gate",
474 "list-threads",
475 "sessions:active",
476 "stop:active-session",
477 "start-sign-in",
478 ]);
479 }),
480 );
481
482 it.effect("keeps sign-in state private and accepts the owner's redirect URL", () =>
483 Effect.gen(function* () {
484 const { service } = yield* makeHarness();
485 const waiting = yield* service.start({ instanceId }, owner);
486 const ownerStates = yield* service
487 .subscribe({ instanceId }, owner)
488 .pipe(Stream.take(1), Stream.runCollect);
489 const otherStates = yield* service
490 .subscribe({ instanceId }, otherOwner)
491 .pipe(Stream.take(1), Stream.runCollect);
492
493 assert.deepStrictEqual(ownerStates, [waiting]);
494 assert.strictEqual(otherStates[0]?.authorizationUrl, null);
495 assert.strictEqual(otherStates[0]?.flowId, null);
496
497 const otherError = yield* Effect.flip(
498 service.complete({ instanceId, flowId, callbackUrl }, otherOwner),
499 );
500 assert.strictEqual(otherError.operation, "complete");
501 const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner);
502 assert.strictEqual(complete.phase, "succeeded");
503 assert.strictEqual(complete.authorizationUrl, null);
504 }),
505 );
506
507 it.effect("lets only the flow owner cancel sign-in", () =>
508 Effect.gen(function* () {
509 const { service } = yield* makeHarness();
510 yield* service.start({ instanceId }, owner);
511 const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner));
512 assert.strictEqual(error.operation, "cancel");
513
514 const cancelled = yield* service.cancel({ instanceId, flowId }, owner);
515 assert.strictEqual(cancelled.phase, "cancelled");
516 assert.strictEqual(cancelled.authorizationUrl, null);
517 }),
518 );
519
520 it.effect.each([
521 { change: "enable", initialEnabled: false, closeBeforeReplacement: true },
522 { change: "config", initialEnabled: true, closeBeforeReplacement: false },
523 ])(
524 "keeps private auth subscriptions current after an instance $change change",
525 ({ initialEnabled, closeBeforeReplacement }) =>
526 Effect.gen(function* () {
527 const first = yield* makeStreamingController(owner);
528 const replacement = yield* makeStreamingController(otherOwner);
529 yield* SubscriptionRef.set(first.state, waitingAuthState);
530 const { service, replace } = yield* makeSubscriptionHarness(
531 makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }),
532 );
533 const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner));
534 const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner));
535 assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState);
536 assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState);
537
538 if (closeBeforeReplacement) {
539 yield* first.close;
540 const closed = yield* Queue.takeN(first.closedSubscriptions, 2);
541 assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner]));
542 }
543 yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth }));
544 assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState);
545 assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState);
546 if (!closeBeforeReplacement) {
547 const closed = yield* Queue.takeN(first.closedSubscriptions, 2);
548 assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner]));
549 }
550
551 const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" };
552 yield* SubscriptionRef.set(first.state, staleState);
553 const newWaiting: ProviderAuthState = {
554 ...waitingAuthState,
555 flowId: "replacement-flow",
556 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state",
557 };
558 yield* SubscriptionRef.set(replacement.state, newWaiting);
559 assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState);
560 assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting);
561 }),
562 );
563
564 it.effect("does not miss a replacement during the first instance lookup", () =>
565 Effect.gen(function* () {
566 const first = yield* makeStreamingController(owner);
567 const replacement = yield* makeStreamingController(owner);
568 yield* SubscriptionRef.set(replacement.state, waitingAuthState);
569 const { service } = yield* makeSubscriptionHarness(
570 makeInstance({ instanceId, enabled: false, auth: first.auth }),
571 makeInstance({ instanceId, enabled: true, auth: replacement.auth }),
572 );
573
574 const states = yield* service.subscribe({ instanceId }, owner).pipe(
575 Stream.filter((state) => state.phase === "waiting"),
576 Stream.take(1),
577 Stream.runCollect,
578 );
579
580 assert.deepStrictEqual(states, [waitingAuthState]);
581 }),
582 );
583
584 it.effect("ends the subscription with a setup error when the instance is removed", () =>
585 Effect.gen(function* () {
586 const controller = yield* makeStreamingController(owner);
587 const { service, replace } = yield* makeSubscriptionHarness(
588 makeInstance({ instanceId, enabled: true, auth: controller.auth }),
589 );
590 const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner));
591 assert.deepStrictEqual(yield* Queue.take(states), idleAuthState);
592
593 yield* replace(undefined);
594 const error = yield* Effect.flip(Fiber.join(fiber));
595
596 assert.instanceOf(error, ProviderSetupError);
597 assert.strictEqual(error.instanceId, instanceId);
598 assert.strictEqual(error.operation, "subscribe");
599 assert.include(error.detail, "no longer available");
600 assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner);
601 }),
602 );
603
604 it.effect.each([
605 { id: ProviderInstanceId.make("missing"), detail: "no longer available" },
606 { id: unsupportedInstanceId, detail: "does not support sign-in" },
607 ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) =>
608 Effect.gen(function* () {
609 const { service, actions } = yield* makeHarness();
610 const operations: Effect.Effect<
611 ProviderAuthState | ReadonlyArray<ProviderAuthState>,
612 ProviderSetupError
613 >[] = [
614 service.start({ instanceId: id }, owner),
615 service.complete({ instanceId: id, flowId, callbackUrl }, owner),
616 service.cancel({ instanceId: id, flowId }, owner),
617 service.logout({ instanceId: id }),
618 Stream.runCollect(service.subscribe({ instanceId: id }, owner)),
619 ];
620 for (const operation of operations) {
621 const error = yield* Effect.flip(operation);
622 assert.instanceOf(error, ProviderSetupError);
623 assert.strictEqual(error.instanceId, id);
624 assert.include(error.detail, detail);
625 }
626 assert.deepStrictEqual(actions, []);
627 }),
628 );
629
630 it.effect("stops every live session on the instance before native logout", () =>
631 Effect.gen(function* () {
632 const { service, actions, released } = yield* makeHarness({
633 threads: [
634 makeThread("running"),
635 makeThread("starting"),
636 makeThread("idle", { active: false }),
637 makeThread("other-instance", { providerInstanceId: otherInstanceId }),
638 ],
639 sessions: new Map([
640 [
641 ThreadId.make("running"),
642 [
643 makeSession("running-session", "running"),
644 makeSession("stopped-session", "stopped"),
645 makeSession("errored-session", "error"),
646 makeSession("foreign-session", "ready", otherInstanceId),
647 ],
648 ],
649 [ThreadId.make("starting"), [makeSession("starting-session", "starting")]],
650 [ThreadId.make("idle"), [makeSession("idle-session")]],
651 [
652 ThreadId.make("other-instance"),
653 [makeSession("other-session", "ready", otherInstanceId)],
654 ],
655 ]),
656 });
657 const state = yield* service.logout({ instanceId });
658
659 assert.strictEqual(state.phase, "idle");
660 assert.deepStrictEqual(actions, [
661 "close-gate",
662 "list-threads",
663 "sessions:running",
664 "stop:running-session",
665 "sessions:starting",
666 "stop:starting-session",
667 "sessions:idle",
668 "stop:idle-session",
669 "sessions:other-instance",
670 "native-logout",
671 ]);
672 assert.deepStrictEqual(released, ["running-session", "starting-session", "idle-session"]);
673 }),
674 );
675
676 it.effect(
677 "releases a shared native session once after a thread's selected provider changes",
678 () =>
679 Effect.gen(function* () {
680 const shared = makeSession("shared-native-session");
681 const { service, released } = yield* makeHarness({
682 threads: [
683 makeThread("changed-selection", { providerInstanceId: otherInstanceId }),
684 makeThread("same-session"),
685 ],
686 sessions: new Map([
687 [ThreadId.make("changed-selection"), [shared]],
688 [ThreadId.make("same-session"), [shared]],
689 ]),
690 });
691
692 yield* service.logout({ instanceId });
693
694 assert.deepStrictEqual(released, [shared.id]);
695 }),
696 );
697
698 it.effect.each([
699 { text: "/logout", hasAttachments: false, handled: true },
700 { text: " \n/logout\t", hasAttachments: false, handled: true },
701 { text: "/logout", hasAttachments: true, handled: false },
702 { text: "/logout please", hasAttachments: false, handled: false },
703 { text: "Explain /logout", hasAttachments: false, handled: false },
704 { text: "/Logout", hasAttachments: false, handled: false },
705 ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) =>
706 Effect.gen(function* () {
707 const { service, actions } = yield* makeHarness();
708 const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments });
709
710 assert.strictEqual(result, handled);
711 assert.deepStrictEqual(
712 actions,
713 handled ? ["close-gate", "list-threads", "native-logout"] : [],
714 );
715 }),
716 );
717
718 it.effect("does not intercept commands for providers without an auth controller", () =>
719 Effect.gen(function* () {
720 const { service, actions } = yield* makeHarness();
721 for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) {
722 assert.isFalse(
723 yield* service.tryHandlePromptCommand({
724 instanceId: id,
725 text: "/logout",
726 hasAttachments: false,
727 }),
728 );
729 }
730 assert.deepStrictEqual(actions, []);
731 }),
732 );
733
734 it.effect("does not log out when the thread shell cannot be read", () =>
735 Effect.gen(function* () {
736 const { service, actions } = yield* makeHarness({ shellError: true });
737 const error = yield* Effect.flip(service.logout({ instanceId }));
738
739 assert.instanceOf(error, ProviderSetupError);
740 assert.strictEqual(error.instanceId, instanceId);
741 assert.strictEqual(error.operation, "stopSessions");
742 assert.notInclude(error.detail, "private database diagnostics");
743 assert.deepStrictEqual(actions, ["close-gate", "list-threads"]);
744 }),
745 );
746
747 it.effect("does not log out or consume the command when stopping a session fails", () =>
748 Effect.gen(function* () {
749 const { service, actions, released } = yield* makeHarness({
750 threads: [makeThread("active")],
751 sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]),
752 stopError: true,
753 });
754 const error = yield* Effect.flip(
755 service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }),
756 );
757
758 assert.instanceOf(error, ProviderSetupError);
759 assert.strictEqual(error.operation, "stopSessions");
760 assert.notInclude(error.detail, "private process diagnostics");
761 assert.deepStrictEqual(released, []);
762 assert.deepStrictEqual(actions, [
763 "close-gate",
764 "list-threads",
765 "sessions:active",
766 "stop:active-session",
767 ]);
768 }),
769 );
770
771 it.effect("returns native logout failures to the command caller", () =>
772 Effect.gen(function* () {
773 const logoutError = new ProviderSetupError({
774 instanceId,
775 operation: "logout",
776 detail: "Native sign-out failed. Try again.",
777 });
778 const { service } = yield* makeHarness({ logoutError });
779 const error = yield* Effect.flip(
780 service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }),
781 );
782
783 assert.strictEqual(error, logoutError);
784 }),
785 );
786});
787
788it.effect("queued logout prompts resolve the current controller after provider replacement", () =>
789 Effect.gen(function* () {
790 const entered = yield* Deferred.make<void>();
791 const release = yield* Deferred.make<void>();
792 const lookup = yield* Deferred.make<void>();
793 let observeLookup = false;
794 const harness = yield* makeHarness({
795 beforeLogout: Deferred.succeed(entered, undefined).pipe(
796 Effect.andThen(Deferred.await(release)),
797 ),
798 onLookup: Effect.suspend(() =>
799 observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void,
800 ),
801 });
802 const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild);
803 yield* Deferred.await(entered);
804 observeLookup = true;
805 const queued = yield* harness.service
806 .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false })
807 .pipe(Effect.forkChild);
808 yield* Deferred.await(lookup);
809 let replacementLoggedOut = false;
810 harness.replaceAuth({
811 ...harness.auth,
812 logout: (stopSessions) =>
813 stopSessions.pipe(
814 Effect.andThen(
815 Effect.sync(() => {
816 replacementLoggedOut = true;
817 return idleAuthState;
818 }),
819 ),
820 ),
821 });
822 yield* Deferred.succeed(release, undefined);
823 yield* Fiber.join(first);
824 assert.isTrue(yield* Fiber.join(queued));
825 assert.isTrue(replacementLoggedOut);
826 assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1);
827 }).pipe(Effect.scoped),
828);
829
830it.effect.each(["start", "logout", "prompt"] as const)(
831 "%s uses the same credential controller for shared exclusion and mutation during replacement",
832 (action) =>
833 Effect.gen(function* () {
834 const checked = yield* Deferred.make<void>();
835 const continueCheck = yield* Deferred.make<void>();
836 const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer");
837 const harness = yield* makeHarness({
838 sharedCredentials: true,
839 sharedBusyEffect: Deferred.succeed(checked, undefined).pipe(
840 Effect.andThen(Deferred.await(continueCheck)),
841 Effect.as(false),
842 ),
843 ...sessionThreads([
844 makeSession("old-shared", "ready", otherInstanceId),
845 makeSession("replacement-shared", "ready", replacementPeerId),
846 ]),
847 });
848 harness.addInstance(
849 makeInstance({
850 instanceId: replacementPeerId,
851 enabled: true,
852 auth: {
853 ...harness.auth,
854 credentialBinding: { owner: "provider", key: "replacement-binding" },
855 isChangingCredentials: Effect.succeed(true),
856 invalidate: Effect.die(
857 "The unrelated replacement credential binding must stay intact.",
858 ),
859 },
860 }),
861 );
862 const operation =
863 action === "start"
864 ? harness.service.start({ instanceId }, owner)
865 : action === "logout"
866 ? harness.service.logout({ instanceId })
867 : harness.service.tryHandlePromptCommand({
868 instanceId,
869 text: "/logout",
870 hasAttachments: false,
871 });
872 const running = yield* operation.pipe(Effect.forkChild);
873 yield* Deferred.await(checked);
874 let replacementMutations = 0;
875 harness.replaceAuth({
876 ...harness.auth,
877 credentialBinding: { owner: "provider", key: "replacement-binding" },
878 start: () =>
879 Effect.sync(() => {
880 replacementMutations++;
881 return waitingAuthState;
882 }),
883 logout: () =>
884 Effect.sync(() => {
885 replacementMutations++;
886 return idleAuthState;
887 }),
888 });
889 yield* Deferred.succeed(continueCheck, undefined);
890 yield* Fiber.join(running);
891 assert.equal(replacementMutations, 0);
892 assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout");
893 assert.isFalse(harness.sessions.has(ThreadId.make("old-shared")));
894 assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared")));
895 const blocked = yield* Effect.flip(harness.service.logout({ instanceId }));
896 assert.include(blocked.detail, "shared sign-in");
897 assert.equal(replacementMutations, 0);
898 }).pipe(Effect.scoped),
899);
900
901it.effect.each([
902 { owner: "provider" as const, key: "different-binding" },
903 { owner: "t3" as const, key: "shared" },
904])(
905 "does not invalidate a peer that switches credential binding during session draining %#",
906 (binding) =>
907 Effect.gen(function* () {
908 const draining = yield* Deferred.make<void>();
909 const continueDrain = yield* Deferred.make<void>();
910 const harness = yield* makeHarness({
911 sharedCredentials: true,
912 ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]),
913 beforeStop: Deferred.succeed(draining, undefined).pipe(
914 Effect.andThen(Deferred.await(continueDrain)),
915 ),
916 });
917 const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild);
918 yield* Deferred.await(draining);
919 let replacementInvalidated = false;
920 harness.replaceInstance(
921 makeInstance({
922 instanceId: otherInstanceId,
923 enabled: true,
924 auth: {
925 ...harness.auth,
926 credentialBinding: binding,
927 invalidate: Effect.sync(() => {
928 replacementInvalidated = true;
929 }),
930 },
931 }),
932 );
933 yield* Deferred.succeed(continueDrain, undefined);
934 yield* Fiber.join(logout);
935 assert.isFalse(replacementInvalidated);
936 assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining")));
937 assert.include(harness.actions, "native-logout");
938 }).pipe(Effect.scoped),
939);
940
941it.effect.each(["selection", "drain"] as const)(
942 "preserves replacement peer sessions when credentials change during session %s",
943 (phase) =>
944 Effect.gen(function* () {
945 const entered = yield* Deferred.make<void>();
946 const proceed = yield* Deferred.make<void>();
947 const block = Deferred.succeed(entered, undefined).pipe(
948 Effect.andThen(Deferred.await(proceed)),
949 );
950 const harness = yield* makeHarness({
951 sharedCredentials: true,
952 ...sessionThreads([
953 makeSession("target-draining"),
954 makeSession("peer-replacement", "ready", otherInstanceId),
955 makeSession("peer-persisted", "running", otherInstanceId),
956 ]),
957 ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }),
958 });
959 const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild);
960 yield* Deferred.await(entered);
961 harness.replaceInstance(
962 makeInstance({
963 instanceId: otherInstanceId,
964 enabled: true,
965 auth: {
966 ...harness.auth,
967 credentialBinding: { owner: "provider", key: "replacement-credentials" },
968 invalidate: Effect.die("The replacement peer's credentials must not be invalidated."),
969 },
970 }),
971 );
972 yield* Deferred.succeed(proceed, undefined);
973 yield* Fiber.join(logout);
974 assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement")));
975 assert.notInclude(harness.actions, "stop:peer-replacement");
976 assert.notInclude(harness.actions, "stop:peer-persisted");
977 assert.isFalse(harness.sessions.has(ThreadId.make("target-draining")));
978 assert.include(harness.actions, "native-logout");
979 }).pipe(Effect.scoped),
980);
apps/server/src/provider/Layers/ProviderAuthService.ts deleted-253
......@@ -1,253 +0,0 @@
1import {
2 ProviderSetupError,
3 type ProviderInstanceId,
4 type ProviderSessionId,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Stream from "effect/Stream";
9import * as Semaphore from "effect/Semaphore";
10
11import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts";
12import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts";
13import * as ProviderAuthService from "../Services/ProviderAuthService.ts";
14import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
15
16export const makeProviderAuthService = Effect.gen(function* () {
17 const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
18 const projections = yield* ProjectionStore.ProjectionStoreV2;
19 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
20 const credentialChanges = yield* Semaphore.make(1);
21
22 const getController = Effect.fn("ProviderAuthService.getController")(function* (
23 instanceId: ProviderInstanceId,
24 operation: string,
25 ) {
26 const instance = yield* registry.getInstance(instanceId);
27 if (!instance?.auth) {
28 return yield* new ProviderSetupError({
29 instanceId,
30 operation,
31 detail: instance
32 ? "This provider does not support sign-in in T3 Code."
33 : "This provider instance is no longer available.",
34 });
35 }
36 return instance.auth;
37 });
38
39 // Native sessions may still belong to the previous provider after the
40 // selected model changes. Read session bindings, not the selected model,
41 // when invalidating credentials for sign-in or sign-out.
42 const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* (
43 instanceId: ProviderInstanceId,
44 binding: ProviderAuthService.ProviderAuthController["credentialBinding"],
45 ) {
46 const failure = (detail: string) =>
47 new ProviderSetupError({ instanceId, operation: "stopSessions", detail });
48 const affectedIds = new Set([
49 instanceId,
50 ...(binding === undefined
51 ? []
52 : (yield* registry.listInstances)
53 .filter(
54 (instance) =>
55 instance.auth?.credentialBinding?.key === binding.key &&
56 instance.auth.credentialBinding.owner === binding.owner,
57 )
58 .map((instance) => instance.instanceId)),
59 ]);
60 const threadIds = yield* projections
61 .getRecoveryThreadIds("runtime")
62 .pipe(
63 Effect.mapError(() => failure("Could not read the provider's active sessions. Try again.")),
64 );
65 const released = new Set<ProviderSessionId>();
66 yield* Effect.forEach(
67 threadIds,
68 (threadId) =>
69 projections.getThreadRecords(threadId, ["providerSessions"]).pipe(
70 Effect.flatMap((projection) =>
71 Effect.forEach(
72 projection.providerSessions.filter(
73 (session) =>
74 affectedIds.has(session.providerInstanceId) &&
75 session.status !== "stopped" &&
76 session.status !== "error" &&
77 !released.has(session.id),
78 ),
79 (session) =>
80 Effect.gen(function* () {
81 if (session.providerInstanceId !== instanceId) {
82 const current = yield* registry.getInstance(session.providerInstanceId);
83 if (
84 !binding ||
85 current?.auth?.credentialBinding?.key !== binding.key ||
86 current.auth.credentialBinding.owner !== binding.owner
87 )
88 return;
89 }
90 yield* providerSessions
91 .release({
92 providerSessionId: session.id,
93 reason: "manual_shutdown",
94 detail: "Provider sign-in changed.",
95 })
96 .pipe(Effect.tap(() => Effect.sync(() => released.add(session.id))));
97 }),
98 { discard: true },
99 ),
100 ),
101 Effect.mapError(() =>
102 failure("Could not stop all sessions for this provider. Try again."),
103 ),
104 ),
105 { discard: true },
106 );
107 if (binding) {
108 yield* Effect.forEach(
109 (yield* registry.listInstances).filter(
110 (instance) =>
111 instance.instanceId !== instanceId &&
112 affectedIds.has(instance.instanceId) &&
113 instance.auth?.credentialBinding?.key === binding.key &&
114 instance.auth.credentialBinding.owner === binding.owner,
115 ),
116 (instance) => instance.auth?.invalidate ?? Effect.void,
117 { discard: true },
118 );
119 }
120 });
121
122 const checkSharedBinding = Effect.fnUntraced(function* (
123 instanceId: ProviderInstanceId,
124 operation: "start" | "logout",
125 auth: ProviderAuthService.ProviderAuthController,
126 ) {
127 const binding = auth.credentialBinding;
128 if (!binding) return;
129 const instances = yield* registry.listInstances;
130 for (const instance of instances) {
131 if (
132 instance.instanceId !== instanceId &&
133 instance.auth?.credentialBinding?.key === binding.key &&
134 instance.auth.credentialBinding.owner === binding.owner &&
135 instance.auth.isChangingCredentials &&
136 (yield* instance.auth.isChangingCredentials)
137 ) {
138 return yield* new ProviderSetupError({
139 instanceId,
140 operation,
141 detail:
142 "Another provider instance is changing this shared sign-in. Finish or cancel it first.",
143 });
144 }
145 }
146 });
147
148 return ProviderAuthService.ProviderAuthService.of({
149 reconnectProfile: Effect.fnUntraced(function* (input) {
150 const auth = yield* getController(input.instanceId, "export");
151 if (!auth.reconnectProfile)
152 return yield* new ProviderSetupError({
153 instanceId: input.instanceId,
154 operation: "export",
155 detail: "This provider does not support ChatGPT profile transfer.",
156 });
157 return yield* auth.reconnectProfile(input.methodId);
158 }),
159 importProfile: (input) =>
160 credentialChanges.withPermit(
161 Effect.gen(function* () {
162 const auth = yield* getController(input.instanceId, "import");
163 yield* checkSharedBinding(input.instanceId, "start", auth);
164 if (!auth.importProfile)
165 return yield* new ProviderSetupError({
166 instanceId: input.instanceId,
167 operation: "import",
168 detail: "This provider does not support ChatGPT profile transfer.",
169 });
170 return yield* auth.importProfile(
171 input.profile,
172 stopSessions(input.instanceId, auth.credentialBinding),
173 );
174 }),
175 ),
176 start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) {
177 return yield* credentialChanges.withPermit(
178 Effect.gen(function* () {
179 const auth = yield* getController(input.instanceId, "start");
180 yield* checkSharedBinding(input.instanceId, "start", auth);
181 return yield* auth.start(
182 ownerSessionId,
183 stopSessions(input.instanceId, auth.credentialBinding),
184 input.methodId,
185 input.returnUrl,
186 input.callbackMode,
187 );
188 }),
189 );
190 }),
191 respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) {
192 const auth = yield* getController(input.instanceId, "respond");
193 if (!auth.respond) {
194 return yield* new ProviderSetupError({
195 instanceId: input.instanceId,
196 operation: "respond",
197 detail: "This provider does not accept this sign-in interaction.",
198 });
199 }
200 return yield* auth.respond(ownerSessionId, input);
201 }),
202 complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) {
203 const auth = yield* getController(input.instanceId, "complete");
204 return yield* auth.complete(ownerSessionId, input);
205 }),
206 cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) {
207 const auth = yield* getController(input.instanceId, "cancel");
208 return yield* auth.cancel(ownerSessionId, input.flowId);
209 }),
210 logout: Effect.fn("ProviderAuthService.logout")(function* (input) {
211 return yield* credentialChanges.withPermit(
212 Effect.gen(function* () {
213 const auth = yield* getController(input.instanceId, "logout");
214 yield* checkSharedBinding(input.instanceId, "logout", auth);
215 return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding));
216 }),
217 );
218 }),
219 subscribe: (input, ownerSessionId) =>
220 Effect.gen(function* () {
221 const changes = yield* registry.subscribeChanges;
222 const initial = yield* getController(input.instanceId, "subscribe");
223 return Stream.concat(
224 Stream.succeed(initial),
225 Stream.fromSubscription(changes).pipe(
226 Stream.mapEffect(() => getController(input.instanceId, "subscribe")),
227 ),
228 ).pipe(
229 Stream.changesWith((previous, next) => previous === next),
230 Stream.switchMap((auth) => auth.subscribe(ownerSessionId)),
231 );
232 }).pipe(Stream.unwrap),
233 tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")(
234 function* (input) {
235 const instance = yield* registry.getInstance(input.instanceId);
236 if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) {
237 return false;
238 }
239 return yield* credentialChanges.withPermit(
240 Effect.gen(function* () {
241 const auth = yield* getController(input.instanceId, "logout");
242 if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false;
243 yield* checkSharedBinding(input.instanceId, "logout", auth);
244 yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding));
245 return true;
246 }),
247 );
248 },
249 ),
250 });
251});
252
253export const layer = Layer.effect(ProviderAuthService.ProviderAuthService, makeProviderAuthService);
apps/server/src/provider/Layers/ProviderEventLoggers.ts deleted-92
......@@ -1,92 +0,0 @@
1/**
2 * ProviderEventLoggers — single observability service that owns the shared
3 * provider event log store and exposes its two runtime views:
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 * must share one underlying log store — opening N writers against the
17 * same rotating file would race the rotation logic. Owning the loggers on
18 * 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 because observability must not prevent startup.
24 *
25 * @module provider/Layers/ProviderEventLoggers
26 */
27import * as Context from "effect/Context";
28import * as Effect from "effect/Effect";
29import * as Layer from "effect/Layer";
30
31import * as ServerConfig from "../../config.ts";
32import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
33import * as EventNdjsonLogger from "./EventNdjsonLogger.ts";
34
35/**
36 * Shared logger pair for native + canonical provider event streams.
37 *
38 * Service value is intentionally a struct of two optional loggers rather
39 * than two parallel tags. Construction site is one place
40 * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the
41 * field they need.
42 */
43export class ProviderEventLoggers extends Context.Service<
44 ProviderEventLoggers,
45 {
46 readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined;
47 readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined;
48 }
49>()("t3/provider/Layers/ProviderEventLoggers") {}
50
51/**
52 * Constant value used by tests / boot layers that want to opt out of native
53 * + canonical logging entirely. Keeps the tag non-optional in the type
54 * system while letting the runtime treat absence as a no-op.
55 */
56export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = {
57 native: undefined,
58 canonical: undefined,
59};
60
61/**
62 * Builds both stream views over one shared store. Setup failures are logged
63 * and downgraded to the no-op service so diagnostics never block startup.
64 *
65 * @public Service construction is part of the canonical Effect module API.
66 */
67export const make = Effect.gen(function* () {
68 const { providerEventLogPath } = yield* ServerConfig.ServerConfig;
69 const attribution = yield* ResourceAttribution.ResourceAttribution;
70 const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, {
71 attribution,
72 }).pipe(
73 Effect.catch((error) =>
74 Effect.logWarning(error.message, { error }).pipe(
75 Effect.annotateLogs({ scope: "provider-observability" }),
76 Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined),
77 ),
78 ),
79 );
80
81 if (!store) {
82 return ProviderEventLoggers.of(NoOpProviderEventLoggers);
83 }
84
85 yield* Effect.addFinalizer(() => store.close());
86 return ProviderEventLoggers.of({
87 native: store.logger("native"),
88 canonical: store.logger("canonical"),
89 });
90});
91
92export const layer = Layer.effect(ProviderEventLoggers, make);
apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts deleted-193
......@@ -1,193 +0,0 @@
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 `ProviderInstanceRegistryLive.layer`.
33 * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that
34 * acquires `ServerSettingsService.subscribeChanges` 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 * as Effect from "effect/Effect";
51import * as Layer from "effect/Layer";
52import * as Stream from "effect/Stream";
53
54import * as Settings from "../../serverSettings.ts";
55import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts";
56import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
57import * as ProviderInstanceRegistryMutator from "../Services/ProviderInstanceRegistryMutator.ts";
58import * as ProviderInstanceRegistryLive from "./ProviderInstanceRegistryLive.ts";
59import { type ProviderOrchestrationAdapterInfrastructure } from "./ProviderOrchestrationAdapterInfrastructure.ts";
60import * as ProviderOrchestrationAdapterInfrastructureLayer from "./ProviderOrchestrationAdapterInfrastructure.ts";
61import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts";
62import * as AcpRegistryCatalogLayer from "./AcpRegistryCatalog.ts";
63
64type ProviderInstanceRegistryHydrationEnv =
65 | Exclude<
66 BuiltInDriversEnv,
67 ProviderOrchestrationAdapterInfrastructure | AcpRegistrySupport.AcpRegistryCatalog
68 >
69 | Settings.ServerSettingsService;
70
71/**
72 * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot.
73 *
74 * Strategy:
75 * 1. Copy all explicit `settings.providerInstances` entries verbatim.
76 * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key
77 * is *not* already in the explicit map, synthesize an entry from the
78 * matching legacy `settings.providers.<kind>` blob.
79 *
80 * The returned map is the input the registry consumes; pure & exported
81 * separately so the hydration logic can be exercised by unit tests
82 * without layering.
83 */
84export const deriveProviderInstanceConfigMap = (
85 settings: ServerSettings,
86): ProviderInstanceConfigMap => {
87 const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances };
88
89 for (const driver of BUILT_IN_DRIVERS) {
90 const instanceId = defaultInstanceIdForDriver(driver.driverKind);
91 if (instanceId in merged) {
92 // Explicit `providerInstances` entry for this slot — user-authored
93 // config always wins over the legacy mirror.
94 continue;
95 }
96
97 // Only built-in drivers have a legacy mirror; the registry's
98 // `providers` struct is keyed on the same literal slug as
99 // `driverKind`. Access is dynamic (the driver kind is a branded string),
100 // but it's constrained to `keyof settings.providers` by the union of
101 // built-in driver kinds.
102 const legacyKey = driver.driverKind as keyof ServerSettings["providers"];
103 const legacyConfig = settings.providers[legacyKey];
104 if (legacyConfig === undefined) {
105 continue;
106 }
107
108 merged[instanceId] = {
109 driver: driver.driverKind,
110 config: legacyConfig,
111 };
112 }
113
114 return merged as ProviderInstanceConfigMap;
115};
116
117/**
118 * Layer that consumes `ProviderInstanceRegistryMutator` and forks a
119 * settings-watcher fiber. The fiber's lifetime is tied to the enclosing
120 * layer scope (process lifetime in production), so it is interrupted on
121 * shutdown without leaking.
122 *
123 * Errors inside the watcher are logged and swallowed — the registry's own
124 * "unavailable" bucket already absorbs unknown drivers and invalid
125 * configs, so the only way the watcher could fail is a settings stream
126 * tear-down, which logs and exits cleanly.
127 */
128const layerSettingsWatcher = Layer.effectDiscard(
129 Effect.gen(function* () {
130 const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator;
131 const serverSettings = yield* Settings.ServerSettingsService;
132 const settingsChanges = yield* serverSettings.subscribeChanges;
133 yield* settingsChanges.pipe(
134 Stream.runForEach((next) =>
135 mutator
136 .reconcile(deriveProviderInstanceConfigMap(next))
137 .pipe(
138 Effect.catchCause((cause) =>
139 Effect.logError("ProviderInstanceRegistry reconcile failed", cause),
140 ),
141 ),
142 ),
143 Effect.forkScoped,
144 );
145 }),
146);
147
148/**
149 * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in
150 * sync with subsequent `streamChanges` emissions.
151 *
152 * The Layer's two halves:
153 * - `ProviderInstanceRegistryLive.layer` produces the registry +
154 * mutator from the initial config map. Its scope owns every
155 * per-instance child scope created during reconcile.
156 * - `SettingsWatcherLive` consumes the mutator, acquires its settings
157 * subscription before forking, and runs a daemon fiber in the same scope.
158 *
159 * Composing via `Layer.provideMerge` makes the watcher's deps available
160 * from the mutable layer while still surfacing the registry as an output.
161 * The mutator tag is technically also exposed; only this module imports
162 * it, so the visibility leak is harmless in practice.
163 */
164export const layer: Layer.Layer<
165 ProviderInstanceRegistry.ProviderInstanceRegistry,
166 never,
167 ProviderInstanceRegistryHydrationEnv
168> = Layer.unwrap(
169 Effect.gen(function* () {
170 const serverSettings = yield* Settings.ServerSettingsService;
171 const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe(
172 Effect.orElseSucceed(() => undefined),
173 );
174 const initialConfigMap =
175 initialSettings === undefined
176 ? ({} as ProviderInstanceConfigMap)
177 : deriveProviderInstanceConfigMap(initialSettings);
178
179 const layerMutable = ProviderInstanceRegistryLive.layer({
180 drivers: BUILT_IN_DRIVERS,
181 configMap: initialConfigMap,
182 }).pipe(
183 Layer.provide(ProviderOrchestrationAdapterInfrastructureLayer.layer),
184 Layer.provide(AcpRegistryCatalogLayer.layer),
185 );
186
187 return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable));
188 }),
189) as Layer.Layer<
190 ProviderInstanceRegistry.ProviderInstanceRegistry,
191 never,
192 ProviderInstanceRegistryHydrationEnv
193>;
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts deleted-781
......@@ -1,781 +0,0 @@
1import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "../CodexInstallation.ts";
3import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
4/**
5 * Multi-instance validation slices for `ProviderInstanceRegistryLive`.
6 *
7 * Two axes of the driver/registry refactor are exercised here:
8 *
9 * 1. **Same driver, many instances** — the "multi-instance codex slice"
10 * describe block below configures two independent `codex` instances and
11 * asserts each gets its own closures and identity. This is the
12 * multi-codex capability the refactor exists to unlock.
13 *
14 * 2. **Many drivers, one registry** — the "all drivers slice" describe
15 * block below configures one instance of every shipped driver
16 * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single
17 * `ProviderInstanceConfigMap` and asserts the registry boots them all
18 * without cross-contamination. This proves the driver SPI is uniform
19 * across every provider — any driver plugs into the registry through
20 * the same `ProviderDriver` value contract.
21 *
22 * Every instance in these tests is configured with `enabled: false` so the
23 * provider-status checks short-circuit to pending/disabled snapshots
24 * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode`
25 * binaries. That keeps the assertions focused on registry routing
26 * behaviour rather than the runtime details of each provider.
27 */
28import { describe, expect, it } from "@effect/vitest";
29import * as NodeServices from "@effect/platform-node/NodeServices";
30import {
31 EnvironmentId,
32 type ClaudeSettings,
33 type CodexSettings,
34 type CursorSettings,
35 type GrokSettings,
36 type OpenCodeSettings,
37 ProviderDriverKind,
38 type ProviderInstanceConfigMap,
39 ProviderInstanceId,
40} from "@t3tools/contracts";
41import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
42import * as DateTime from "effect/DateTime";
43import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
45import * as Layer from "effect/Layer";
46import * as Path from "effect/Path";
47import * as Stream from "effect/Stream";
48import { HttpClient, HttpClientResponse } from "effect/http";
49
50import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
51import * as AntigravityInstallation from "../AntigravityInstallation.ts";
52import * as ServerConfig from "../../config.ts";
53import { expandHomePath } from "../../pathExpansion.ts";
54import * as ServerSettings from "../../serverSettings.ts";
55import { ClaudeDriver, type ClaudeDriverEnv } from "../Drivers/ClaudeDriver.ts";
56import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts";
57import { CursorDriver, type CursorDriverEnv } from "../Drivers/CursorDriver.ts";
58import { GrokDriver, type GrokDriverEnv } from "../Drivers/GrokDriver.ts";
59import { OpenCodeDriver, type OpenCodeDriverEnv } from "../Drivers/OpenCodeDriver.ts";
60import * as ModelManifest from "../ModelManifest.ts";
61import * as OpenCodeRuntime from "../opencodeRuntime.ts";
62import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
63import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
64import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
65import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
66import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";
67
68const layerTestHttpClient = Layer.succeed(
69 HttpClient.HttpClient,
70 HttpClient.make((request) =>
71 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
72 ),
73);
74
75const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
76
77const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
78 reportClientActivity: () => Effect.void,
79 removeRpcClient: () => Effect.void,
80 reportHostPowerState: () => Effect.void,
81 snapshot: Effect.succeed({
82 hostPower: {
83 source: "unknown",
84 idle: "unknown",
85 idleSeconds: null,
86 locked: "unknown",
87 suspended: false,
88 onBattery: "unknown",
89 lowPowerMode: "unknown",
90 thermalState: "unknown",
91 stale: true,
92 updatedAt: TEST_EPOCH,
93 },
94 leases: [],
95 activeForegroundLeaseCount: 0,
96 activeScopeKeys: [],
97 shouldRunOpportunisticWork: true,
98 updatedAt: TEST_EPOCH,
99 }),
100 streamChanges: Stream.empty,
101 hasDemand: () => Effect.succeed(true),
102 shouldRunScopeWork: () => Effect.succeed(true),
103 shouldRunOpportunisticWork: Effect.succeed(true),
104});
105
106const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
107 enabled: false,
108 binaryPath: "codex",
109 homePath: "",
110 shadowHomePath: "",
111 launchArgs: "",
112 customModels: [],
113 ...overrides,
114});
115
116const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => ({
117 enabled: false,
118 binaryPath: "claude",
119 homePath: "",
120 customModels: [],
121 launchArgs: "",
122 autoCompactWindow: "",
123 ...overrides,
124});
125
126const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => ({
127 enabled: false,
128 customModels: [],
129 ...overrides,
130});
131
132const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({
133 enabled: false,
134 binaryPath: "grok",
135 customModels: [],
136 ...overrides,
137});
138
139const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({
140 enabled: false,
141 binaryPath: "opencode",
142 serverUrl: "",
143 serverPassword: "",
144 customModels: [],
145 ...overrides,
146});
147
148const makeTildeProviderFixtures = Effect.fn(
149 "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures",
150)(function* () {
151 const fileSystem = yield* FileSystem.FileSystem;
152 const path = yield* Path.Path;
153 const homePath = expandHomePath("~");
154 const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({
155 directory: homePath,
156 prefix: ".t3-provider-path-test-",
157 });
158 const codexPath = path.join(fixtureDir, "codex");
159 const claudePath = path.join(fixtureDir, "claude");
160 const claudeHomePath = path.join(fixtureDir, "claude-home");
161 const codexScriptPath = path.join(fixtureDir, "codex-script.json");
162 const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures");
163
164 yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath);
165 yield* fileSystem.copyFile(
166 path.join(codexFixtureDir, "codexCollabMockPeer.mjs"),
167 path.join(fixtureDir, "codexCollabMockPeer.mjs"),
168 );
169 yield* fileSystem.copyFile(
170 path.join(codexFixtureDir, "codexMultiAgentWire.json"),
171 path.join(fixtureDir, "codexMultiAgentWire.json"),
172 );
173 yield* fileSystem.writeFileString(
174 codexScriptPath,
175 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer.
176 JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }),
177 );
178 yield* fileSystem.chmod(codexPath, 0o755);
179
180 yield* fileSystem.copyFile(
181 yield* path.fromFileUrl(
182 new URL("./testing/ProviderInstanceRegistryLive.fixture.mjs", import.meta.url),
183 ),
184 claudePath,
185 );
186 yield* fileSystem.chmod(claudePath, 0o755);
187 yield* fileSystem.makeDirectory(claudeHomePath);
188
189 const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`;
190 return {
191 codexBinaryPath: asTildePath(codexPath),
192 claudeBinaryPath: asTildePath(claudePath),
193 claudeHomePath,
194 codexScriptPath,
195 };
196});
197
198describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
199 // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch
200 // directory. `Layer.merge` just unions requirements, so we have to push
201 // `NodeServices.layer` through `Layer.provideMerge` to satisfy that
202 // dependency while still surfacing NodeServices to the test body (the
203 // codex driver's `create` yields `ChildProcessSpawner` directly).
204 const layerBase = ServerConfig.layerTest(process.cwd(), {
205 prefix: "provider-instance-registry-test",
206 }).pipe(
207 Layer.provideMerge(NodeServices.layer),
208 Layer.provideMerge(
209 Layer.mock(CodexInstallation.CodexInstallation)({
210 managedDirectory: "unused-managed-installation",
211 }),
212 ),
213 Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})),
214 Layer.provideMerge(
215 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
216 getEnvironmentId: Effect.succeed(
217 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
218 ),
219 }),
220 ),
221 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
222 Layer.provideMerge(ServerSettings.layerTest()),
223 Layer.provideMerge(layerTestHttpClient),
224 Layer.provideMerge(ServerSettings.layerTest()),
225 Layer.provideMerge(
226 Layer.succeed(
227 ProviderEventLoggers.ProviderEventLoggers,
228 ProviderEventLoggers.NoOpProviderEventLoggers,
229 ),
230 ),
231 Layer.provideMerge(ModelManifest.layerTest),
232 Layer.provideMerge(ResetCreditCoordinator.layerTest),
233 );
234 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
235 Layer.provideMerge(layerBase),
236 );
237
238 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
239 Effect.gen(function* () {
240 const personalId = ProviderInstanceId.make("codex_personal");
241 const workId = ProviderInstanceId.make("codex_work");
242 const codexDriverKind = ProviderDriverKind.make("codex");
243
244 const configMap: ProviderInstanceConfigMap = {
245 [personalId]: {
246 driver: codexDriverKind,
247 displayName: "Codex (personal)",
248 enabled: false,
249 config: makeCodexConfig({
250 binaryPath: "/opt/codex-personal/bin/codex",
251 homePath: "/home/julius/.codex_personal",
252 customModels: ["personal-preview"],
253 }),
254 },
255 [workId]: {
256 driver: codexDriverKind,
257 displayName: "Codex (work)",
258 enabled: false,
259 config: makeCodexConfig({
260 binaryPath: "/opt/codex-work/bin/codex",
261 homePath: "/home/julius/.codex",
262 customModels: ["work-preview"],
263 }),
264 },
265 };
266
267 const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({
268 drivers: [CodexDriver],
269 configMap,
270 });
271
272 const instances = yield* registry.listInstances;
273 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
274 [personalId, workId].toSorted(),
275 );
276 expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true);
277 expect(instances.map((instance) => instance.displayName).toSorted()).toEqual(
278 ["Codex (personal)", "Codex (work)"].toSorted(),
279 );
280
281 // Each instance must be retrievable by id and carry its *own* closures.
282 const personal = yield* registry.getInstance(personalId);
283 const work = yield* registry.getInstance(workId);
284 expect(personal).toBeDefined();
285 expect(work).toBeDefined();
286 expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter);
287 expect(personal!.textGeneration).not.toBe(work!.textGeneration);
288 expect(personal!.snapshot).not.toBe(work!.snapshot);
289
290 // Snapshots identify themselves by instanceId + driver — this is
291 // what makes per-instance routing distinguishable downstream.
292 const personalSnapshot = yield* personal!.snapshot.getSnapshot;
293 expect(personalSnapshot.instanceId).toBe(personalId);
294 expect(personalSnapshot.driver).toBe(codexDriverKind);
295 expect(personalSnapshot.enabled).toBe(false);
296 // The layout resolves the configured home through the host Path.
297 const path = yield* Path.Path;
298 expect(personalSnapshot.continuation?.groupKey).toBe(
299 `codex:home:${path.resolve("/home/julius/.codex_personal")}`,
300 );
301
302 const workSnapshot = yield* work!.snapshot.getSnapshot;
303 expect(workSnapshot.instanceId).toBe(workId);
304 expect(workSnapshot.driver).toBe(codexDriverKind);
305 expect(workSnapshot.enabled).toBe(false);
306 expect(workSnapshot.continuation?.groupKey).toBe(
307 `codex:home:${path.resolve("/home/julius/.codex")}`,
308 );
309
310 // Nothing goes to the unavailable bucket — both drivers are registered.
311 const unavailable = yield* registry.listUnavailable;
312 expect(unavailable).toEqual([]);
313 }).pipe(Effect.provide(layerTest)),
314 );
315
316 it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () =>
317 Effect.gen(function* () {
318 // Old settings files can carry both flags with conflicting values.
319 // The explicit false must win so a user's disable is never undone.
320 const staleId = ProviderInstanceId.make("codex_stale");
321 const configMap: ProviderInstanceConfigMap = {
322 [staleId]: {
323 driver: ProviderDriverKind.make("codex"),
324 enabled: true,
325 config: makeCodexConfig({ enabled: false }),
326 },
327 };
328
329 const { registry } = yield* makeProviderInstanceRegistry({
330 drivers: [CodexDriver],
331 configMap,
332 });
333
334 const instance = yield* registry.getInstance(staleId);
335 expect(instance).toBeDefined();
336 expect(instance!.enabled).toBe(false);
337 const snapshot = yield* instance!.snapshot.getSnapshot;
338 expect(snapshot.enabled).toBe(false);
339 }).pipe(Effect.provide(layerTest)),
340 );
341
342 it.live("reports Codex's answer when a redemption changed nothing", () =>
343 Effect.gen(function* () {
344 if (yield* isHostWindows) return;
345 const fileSystem = yield* FileSystem.FileSystem;
346 const fixtures = yield* makeTildeProviderFixtures();
347 yield* fileSystem.writeFileString(
348 fixtures.codexScriptPath,
349 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer.
350 JSON.stringify({
351 rootThreadId: "probe-thread",
352 notifications: [],
353 account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
354 failRateLimitsRead: true,
355 resetCreditOutcome: "alreadyRedeemed",
356 }),
357 );
358 const codexId = ProviderInstanceId.make("codex_reset");
359 const { registry } = yield* makeProviderInstanceRegistry({
360 drivers: [CodexDriver],
361 configMap: {
362 [codexId]: {
363 driver: ProviderDriverKind.make("codex"),
364 enabled: true,
365 environment: [
366 { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false },
367 ],
368 config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }),
369 },
370 },
371 });
372 const codex = yield* registry.getInstance(codexId);
373 expect(codex).toBeDefined();
374 // The usage read fails, so the re-probe cannot confirm new limits.
375 yield* codex!.snapshot.refresh;
376 expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed");
377 }).pipe(Effect.provide(layerTest)),
378 );
379
380 it.live("runs Codex and Claude readiness probes from configured tilde paths", () =>
381 Effect.gen(function* () {
382 if (yield* isHostWindows) return;
383
384 const fixtures = yield* makeTildeProviderFixtures();
385
386 const codexId = ProviderInstanceId.make("codex_tilde");
387 const claudeId = ProviderInstanceId.make("claude_tilde");
388 const configMap: ProviderInstanceConfigMap = {
389 [codexId]: {
390 driver: ProviderDriverKind.make("codex"),
391 enabled: true,
392 environment: [
393 {
394 name: "T3_CODEX_COLLAB_SCRIPT",
395 value: fixtures.codexScriptPath,
396 sensitive: false,
397 },
398 ],
399 config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }),
400 },
401 [claudeId]: {
402 driver: ProviderDriverKind.make("claudeAgent"),
403 enabled: true,
404 config: makeClaudeConfig({
405 enabled: true,
406 binaryPath: fixtures.claudeBinaryPath,
407 homePath: fixtures.claudeHomePath,
408 }),
409 },
410 };
411
412 const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv | ClaudeDriverEnv>({
413 drivers: [CodexDriver, ClaudeDriver],
414 configMap,
415 });
416 const codex = yield* registry.getInstance(codexId);
417 const claude = yield* registry.getInstance(claudeId);
418 expect(codex).toBeDefined();
419 expect(claude).toBeDefined();
420
421 const [codexSnapshot, claudeSnapshot] = yield* Effect.all(
422 [codex!.snapshot.refresh, claude!.snapshot.refresh],
423 { concurrency: "unbounded" },
424 );
425 expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" });
426 expect(claudeSnapshot).toMatchObject({
427 status: "ready",
428 installed: true,
429 version: "2.1.219",
430 });
431 }).pipe(Effect.provide(layerTest)),
432 );
433
434 const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>
435 Effect.gen(function* () {
436 const fs = yield* FileSystem.FileSystem;
437 const path = yield* Path.Path;
438 const fixtures = yield* makeTildeProviderFixtures();
439 const marker = path.join(fixtures.claudeHomePath, "redeemed");
440 yield* fs.writeFileString(
441 path.join(fixtures.claudeHomePath, ".credentials.json"),
442 '{"claudeAiOauth":{"accessToken":"fake-token"}}',
443 );
444 yield* fs.writeFileString(
445 path.join(fixtures.claudeHomePath, ".claude.json"),
446 '{"oauthAccount":{"organizationUuid":"fake-org"}}',
447 );
448 const client = HttpClient.make((request) =>
449 Effect.gen(function* () {
450 if (request.url.endsWith("/api/oauth/usage")) {
451 return HttpClientResponse.fromWeb(
452 request,
453 Response.json({
454 cedar_ember: {
455 eligible: true,
456 next_grant_id: "grant_a",
457 grants: [{ id: "grant_a", resets_left: 1, usable_now: true }],
458 },
459 }),
460 );
461 }
462 if (request.url.endsWith("/reset_rate_limits")) {
463 yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie);
464 return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result }));
465 }
466 return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }));
467 }),
468 );
469 const instanceId = ProviderInstanceId.make("claude_reset");
470 const { registry } = yield* makeProviderInstanceRegistry({
471 drivers: [ClaudeDriver],
472 configMap: {
473 [instanceId]: {
474 driver: ProviderDriverKind.make("claudeAgent"),
475 enabled: true,
476 environment: [
477 { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false },
478 ...(claim.usageFailsAfterClaim
479 ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }]
480 : []),
481 ],
482 config: makeClaudeConfig({
483 enabled: true,
484 binaryPath: fixtures.claudeBinaryPath,
485 homePath: fixtures.claudeHomePath,
486 }),
487 },
488 },
489 }).pipe(Effect.provideService(HttpClient.HttpClient, client));
490 const instance = yield* registry.getInstance(instanceId);
491 expect(instance).toBeDefined();
492 const before = yield* instance!.snapshot.refresh;
493 expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100);
494 expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a");
495 const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result);
496 return { outcome, after: yield* instance!.snapshot.getSnapshot };
497 }).pipe(
498 // macOS logins live in the Keychain, where resets are never read.
499 Effect.provideService(HostProcessPlatform, "linux"),
500 Effect.provide(layerTest),
501 );
502
503 it.live("refreshes Claude usage after redeeming a reset", () =>
504 Effect.gen(function* () {
505 const { outcome, after } = yield* redeemClaudeReset({
506 result: "reset",
507 usageFailsAfterClaim: false,
508 });
509 expect(outcome).toMatchObject({ _tag: "Success", success: "reset" });
510 expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0);
511 }),
512 );
513
514 it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () =>
515 Effect.gen(function* () {
516 const { outcome } = yield* redeemClaudeReset({
517 result: "already_used",
518 usageFailsAfterClaim: true,
519 });
520 expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" });
521 }),
522 );
523
524 it.live(
525 "shadows instances whose driver is not registered in this build without failing boot",
526 () =>
527 Effect.gen(function* () {
528 const codexId = ProviderInstanceId.make("codex_main");
529 const ghostId = ProviderInstanceId.make("ghost_main");
530
531 const configMap: ProviderInstanceConfigMap = {
532 [codexId]: {
533 driver: ProviderDriverKind.make("codex"),
534 enabled: false,
535 config: makeCodexConfig({}),
536 },
537 [ghostId]: {
538 driver: ProviderDriverKind.make("ghostDriver"),
539 displayName: "A fork-only driver we don't ship",
540 enabled: false,
541 config: { arbitrary: "payload", preserved: true },
542 },
543 };
544
545 const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({
546 drivers: [CodexDriver],
547 configMap,
548 });
549
550 const instances = yield* registry.listInstances;
551 expect(instances).toHaveLength(1);
552 expect(instances[0]!.instanceId).toBe(codexId);
553
554 const unavailable = yield* registry.listUnavailable;
555 expect(unavailable).toHaveLength(1);
556 const ghost = unavailable[0]!;
557 expect(ghost.instanceId).toBe(ghostId);
558 expect(ghost.driver).toBe("ghostDriver");
559 expect(ghost.availability).toBe("unavailable");
560 expect(ghost.unavailableReason).toMatch(/ghostDriver/);
561 }).pipe(Effect.provide(layerTest)),
562 );
563});
564
565describe("ProviderInstanceRegistryLive — all drivers slice", () => {
566 // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem +
567 // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime`
568 // at construction time, so we wire `OpenCodeRuntimeLive` into the stack.
569 // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via
570 // `Layer.provide`, so the only external requirement it still exposes is
571 // `ChildProcessSpawner` — resolved here by piping it through
572 // `provideMerge(NodeServices.layer)`.
573 //
574 // The nested `provideMerge`s read bottom-up: `NodeServices.layer`
575 // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs
576 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s
577 // `FileSystem` dep while keeping everything else surfaced to the test.
578 const layerInfra = OpenCodeRuntime.layer.pipe(
579 Layer.provide(OpenCodeServerLedger.layerTest),
580 Layer.provideMerge(NodeServices.layer),
581 Layer.provideMerge(
582 Layer.mock(CodexInstallation.CodexInstallation)({
583 managedDirectory: "unused-managed-installation",
584 }),
585 ),
586 Layer.provideMerge(
587 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
588 getEnvironmentId: Effect.succeed(
589 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
590 ),
591 }),
592 ),
593 );
594 const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe(
595 Layer.provideMerge(ServerSecretStore.layer),
596 Layer.provideMerge(
597 ServerConfig.layerTest(process.cwd(), {
598 prefix: "provider-instance-registry-all-drivers-test",
599 }),
600 ),
601 Layer.provideMerge(layerInfra),
602 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
603 Layer.provideMerge(ServerSettings.layerTest()),
604 Layer.provideMerge(layerTestHttpClient),
605 Layer.provideMerge(ServerSettings.layerTest()),
606 Layer.provideMerge(
607 Layer.succeed(
608 ProviderEventLoggers.ProviderEventLoggers,
609 ProviderEventLoggers.NoOpProviderEventLoggers,
610 ),
611 ),
612 Layer.provideMerge(ModelManifest.layerTest),
613 Layer.provideMerge(ResetCreditCoordinator.layerTest),
614 );
615 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
616 Layer.provideMerge(layerBase),
617 );
618
619 it.live("boots one instance of every shipped driver from a single config map", () =>
620 Effect.gen(function* () {
621 const codexId = ProviderInstanceId.make("codex_default");
622 const claudeId = ProviderInstanceId.make("claude_default");
623 const cursorId = ProviderInstanceId.make("cursor_default");
624 const grokId = ProviderInstanceId.make("grok_default");
625 const openCodeId = ProviderInstanceId.make("opencode_default");
626
627 const codexDriverKind = ProviderDriverKind.make("codex");
628 const claudeDriverKind = ProviderDriverKind.make("claudeAgent");
629 const cursorDriverKind = ProviderDriverKind.make("cursor");
630 const grokDriverKind = ProviderDriverKind.make("grok");
631 const openCodeDriverKind = ProviderDriverKind.make("opencode");
632
633 const configMap: ProviderInstanceConfigMap = {
634 [codexId]: {
635 driver: codexDriverKind,
636 displayName: "Codex",
637 enabled: false,
638 config: makeCodexConfig({ homePath: "/home/julius/.codex" }),
639 },
640 [claudeId]: {
641 driver: claudeDriverKind,
642 displayName: "Claude",
643 enabled: false,
644 config: makeClaudeConfig({
645 homePath: "/home/julius/.claude-work",
646 launchArgs: "--verbose",
647 }),
648 },
649 [cursorId]: {
650 driver: cursorDriverKind,
651 displayName: "Cursor",
652 enabled: false,
653 config: makeCursorConfig({}),
654 },
655 [grokId]: {
656 driver: grokDriverKind,
657 displayName: "Grok",
658 enabled: false,
659 config: makeGrokConfig({}),
660 },
661 [openCodeId]: {
662 driver: openCodeDriverKind,
663 displayName: "OpenCode",
664 enabled: false,
665 config: makeOpenCodeConfig({}),
666 },
667 };
668
669 const { registry } = yield* makeProviderInstanceRegistry<
670 CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv
671 >({
672 drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
673 configMap,
674 });
675
676 // Every configured instance must materialize — none downgraded to a
677 // shadow snapshot, because every driver in the map is registered.
678 const unavailable = yield* registry.listUnavailable;
679 expect(unavailable).toEqual([]);
680
681 const instances = yield* registry.listInstances;
682 expect(instances).toHaveLength(5);
683 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
684 [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(),
685 );
686
687 // Instance lookup by id resolves each instance to its own bundle —
688 // this is how rest-of-server routes turn/session calls in the new
689 // model. Each driver's bundle carries its advertised `driverKind`.
690 const codex = yield* registry.getInstance(codexId);
691 const claude = yield* registry.getInstance(claudeId);
692 const cursor = yield* registry.getInstance(cursorId);
693 const grok = yield* registry.getInstance(grokId);
694 const openCode = yield* registry.getInstance(openCodeId);
695 expect(codex?.driverKind).toBe(codexDriverKind);
696 expect(claude?.driverKind).toBe(claudeDriverKind);
697 expect(cursor?.driverKind).toBe(cursorDriverKind);
698 expect(grok?.driverKind).toBe(grokDriverKind);
699 expect(openCode?.driverKind).toBe(openCodeDriverKind);
700 expect(codex?.displayName).toBe("Codex");
701 expect(claude?.displayName).toBe("Claude");
702 expect(cursor?.displayName).toBe("Cursor");
703 expect(grok?.displayName).toBe("Grok");
704 expect(openCode?.displayName).toBe("OpenCode");
705
706 // Every instance owns its own set of closures — no sharing across
707 // drivers. `orchestrationAdapter` / `textGeneration` / `snapshot` are all
708 // distinct references even when two instances happen to share a
709 // trait (e.g. Cursor + others all use a stub-or-real
710 // `textGeneration`; they must still be different object values).
711 const adapters = [
712 codex!.orchestrationAdapter,
713 claude!.orchestrationAdapter,
714 cursor!.orchestrationAdapter,
715 grok!.orchestrationAdapter,
716 openCode!.orchestrationAdapter,
717 ];
718 expect(new Set(adapters).size).toBe(adapters.length);
719 const textGenerations = [
720 codex!.textGeneration,
721 claude!.textGeneration,
722 cursor!.textGeneration,
723 grok!.textGeneration,
724 openCode!.textGeneration,
725 ];
726 expect(new Set(textGenerations).size).toBe(textGenerations.length);
727 const snapshots = [
728 codex!.snapshot,
729 claude!.snapshot,
730 cursor!.snapshot,
731 grok!.snapshot,
732 openCode!.snapshot,
733 ];
734 expect(new Set(snapshots).size).toBe(snapshots.length);
735
736 // Snapshots identify themselves by `instanceId` + `driver` so
737 // downstream aggregation in `ProviderRegistry` can tell instances
738 // apart even when two share a driver. With `enabled: false`, the
739 // check short-circuits and we get a disabled/pending snapshot back
740 // — that's enough signal to validate the stamping wrapper without
741 // spawning real binaries.
742 const codexSnapshot = yield* codex!.snapshot.getSnapshot;
743 expect(codexSnapshot.instanceId).toBe(codexId);
744 expect(codexSnapshot.driver).toBe(codexDriverKind);
745 expect(codexSnapshot.enabled).toBe(false);
746 expect(codexSnapshot.continuation?.groupKey).toBe(
747 `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`,
748 );
749
750 const claudeSnapshot = yield* claude!.snapshot.getSnapshot;
751 expect(claudeSnapshot.instanceId).toBe(claudeId);
752 expect(claudeSnapshot.driver).toBe(claudeDriverKind);
753 expect(claudeSnapshot.enabled).toBe(false);
754 expect(claudeSnapshot.continuation?.groupKey).toBe(
755 `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`,
756 );
757
758 const cursorSnapshot = yield* cursor!.snapshot.getSnapshot;
759 expect(cursorSnapshot.instanceId).toBe(cursorId);
760 expect(cursorSnapshot.driver).toBe(cursorDriverKind);
761 expect(cursorSnapshot.enabled).toBe(false);
762 expect(cursorSnapshot.continuation?.groupKey).toBe(
763 `${cursorDriverKind}:instance:${cursorId}`,
764 );
765
766 const grokSnapshot = yield* grok!.snapshot.getSnapshot;
767 expect(grokSnapshot.instanceId).toBe(grokId);
768 expect(grokSnapshot.driver).toBe(grokDriverKind);
769 expect(grokSnapshot.enabled).toBe(false);
770 expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`);
771
772 const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot;
773 expect(openCodeSnapshot.instanceId).toBe(openCodeId);
774 expect(openCodeSnapshot.driver).toBe(openCodeDriverKind);
775 expect(openCodeSnapshot.enabled).toBe(false);
776 expect(openCodeSnapshot.continuation?.groupKey).toBe(
777 `${openCodeDriverKind}:instance:${openCodeId}`,
778 );
779 }).pipe(Effect.provide(layerTest)),
780 );
781});
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts deleted-437
......@@ -1,437 +0,0 @@
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 providerInstanceConfigEnabledFlag,
37 ProviderInstanceId,
38 type ProviderInstanceConfig,
39 type ProviderInstanceConfigMap,
40 type ProviderDriverKind,
41 type ServerProvider,
42} from "@t3tools/contracts";
43import * as Context from "effect/Context";
44import * as Effect from "effect/Effect";
45import * as Equal from "effect/Equal";
46import * as Exit from "effect/Exit";
47import * as Layer from "effect/Layer";
48import * as PubSub from "effect/PubSub";
49import * as Ref from "effect/Ref";
50import * as Schema from "effect/Schema";
51import * as Scope from "effect/Scope";
52import * as Stream from "effect/Stream";
53
54import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts";
55import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
56import * as ProviderInstanceRegistryMutator from "../Services/ProviderInstanceRegistryMutator.ts";
57import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
58
59/**
60 * Live registry entry: the materialized `ProviderInstance` + the fresh
61 * child scope its `create` effect ran in + the original `entry` envelope
62 * so `reconcile` can cheaply detect "no-op" updates.
63 */
64interface LiveEntry {
65 readonly instance: ProviderInstance;
66 readonly scope: Scope.Closeable;
67 readonly entry: ProviderInstanceConfig;
68}
69
70/**
71 * Internal state shared between the public registry service and the
72 * mutator service. Both services are thin shells around these refs.
73 */
74interface RegistryState {
75 readonly entries: Ref.Ref<ReadonlyMap<ProviderInstanceId, LiveEntry>>;
76 readonly unavailable: Ref.Ref<ReadonlyMap<ProviderInstanceId, ServerProvider>>;
77 readonly changes: PubSub.PubSub<void>;
78}
79
80/**
81 * Structural equality on `ProviderInstanceConfig` envelopes. Used by
82 * `reconcile` to skip rebuilds when settings arrive unchanged. Config
83 * payloads are opaque `unknown` at the envelope layer; `Equal.equals`
84 * falls back to structural equality for plain records, which matches how
85 * the schema decode output is constructed.
86 */
87const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean =>
88 Equal.equals(a, b);
89
90/**
91 * Resolve an entry's enabled state. An explicit false on either the
92 * envelope or the raw config blob wins (most restrictive) — old settings
93 * files can carry both flags with conflicting values, and a user's disable
94 * must never be silently undone. Otherwise the envelope flag wins, then the
95 * decoded config's flag (which carries the driver schema's default for
96 * built-ins and forks alike), then enabled by default.
97 */
98const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => {
99 const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config);
100 if (entry.enabled === false || rawConfigEnabled === false) {
101 return false;
102 }
103 return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true;
104};
105
106/**
107 * Build one live entry from a raw config envelope. Returns either a
108 * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and
109 * undefined entry — callers dispatch to the appropriate Ref bucket.
110 */
111const buildEntry = <R>(input: {
112 readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>;
113 readonly parentScope: Scope.Scope;
114 readonly instanceId: ProviderInstanceId;
115 readonly rawInstanceId: string;
116 readonly entry: ProviderInstanceConfig;
117}): Effect.Effect<
118 | { readonly kind: "live"; readonly live: LiveEntry }
119 | { readonly kind: "unavailable"; readonly snapshot: ServerProvider },
120 never,
121 R
122> =>
123 Effect.gen(function* () {
124 const { driversById, parentScope, instanceId, rawInstanceId, entry } = input;
125 const driver = driversById.get(entry.driver);
126 if (!driver) {
127 return {
128 kind: "unavailable" as const,
129 snapshot: yield* buildUnavailableProviderSnapshot({
130 driverKind: entry.driver,
131 instanceId,
132 displayName: entry.displayName,
133 accentColor: entry.accentColor,
134 reason: `Driver '${entry.driver}' is not registered in this build.`,
135 }),
136 };
137 }
138
139 const decoder = Schema.decodeUnknownEffect(driver.configSchema);
140 const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result);
141 if (decodeResult._tag === "Failure") {
142 const issue = decodeResult.failure;
143 const detail = issue.message ?? String(issue);
144 yield* Effect.logError("Failed to decode provider instance config", {
145 instanceId: rawInstanceId,
146 driver: entry.driver,
147 detail,
148 });
149 return {
150 kind: "unavailable" as const,
151 snapshot: yield* buildUnavailableProviderSnapshot({
152 driverKind: entry.driver,
153 instanceId,
154 displayName: entry.displayName,
155 accentColor: entry.accentColor,
156 reason: `Invalid config for instance '${rawInstanceId}': ${detail}`,
157 }),
158 };
159 }
160
161 const typedConfig = decodeResult.success;
162 const childScope = yield* Scope.make();
163 // Attach the child scope to the registry's parent scope: if the
164 // registry scope closes, each surviving instance's child scope is
165 // closed through this finalizer. `reconcile` manually closes the
166 // child scope on remove/replace; subsequent close via the parent's
167 // finalizer is a no-op because `Scope.close` is idempotent.
168 yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore));
169
170 const createResult = yield* driver
171 .create({
172 instanceId,
173 displayName: entry.displayName,
174 accentColor: entry.accentColor,
175 environment: entry.environment ?? [],
176 enabled: resolveEntryEnabled(entry, typedConfig),
177 config: typedConfig,
178 })
179 .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result);
180 if (createResult._tag === "Failure") {
181 yield* Effect.logError("Failed to create provider instance", {
182 instanceId: rawInstanceId,
183 driver: entry.driver,
184 detail: createResult.failure.detail,
185 });
186 yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore);
187 return {
188 kind: "unavailable" as const,
189 snapshot: yield* buildUnavailableProviderSnapshot({
190 driverKind: entry.driver,
191 instanceId,
192 displayName: entry.displayName,
193 accentColor: entry.accentColor,
194 reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`,
195 }),
196 };
197 }
198
199 return {
200 kind: "live" as const,
201 live: {
202 instance: createResult.success,
203 scope: childScope,
204 entry,
205 },
206 };
207 });
208
209/**
210 * Reconcile-only implementation of the mutator. Exposed to the hydration
211 * layer; never called directly by the rest of the server.
212 */
213const makeReconcile = <R>(input: {
214 readonly state: RegistryState;
215 readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>;
216 readonly parentScope: Scope.Scope;
217}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect<void, never, R>) => {
218 const { state, driversById, parentScope } = input;
219 return (configMap: ProviderInstanceConfigMap) =>
220 Effect.gen(function* () {
221 const previousEntries = yield* Ref.get(state.entries);
222 const previousUnavailable = yield* Ref.get(state.unavailable);
223 const nextRaw = Object.entries(configMap);
224 const nextKeys = new Set<ProviderInstanceId>(
225 nextRaw.map(([raw]) => ProviderInstanceId.make(raw)),
226 );
227
228 // 1. Close scopes for instances that disappeared or whose config
229 // changed. Do this BEFORE creating replacements so ids map 1-to-1
230 // to live scopes at all times.
231 const removedIds: Array<ProviderInstanceId> = [];
232 const replacedIds = new Set<ProviderInstanceId>();
233 for (const [instanceId, live] of previousEntries) {
234 if (!nextKeys.has(instanceId)) {
235 removedIds.push(instanceId);
236 continue;
237 }
238 const nextEntry = configMap[instanceId];
239 if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) {
240 replacedIds.add(instanceId);
241 }
242 }
243 for (const id of [...removedIds, ...replacedIds]) {
244 const live = previousEntries.get(id);
245 if (live) {
246 yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore);
247 }
248 }
249
250 // 2. Build additions and replacements. Walk `nextRaw` so the final
251 // entry order follows settings-author order.
252 const builtEntries = new Map<ProviderInstanceId, LiveEntry>();
253 const builtUnavailable = new Map<ProviderInstanceId, ServerProvider>();
254 let orderChanged = false;
255 const previousOrder = [...previousEntries.keys()];
256 const nextOrder: Array<ProviderInstanceId> = [];
257
258 for (const [rawInstanceId, entry] of nextRaw) {
259 const instanceId = ProviderInstanceId.make(rawInstanceId);
260 nextOrder.push(instanceId);
261
262 const existing = previousEntries.get(instanceId);
263 if (existing !== undefined && !replacedIds.has(instanceId)) {
264 // No-op update: keep the existing live entry and scope.
265 builtEntries.set(instanceId, existing);
266 continue;
267 }
268
269 const result = yield* buildEntry({
270 driversById,
271 parentScope,
272 instanceId,
273 rawInstanceId,
274 entry,
275 });
276 if (result.kind === "live") {
277 builtEntries.set(instanceId, result.live);
278 } else {
279 builtUnavailable.set(instanceId, result.snapshot);
280 }
281 }
282
283 if (previousOrder.length === nextOrder.length) {
284 for (let i = 0; i < previousOrder.length; i++) {
285 if (previousOrder[i] !== nextOrder[i]) {
286 orderChanged = true;
287 break;
288 }
289 }
290 } else {
291 orderChanged = true;
292 }
293
294 const entriesChanged =
295 orderChanged ||
296 removedIds.length > 0 ||
297 replacedIds.size > 0 ||
298 builtEntries.size !== previousEntries.size;
299 const unavailableChanged =
300 builtUnavailable.size !== previousUnavailable.size ||
301 [...builtUnavailable].some(([id, snapshot]) => {
302 const prev = previousUnavailable.get(id);
303 return prev === undefined || !Equal.equals(prev, snapshot);
304 }) ||
305 [...previousUnavailable].some(([id]) => !builtUnavailable.has(id));
306
307 yield* Ref.set(state.entries, builtEntries);
308 yield* Ref.set(state.unavailable, builtUnavailable);
309
310 if (entriesChanged || unavailableChanged) {
311 yield* PubSub.publish(state.changes, undefined);
312 }
313 });
314};
315
316/**
317 * Build the registry's runtime state from a concrete configMap. Returns a
318 * record containing:
319 *
320 * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose
321 * under `ProviderInstanceRegistry`.
322 * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose
323 * under `ProviderInstanceRegistryMutator`.
324 * - `reconcile`: the raw reconcile function, provided for convenience so
325 * boot-time layers can hydrate an initial map before publishing the
326 * services.
327 *
328 * The scope that this effect runs in owns every per-instance child scope
329 * created during `reconcile`. Closing that scope closes every live
330 * instance.
331 */
332export const makeProviderInstanceRegistry = <R>(input: {
333 readonly drivers: ReadonlyArray<AnyProviderDriver<R>>;
334 readonly configMap: ProviderInstanceConfigMap;
335}): Effect.Effect<
336 {
337 readonly registry: ProviderInstanceRegistry.ProviderInstanceRegistryShape;
338 readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape;
339 },
340 never,
341 R | Scope.Scope
342> =>
343 Effect.gen(function* () {
344 const driversById = new Map<ProviderDriverKind, AnyProviderDriver<R>>(
345 input.drivers.map((driver) => [driver.driverKind, driver]),
346 );
347
348 // Capture the enclosing scope so per-instance child scopes can be
349 // attached to it at `reconcile` time. Without this, `reconcile`
350 // called later (e.g. from the hydration layer) would attach child
351 // scopes to the *caller's* scope instead of the registry's.
352 const parentScope = yield* Scope.Scope;
353
354 // Capture the driver R context at construction time so `reconcile`
355 // can be invoked later without re-providing driver dependencies.
356 // The service tag's declared `reconcile: Effect<void>` hides R from
357 // consumers — we materialize that here.
358 const driverContext = yield* Effect.context<R>();
359
360 const entries = yield* Ref.make<ReadonlyMap<ProviderInstanceId, LiveEntry>>(new Map());
361 const unavailable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ServerProvider>>(new Map());
362 const changes = yield* PubSub.unbounded<void>();
363 yield* Effect.addFinalizer(() => PubSub.shutdown(changes));
364
365 const state: RegistryState = { entries, unavailable, changes };
366 const reconcileWithR = makeReconcile({ state, driversById, parentScope });
367 const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] =
368 (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext));
369
370 // Hydrate the initial configMap synchronously so callers can read
371 // `listInstances` immediately after this effect completes.
372 yield* reconcile(input.configMap);
373
374 const registry: ProviderInstanceRegistry.ProviderInstanceRegistryShape = {
375 getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)),
376 listInstances: Ref.get(entries).pipe(
377 Effect.map(
378 (map) =>
379 Array.from(map.values(), (live) => live.instance) as ReadonlyArray<ProviderInstance>,
380 ),
381 ),
382 listUnavailable: Ref.get(unavailable).pipe(
383 Effect.map((map) => Array.from(map.values()) as ReadonlyArray<ServerProvider>),
384 ),
385 // Getters: each read constructs a fresh Stream / Effect descriptor
386 // so multiple consumers don't share a single already-started
387 // Channel or subscription. Matches the pattern `ProviderRegistry`
388 // uses for its own `streamChanges`.
389 get streamChanges() {
390 return Stream.fromPubSub(changes);
391 },
392 // Synchronous subscribe — callers that need to consume changes
393 // from a forked fibre must acquire the subscription in their own
394 // fibre first (via `yield* registry.subscribeChanges`) and only
395 // then fork a consumer loop on `Stream.fromSubscription(...)` /
396 // `PubSub.take(...)`. See the shape docs for the race this avoids.
397 get subscribeChanges() {
398 return PubSub.subscribe(changes);
399 },
400 };
401
402 const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = {
403 reconcile,
404 };
405
406 return { registry, mutator };
407 });
408
409/**
410 * Layer variant that also exposes the mutator tag. Consumed by
411 * `ProviderInstanceRegistryHydrationLive` to reconcile on settings
412 * changes. Tests that exercise the mutator directly can pair this Layer
413 * with a test-local `ServerSettingsService`.
414 */
415export const layer = <R>(input: {
416 readonly drivers: ReadonlyArray<AnyProviderDriver<R>>;
417 readonly configMap: ProviderInstanceConfigMap;
418}): Layer.Layer<
419 | ProviderInstanceRegistry.ProviderInstanceRegistry
420 | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
421 never,
422 R
423> =>
424 Layer.effectContext(
425 makeProviderInstanceRegistry(input).pipe(
426 Effect.map(({ registry, mutator }) =>
427 Context.make(ProviderInstanceRegistry.ProviderInstanceRegistry, registry).pipe(
428 Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator),
429 ),
430 ),
431 ),
432 ) as Layer.Layer<
433 | ProviderInstanceRegistry.ProviderInstanceRegistry
434 | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
435 never,
436 R
437 >;
apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts deleted-27
......@@ -1,27 +0,0 @@
1import * as Layer from "effect/Layer";
2
3import * as ClaudeAdapterV2 from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
4import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
5import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts";
6import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
7import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts";
8
9export type ProviderOrchestrationAdapterInfrastructure =
10 | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner
11 | CodexAdapterV2.CodexAppServerClientFactory
12 | CursorAgentSdk.CursorAgentSdkRunner
13 | IdAllocator.IdAllocatorV2;
14
15/**
16 * Infrastructure shared by the V2 adapters materialized inside provider
17 * instances. `providerContinuationRequestsLayer` must be the same layer
18 * reference the orchestration runtime provides to its continuation worker so
19 * Effect layer memoization yields one shared queue.
20 */
21export const layer = Layer.mergeAll(
22 ClaudeAdapterV2.layerQueryRunner,
23 CodexAdapterV2.layerAppServerClientFactory,
24 CursorAgentSdk.layer,
25 IdAllocator.layer,
26 ProviderContinuationRequests.layer,
27);
apps/server/src/provider/Layers/ProviderRegistry.test.ts deleted-3338
......@@ -1,3338 +0,0 @@
1import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "../CodexInstallation.ts";
3import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, it, assert } from "@effect/vitest";
6import * as DateTime from "effect/DateTime";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Path from "effect/Path";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as PubSub from "effect/PubSub";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Sink from "effect/Sink";
18import * as Stream from "effect/Stream";
19import * as TestClock from "effect/testing/TestClock";
20import * as CodexErrors from "effect-codex-app-server/errors";
21import {
22 EnvironmentId,
23 ClaudeSettings,
24 CodexSettings,
25 DEFAULT_SERVER_SETTINGS,
26 ProviderDriverKind,
27 ProviderInstanceId,
28 ServerSettings,
29 type ServerProvider,
30 type ServerProviderSlashCommand,
31 type ServerSettings as ContractServerSettings,
32} from "@t3tools/contracts";
33import * as PlatformError from "effect/PlatformError";
34import { HttpClient, HttpClientResponse } from "effect/http";
35import { ChildProcessSpawner } from "effect/process";
36import { deepMerge } from "@t3tools/shared/Struct";
37import { createModelCapabilities } from "@t3tools/shared/model";
38import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
39
40import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
41import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
42import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
43import * as AntigravityInstallation from "../AntigravityInstallation.ts";
44import * as ModelManifest from "../ModelManifest.ts";
45import { applyProviderCompatibility } from "../providerCompatibility.ts";
46import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
47import * as OpenCodeRuntime from "../opencodeRuntime.ts";
48import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
49import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
50import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts";
51import {
52 mergeProviderSnapshot,
53 mergeProviderSnapshots,
54 selectProvidersByKind,
55 upsertProviderWorkspaceSnapshot,
56} from "./ProviderRegistry.ts";
57import * as ProviderRegistryLayer from "./ProviderRegistry.ts";
58import * as ServerConfig from "../../config.ts";
59import * as ServerSettingsModule from "../../serverSettings.ts";
60import {
61 readProviderStatusCache,
62 resolveProviderStatusCachePath,
63 writeProviderStatusCache,
64} from "../providerStatusCache.ts";
65import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
66import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
67import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
68import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
69import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
70const decodeServerSettings = Schema.decodeSync(ServerSettings);
71const encodeServerSettings = Schema.encodeSync(ServerSettings);
72const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
73
74const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
75const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
76const decodeCodexSettings = Schema.decodeSync(CodexSettings);
77const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({
78 enabled: false,
79});
80
81process.env.T3CODE_CURSOR_ENABLED = "1";
82
83// ── Test helpers ────────────────────────────────────────────────────
84
85const encoder = new TextEncoder();
86const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
87const withBundledCompatibility = (snapshot: ServerProvider) =>
88 applyProviderCompatibility(
89 snapshot,
90 undefined,
91 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
92 );
93
94// Provider metadata checks use a bundled manifest and stubbed HTTP.
95const layerTestHttpClient = Layer.succeed(
96 HttpClient.HttpClient,
97 HttpClient.make((request) =>
98 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
99 ),
100).pipe(Layer.provideMerge(ModelManifest.layerTest));
101
102const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
103 reportClientActivity: () => Effect.void,
104 removeRpcClient: () => Effect.void,
105 reportHostPowerState: () => Effect.void,
106 snapshot: Effect.succeed({
107 hostPower: {
108 source: "unknown",
109 idle: "unknown",
110 idleSeconds: null,
111 locked: "unknown",
112 suspended: false,
113 onBattery: "unknown",
114 lowPowerMode: "unknown",
115 thermalState: "unknown",
116 stale: true,
117 updatedAt: TEST_EPOCH,
118 },
119 leases: [],
120 activeForegroundLeaseCount: 0,
121 activeScopeKeys: [],
122 shouldRunOpportunisticWork: true,
123 updatedAt: TEST_EPOCH,
124 }),
125 streamChanges: Stream.empty,
126 hasDemand: () => Effect.succeed(true),
127 shouldRunScopeWork: () => Effect.succeed(true),
128 shouldRunOpportunisticWork: Effect.succeed(true),
129});
130
131function selectDescriptor(
132 id: string,
133 label: string,
134 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
135) {
136 return {
137 id,
138 label,
139 type: "select" as const,
140 options: [...options],
141 ...(options.find((option) => option.isDefault)?.id
142 ? { currentValue: options.find((option) => option.isDefault)?.id }
143 : {}),
144 };
145}
146
147function booleanDescriptor(id: string, label: string) {
148 return {
149 id,
150 label,
151 type: "boolean" as const,
152 };
153}
154
155type TestClaudeCapabilities = {
156 readonly email: string | undefined;
157 readonly subscriptionType: string | undefined;
158 readonly tokenSource: string | undefined;
159 readonly apiProvider: string | undefined;
160 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
161};
162
163function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
164 return () =>
165 Effect.succeed({
166 email: undefined,
167 subscriptionType: undefined,
168 tokenSource: undefined,
169 apiProvider: undefined,
170 slashCommands: [],
171 ...overrides,
172 });
173}
174
175const noClaudeCapabilities = () =>
176 Effect.sync(() => undefined as TestClaudeCapabilities | undefined);
177
178function mockHandle(result: { stdout: string; stderr: string; code: number }) {
179 return ChildProcessSpawner.makeHandle({
180 pid: ChildProcessSpawner.ProcessId(1),
181 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)),
182 isRunning: Effect.succeed(false),
183 kill: () => Effect.void,
184 unref: Effect.succeed(Effect.void),
185 stdin: Sink.drain,
186 stdout: Stream.make(encoder.encode(result.stdout)),
187 stderr: Stream.make(encoder.encode(result.stderr)),
188 all: Stream.empty,
189 getInputFd: () => Sink.drain,
190 getOutputFd: () => Stream.empty,
191 });
192}
193
194function layerMockSpawner(
195 handler: (args: ReadonlyArray<string>) => {
196 stdout: string;
197 stderr: string;
198 code: number;
199 },
200) {
201 return Layer.succeed(
202 ChildProcessSpawner.ChildProcessSpawner,
203 ChildProcessSpawner.make((command) => {
204 const cmd = command as unknown as { args: ReadonlyArray<string> };
205 return Effect.succeed(mockHandle(handler(cmd.args)));
206 }),
207 );
208}
209
210function recordingMockSpawnerLayer(
211 handler: (args: ReadonlyArray<string>) => {
212 stdout: string;
213 stderr: string;
214 code: number;
215 },
216) {
217 const commands: Array<{
218 readonly args: ReadonlyArray<string>;
219 readonly env: NodeJS.ProcessEnv | undefined;
220 }> = [];
221 const layer = Layer.succeed(
222 ChildProcessSpawner.ChildProcessSpawner,
223 ChildProcessSpawner.make((command) => {
224 const cmd = command as unknown as {
225 args: ReadonlyArray<string>;
226 options?: {
227 readonly env?: NodeJS.ProcessEnv;
228 };
229 };
230 commands.push({ args: cmd.args, env: cmd.options?.env });
231 return Effect.succeed(mockHandle(handler(cmd.args)));
232 }),
233 );
234 return { layer, commands };
235}
236
237function layerMockCommandSpawner(
238 handler: (
239 command: string,
240 args: ReadonlyArray<string>,
241 ) => { stdout: string; stderr: string; code: number },
242) {
243 return Layer.succeed(
244 ChildProcessSpawner.ChildProcessSpawner,
245 ChildProcessSpawner.make((command) => {
246 const cmd = command as unknown as {
247 command: string;
248 args: ReadonlyArray<string>;
249 };
250 return Effect.succeed(mockHandle(handler(cmd.command, cmd.args)));
251 }),
252 );
253}
254
255function layerFailingSpawner(description: string) {
256 return Layer.succeed(
257 ChildProcessSpawner.ChildProcessSpawner,
258 ChildProcessSpawner.make(() =>
259 Effect.fail(
260 PlatformError.systemError({
261 _tag: "NotFound",
262 module: "ChildProcess",
263 method: "spawn",
264 description,
265 }),
266 ),
267 ),
268 );
269}
270
271function layerHangingScopedSpawner(killCalls: Ref.Ref<number>) {
272 return Layer.succeed(
273 ChildProcessSpawner.ChildProcessSpawner,
274 ChildProcessSpawner.make(() =>
275 Effect.gen(function* () {
276 const handle = ChildProcessSpawner.makeHandle({
277 pid: ChildProcessSpawner.ProcessId(1),
278 exitCode: Effect.never,
279 isRunning: Effect.succeed(true),
280 kill: () => Ref.update(killCalls, (current) => current + 1),
281 unref: Effect.succeed(Effect.void),
282 stdin: Sink.drain,
283 stdout: Stream.never,
284 stderr: Stream.never,
285 all: Stream.never,
286 getInputFd: () => Sink.drain,
287 getOutputFd: () => Stream.empty,
288 });
289 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
290 return handle;
291 }),
292 ),
293 );
294}
295
296const codexModelCapabilities = createModelCapabilities({
297 optionDescriptors: [
298 selectDescriptor("reasoningEffort", "Reasoning", [
299 { id: "high", label: "High", isDefault: true },
300 { id: "low", label: "Low" },
301 ]),
302 booleanDescriptor("fastMode", "Fast Mode"),
303 ],
304}) satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>;
305
306function makeCodexProbeSnapshot(
307 input: Partial<CodexAppServerProviderSnapshot> = {},
308): CodexAppServerProviderSnapshot {
309 return {
310 version: "1.0.0",
311 account: {
312 account: {
313 type: "chatgpt",
314 email: "test@example.com",
315 planType: "pro",
316 },
317 requiresOpenaiAuth: false,
318 },
319 models: [
320 {
321 slug: "gpt-live-codex",
322 name: "GPT Live Codex",
323 isCustom: false,
324 capabilities: codexModelCapabilities,
325 },
326 ],
327 skills: [],
328 ...input,
329 };
330}
331
332function makeMutableServerSettingsService(
333 initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS,
334) {
335 return Effect.gen(function* () {
336 const settingsRef = yield* Ref.make(initial);
337 const changes = yield* PubSub.unbounded<ContractServerSettings>();
338
339 return {
340 start: Effect.void,
341 ready: Effect.void,
342 getSettings: Ref.get(settingsRef),
343 updateSettings: (patch) =>
344 Effect.gen(function* () {
345 const current = yield* Ref.get(settingsRef);
346 const next = applyServerSettingsPatch(current, patch);
347 encodeServerSettings(next);
348 yield* Ref.set(settingsRef, next);
349 yield* PubSub.publish(changes, next);
350 return next;
351 }),
352 updateProviderInstance: (mutation, patch = {}) =>
353 Effect.gen(function* () {
354 const current = yield* Ref.get(settingsRef);
355 const next = ServerSettingsModule.applyProviderInstanceMutation(
356 applyServerSettingsPatch(current, patch),
357 mutation,
358 );
359 encodeServerSettings(next);
360 yield* Ref.set(settingsRef, next);
361 yield* PubSub.publish(changes, next);
362 return next;
363 }),
364 withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)),
365 get streamChanges() {
366 return Stream.fromPubSub(changes);
367 },
368 get subscribeChanges() {
369 return PubSub.subscribe(changes).pipe(
370 Effect.map((subscription) => Stream.fromSubscription(subscription)),
371 );
372 },
373 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
374 });
375}
376
377// The registry writes the status cache and only then publishes the change, so
378// a subscriber that sees `checkedAt` on the stream knows the file is on disk.
379// Subscribed before the publish that triggers it; a spin on the file would
380// race the write and lose on a slow host.
381const awaitPersistedProvider = (
382 registry: ProviderRegistry.ProviderRegistry["Service"],
383 checkedAt: string,
384) =>
385 registry.streamChanges.pipe(
386 Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)),
387 Stream.take(1),
388 Stream.runDrain,
389 Effect.forkScoped,
390 );
391
392const layerTestNodeServices = Layer.mergeAll(
393 NodeServices.layer,
394 Layer.mock(CodexInstallation.CodexInstallation)({
395 managedDirectory: "unused-managed-installation",
396 }),
397 Layer.mock(ServerSecretStore.ServerSecretStore)({}),
398 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
399 getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")),
400 }),
401);
402
403it.layer(
404 Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient),
405)("ProviderRegistry", (it) => {
406 describe("checkCodexProviderStatus", () => {
407 it.effect("uses the app-server account and model list for provider status", () =>
408 Effect.gen(function* () {
409 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
410 Effect.succeed(
411 makeCodexProbeSnapshot({
412 skills: [
413 {
414 name: "github:gh-fix-ci",
415 path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md",
416 enabled: true,
417 displayName: "CI Debug",
418 shortDescription: "Debug failing GitHub Actions checks",
419 },
420 ],
421 }),
422 ),
423 );
424 assert.strictEqual(status.status, "ready");
425 assert.strictEqual(status.installed, true);
426 assert.strictEqual(status.version, "1.0.0");
427 assert.strictEqual(status.auth.status, "authenticated");
428 assert.strictEqual(status.auth.type, "chatgpt");
429 assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription");
430 assert.strictEqual(status.auth.email, "test@example.com");
431 assert.deepStrictEqual(status.models, [
432 {
433 slug: "gpt-live-codex",
434 name: "GPT Live Codex",
435 isCustom: false,
436 capabilities: codexModelCapabilities,
437 },
438 ]);
439 assert.deepStrictEqual(status.skills, [
440 {
441 name: "github:gh-fix-ci",
442 path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md",
443 enabled: true,
444 displayName: "CI Debug",
445 shortDescription: "Debug failing GitHub Actions checks",
446 },
447 ]);
448 assert.deepStrictEqual(status.slashCommands.slice(1), [
449 {
450 name: "goal",
451 description: "Set a goal Codex keeps working toward until it is done",
452 input: { hint: "Objective, or pause, resume, clear" },
453 },
454 {
455 name: "feedback",
456 description: "Send this thread and Codex logs to OpenAI",
457 input: { hint: "Describe the issue (optional)" },
458 },
459 ]);
460 }),
461 );
462
463 it.effect("passes configured launch args to the Codex provider probe", () =>
464 Effect.gen(function* () {
465 let observedLaunchArgs: string | undefined;
466 const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" });
467
468 const status = yield* checkCodexProviderStatus(settings, (input) => {
469 observedLaunchArgs = input.launchArgs;
470 return Effect.succeed(makeCodexProbeSnapshot());
471 });
472
473 assert.strictEqual(status.status, "ready");
474 assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo");
475 }),
476 );
477
478 it.effect("returns unauthenticated when app-server requires OpenAI auth", () =>
479 Effect.gen(function* () {
480 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
481 Effect.succeed(
482 makeCodexProbeSnapshot({
483 account: {
484 account: null,
485 requiresOpenaiAuth: true,
486 },
487 }),
488 ),
489 );
490
491 assert.strictEqual(status.status, "error");
492 assert.strictEqual(status.auth.status, "unauthenticated");
493 assert.strictEqual(
494 status.message,
495 "Codex CLI is not authenticated. Run `codex login` and try again.",
496 );
497 }),
498 );
499
500 it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () =>
501 Effect.gen(function* () {
502 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
503 Effect.succeed(
504 makeCodexProbeSnapshot({
505 account: {
506 account: null,
507 requiresOpenaiAuth: false,
508 },
509 }),
510 ),
511 );
512
513 assert.strictEqual(status.status, "ready");
514 assert.strictEqual(status.auth.status, "unknown");
515 }),
516 );
517
518 it.effect("returns an api key label for codex api key auth", () =>
519 Effect.gen(function* () {
520 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
521 Effect.succeed(
522 makeCodexProbeSnapshot({
523 account: {
524 account: { type: "apiKey" },
525 requiresOpenaiAuth: false,
526 },
527 }),
528 ),
529 );
530
531 assert.strictEqual(status.status, "ready");
532 assert.strictEqual(status.auth.status, "authenticated");
533 assert.strictEqual(status.auth.type, "apiKey");
534 assert.strictEqual(status.auth.label, "OpenAI API Key");
535 }),
536 );
537
538 it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () =>
539 Effect.gen(function* () {
540 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
541 Effect.succeed(
542 makeCodexProbeSnapshot({
543 account: {
544 account: { type: "amazonBedrock" },
545 requiresOpenaiAuth: false,
546 },
547 }),
548 ),
549 );
550
551 assert.strictEqual(status.status, "ready");
552 assert.strictEqual(status.auth.status, "authenticated");
553 assert.strictEqual(status.auth.type, "amazonBedrock");
554 assert.strictEqual(status.auth.label, "Amazon Bedrock");
555 }),
556 );
557
558 it.effect.each([
559 "codex",
560 "/Applications/Custom App.app/Contents/Resources/codex",
561 "C:\\Tools\\codex.exe",
562 ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) =>
563 Effect.gen(function* () {
564 const settings = { ...defaultCodexSettings, binaryPath };
565 const status = yield* checkCodexProviderStatus(settings, (input) => {
566 assert.strictEqual(input.binaryPath, binaryPath);
567 return Effect.fail(
568 new CodexErrors.CodexAppServerSpawnError({
569 command: `${binaryPath} app-server`,
570 cause: new Error("spawn ENOENT"),
571 }),
572 );
573 });
574 assert.strictEqual(status.status, "error");
575 assert.strictEqual(status.installed, false);
576 assert.strictEqual(status.auth.status, "unknown");
577 assert.include(status.message, binaryPath);
578 assert.include(status.message, "Settings → Providers → Codex → Binary path on the server");
579 assert.strictEqual(
580 status.message?.includes("Installing ChatGPT or Codex desktop"),
581 binaryPath === "codex",
582 );
583 assert.strictEqual(settings.binaryPath, binaryPath);
584 }),
585 );
586
587 it.effect("closes the app-server probe scope when provider status times out", () =>
588 Effect.gen(function* () {
589 const killCalls = yield* Ref.make(0);
590 const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe(
591 Effect.provide(layerHangingScopedSpawner(killCalls)),
592 Effect.forkChild,
593 );
594
595 yield* Effect.yieldNow;
596 yield* TestClock.adjust("11 seconds");
597 yield* Effect.yieldNow;
598
599 const status = yield* Fiber.join(statusFiber);
600 assert.strictEqual(status.status, "error");
601 assert.strictEqual(
602 status.message,
603 "Timed out while checking Codex app-server provider status.",
604 );
605 assert.strictEqual(yield* Ref.get(killCalls), 1);
606 }),
607 );
608 });
609
610 describe("ProviderRegistryLive", () => {
611 it("stores workspace skills and commands without changing machine metadata", () => {
612 const provider = {
613 instanceId: ProviderInstanceId.make("codex"),
614 driver: ProviderDriverKind.make("codex"),
615 status: "ready",
616 enabled: true,
617 installed: true,
618 auth: { status: "authenticated" },
619 checkedAt: "2026-03-25T00:00:00.000Z",
620 version: "1.0.0",
621 models: [],
622 slashCommands: [{ name: "global" }],
623 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
624 } satisfies ServerProvider;
625 const scopedSnapshot = {
626 ...provider,
627 checkedAt: "2026-03-25T00:01:00.000Z",
628 slashCommands: [{ name: "project" }],
629 skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }],
630 } satisfies ServerProvider;
631
632 const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot);
633
634 assert.deepStrictEqual(result.slashCommands, provider.slashCommands);
635 assert.deepStrictEqual(result.skills, provider.skills);
636 assert.deepStrictEqual(result.workspaceSnapshots, [
637 {
638 cwd: "/project",
639 checkedAt: scopedSnapshot.checkedAt,
640 slashCommands: scopedSnapshot.slashCommands,
641 skills: scopedSnapshot.skills,
642 },
643 ]);
644
645 const pendingSnapshot = {
646 ...scopedSnapshot,
647 slashCommands: [COMPACT_SLASH_COMMAND],
648 slashCommandsPending: true,
649 } satisfies ProviderWorkspaceSnapshot;
650 const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot);
651 assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [{ name: "project" }]);
652 assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true);
653 const otherProject = upsertProviderWorkspaceSnapshot(
654 partial,
655 "/other-project",
656 pendingSnapshot,
657 );
658 assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [
659 COMPACT_SLASH_COMMAND,
660 ]);
661 const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", {
662 ...scopedSnapshot,
663 slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }],
664 });
665 assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [
666 COMPACT_SLASH_COMMAND,
667 { name: "replacement" },
668 ]);
669 assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined);
670 });
671
672 it("preserves previously discovered provider models when a refresh returns none", () => {
673 const previousProvider = {
674 instanceId: ProviderInstanceId.make("cursor"),
675 driver: ProviderDriverKind.make("cursor"),
676 status: "ready",
677 enabled: true,
678 installed: true,
679 auth: { status: "authenticated" },
680 checkedAt: "2026-04-14T00:00:00.000Z",
681 version: "2026.04.09-f2b0fcd",
682 models: [
683 {
684 slug: "claude-opus-4-6",
685 name: "Opus 4.6",
686 isCustom: false,
687 capabilities: createModelCapabilities({
688 optionDescriptors: [
689 selectDescriptor("reasoning", "Reasoning", [
690 { id: "high", label: "High", isDefault: true },
691 ]),
692 booleanDescriptor("fastMode", "Fast Mode"),
693 booleanDescriptor("thinking", "Thinking"),
694 ],
695 }),
696 },
697 ],
698 slashCommands: [{ name: "review", description: "Review changes" }],
699 skills: [
700 {
701 name: "typescript",
702 description: "TypeScript help",
703 path: "/skills/typescript/SKILL.md",
704 enabled: true,
705 },
706 ],
707 } as const satisfies ServerProvider;
708 const refreshedProvider = {
709 ...previousProvider,
710 checkedAt: "2026-04-14T00:01:00.000Z",
711 models: [],
712 slashCommands: [],
713 skills: [],
714 } satisfies ServerProvider;
715
716 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
717 ...previousProvider.models,
718 ]);
719 assert.deepStrictEqual(
720 mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands,
721 [],
722 );
723 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).skills, []);
724 });
725
726 it("drops custom models the refreshed snapshot no longer carries", () => {
727 const previousProvider = {
728 instanceId: ProviderInstanceId.make("claudeAgent"),
729 driver: ProviderDriverKind.make("claudeAgent"),
730 status: "ready",
731 enabled: true,
732 installed: true,
733 auth: { status: "authenticated" },
734 checkedAt: "2026-04-14T00:00:00.000Z",
735 version: "2.1.0",
736 models: [
737 {
738 slug: "claude-sonnet-4-6",
739 name: "Sonnet 4.6",
740 isCustom: false,
741 capabilities: null,
742 },
743 {
744 slug: "removed-custom",
745 name: "removed-custom",
746 isCustom: true,
747 capabilities: null,
748 },
749 ],
750 slashCommands: [],
751 skills: [],
752 } as const satisfies ServerProvider;
753 const refreshedProvider = {
754 ...previousProvider,
755 checkedAt: "2026-04-14T00:01:00.000Z",
756 models: [previousProvider.models[0]],
757 } satisfies ServerProvider;
758
759 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
760 ...refreshedProvider.models,
761 ]);
762 });
763
764 it("drops stale ACP Registry models missing from a completed discovery probe", () => {
765 const previousProvider = {
766 instanceId: ProviderInstanceId.make("acpRegistry_codex"),
767 driver: ProviderDriverKind.make("acpRegistry"),
768 status: "ready",
769 enabled: true,
770 installed: true,
771 auth: { status: "authenticated" },
772 checkedAt: "2026-08-13T00:00:00.000Z",
773 version: "1.2.0",
774 models: [
775 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
776 {
777 slug: "gpt-5.6-sol[low]",
778 name: "GPT-5.6-Sol (low)",
779 isCustom: false,
780 capabilities: null,
781 },
782 { slug: "default", name: "Default", isCustom: false, capabilities: null },
783 ],
784 slashCommands: [],
785 skills: [],
786 } as const satisfies ServerProvider;
787 const refreshedProvider = {
788 ...previousProvider,
789 checkedAt: "2026-08-13T00:01:00.000Z",
790 models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }],
791 } satisfies ServerProvider;
792
793 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
794 ...refreshedProvider.models,
795 ]);
796 });
797
798 it("retains ACP Registry models while discovery has not completed", () => {
799 const previousProvider = {
800 instanceId: ProviderInstanceId.make("acpRegistry_codex"),
801 driver: ProviderDriverKind.make("acpRegistry"),
802 status: "ready",
803 enabled: true,
804 installed: true,
805 auth: { status: "authenticated" },
806 checkedAt: "2026-08-13T00:00:00.000Z",
807 version: "1.2.0",
808 models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }],
809 slashCommands: [],
810 skills: [],
811 } as const satisfies ServerProvider;
812 const checkingProvider = {
813 ...previousProvider,
814 checkedAt: "2026-08-13T00:01:00.000Z",
815 auth: { status: "unknown" },
816 models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }],
817 } satisfies ServerProvider;
818 const failedProbeProvider = {
819 ...checkingProvider,
820 status: "warning",
821 } satisfies ServerProvider;
822
823 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, checkingProvider).models, [
824 { slug: "default", name: "Default", isCustom: false, capabilities: null },
825 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
826 ]);
827 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, failedProbeProvider).models, [
828 { slug: "default", name: "Default", isCustom: false, capabilities: null },
829 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
830 ]);
831 });
832
833 it("drops stale OpenCode models missing from a successful refresh", () => {
834 const previousProvider = {
835 instanceId: ProviderInstanceId.make("opencode"),
836 driver: ProviderDriverKind.make("opencode"),
837 status: "ready",
838 enabled: true,
839 installed: true,
840 auth: { status: "authenticated" },
841 checkedAt: "2026-07-17T00:00:00.000Z",
842 version: "1.0.0",
843 models: [
844 {
845 slug: "github/gpt-5",
846 name: "GPT-5",
847 subProvider: "GitHub",
848 isCustom: false,
849 capabilities: null,
850 },
851 {
852 slug: "removed-plugin/model",
853 name: "Removed Plugin Model",
854 subProvider: "Removed Plugin",
855 isCustom: false,
856 capabilities: null,
857 },
858 ],
859 slashCommands: [],
860 skills: [],
861 } as const satisfies ServerProvider;
862 const refreshedProvider = {
863 ...previousProvider,
864 checkedAt: "2026-07-17T00:01:00.000Z",
865 models: [
866 {
867 slug: "github/gpt-5",
868 name: "GPT-5",
869 subProvider: "GitHub",
870 isCustom: false,
871 capabilities: null,
872 },
873 ],
874 } satisfies ServerProvider;
875
876 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
877 ...refreshedProvider.models,
878 ]);
879 });
880
881 it("retains stale OpenCode models when a refresh fails", () => {
882 const previousProvider = {
883 instanceId: ProviderInstanceId.make("opencode"),
884 driver: ProviderDriverKind.make("opencode"),
885 status: "ready",
886 enabled: true,
887 installed: true,
888 auth: { status: "authenticated" },
889 checkedAt: "2026-07-17T00:00:00.000Z",
890 version: "1.0.0",
891 models: [
892 {
893 slug: "github/gpt-5",
894 name: "GPT-5",
895 subProvider: "GitHub",
896 isCustom: false,
897 capabilities: null,
898 },
899 ],
900 slashCommands: [{ name: "review", description: "Review changes" }],
901 skills: [
902 {
903 name: "typescript",
904 description: "TypeScript help",
905 path: "/skills/typescript/SKILL.md",
906 enabled: true,
907 },
908 ],
909 } as const satisfies ServerProvider;
910 const refreshedProvider = {
911 ...previousProvider,
912 status: "error",
913 auth: { status: "unknown" },
914 checkedAt: "2026-07-17T00:01:00.000Z",
915 models: [],
916 message: "Failed to refresh OpenCode models.",
917 } satisfies ServerProvider;
918
919 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
920 ...previousProvider.models,
921 ]);
922 assert.deepStrictEqual(
923 mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands,
924 previousProvider.slashCommands,
925 );
926 assert.deepStrictEqual(
927 mergeProviderSnapshot(previousProvider, refreshedProvider).skills,
928 previousProvider.skills,
929 );
930 });
931
932 it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => {
933 const previousProvider = {
934 instanceId: ProviderInstanceId.make("opencode"),
935 driver: ProviderDriverKind.make("opencode"),
936 status: "ready",
937 enabled: true,
938 installed: true,
939 auth: { status: "authenticated" },
940 checkedAt: "2026-07-17T00:00:00.000Z",
941 version: "1.0.0",
942 models: [
943 {
944 slug: "github/gpt-5",
945 name: "GPT-5",
946 subProvider: "GitHub",
947 isCustom: false,
948 capabilities: null,
949 },
950 {
951 slug: "removed-plugin/model",
952 name: "Removed Plugin Model",
953 subProvider: "Removed Plugin",
954 isCustom: false,
955 capabilities: null,
956 },
957 ],
958 slashCommands: [{ name: "review", description: "Review changes" }],
959 skills: [
960 {
961 name: "typescript",
962 description: "TypeScript help",
963 path: "/skills/typescript/SKILL.md",
964 enabled: true,
965 },
966 ],
967 } as const satisfies ServerProvider;
968 const pendingProvider = {
969 ...previousProvider,
970 status: "warning",
971 installed: false,
972 auth: { status: "unknown" },
973 checkedAt: "2026-07-17T00:01:00.000Z",
974 version: null,
975 models: [],
976 message: "OpenCode provider status has not been checked in this session yet.",
977 } satisfies ServerProvider;
978 const loggedOutProvider = {
979 ...previousProvider,
980 status: "warning",
981 auth: { status: "unknown" },
982 checkedAt: "2026-07-17T00:02:00.000Z",
983 models: [],
984 slashCommands: [],
985 skills: [],
986 message: "OpenCode is available, but it did not report any connected upstream providers.",
987 } satisfies ServerProvider;
988 const missingProvider = {
989 ...previousProvider,
990 status: "error",
991 installed: false,
992 auth: { status: "unknown" },
993 checkedAt: "2026-07-17T00:03:00.000Z",
994 version: null,
995 models: [],
996 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
997 } satisfies ServerProvider;
998 const authoritativeProvider = {
999 ...previousProvider,
1000 checkedAt: "2026-07-17T00:04:00.000Z",
1001 models: [previousProvider.models[0]!],
1002 } satisfies ServerProvider;
1003 const failedProvider = {
1004 ...authoritativeProvider,
1005 status: "error",
1006 auth: { status: "unknown" },
1007 checkedAt: "2026-07-17T00:05:00.000Z",
1008 models: [],
1009 message: "Failed to refresh OpenCode models.",
1010 } satisfies ServerProvider;
1011
1012 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [
1013 ...previousProvider.models,
1014 ]);
1015 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).models, []);
1016 assert.deepStrictEqual(
1017 mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands,
1018 [],
1019 );
1020 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, []);
1021 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []);
1022
1023 const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider);
1024 const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider);
1025
1026 assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]);
1027 });
1028
1029 describe("Codex model inventories", () => {
1030 const cachedProvider = {
1031 instanceId: ProviderInstanceId.make("codex-personal"),
1032 driver: ProviderDriverKind.make("codex"),
1033 status: "ready",
1034 enabled: true,
1035 installed: true,
1036 auth: { status: "authenticated" },
1037 checkedAt: "2026-09-04T19:00:00.000Z",
1038 version: "0.153.3",
1039 models: ["vega-alpha", "joule-alpha", "kindle-alpha", "ultima-alpha", "solstice-alpha"].map(
1040 (slug) => ({ slug, name: slug, isCustom: false, capabilities: null }),
1041 ),
1042 slashCommands: [],
1043 skills: [],
1044 } satisfies ServerProvider;
1045 const customModel = {
1046 slug: "custom-model",
1047 name: "Custom model",
1048 isCustom: true,
1049 capabilities: null,
1050 } as const;
1051 const refreshedProvider = {
1052 ...cachedProvider,
1053 checkedAt: "2026-09-04T19:01:00.000Z",
1054 models: [
1055 { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null },
1056 cachedProvider.models[0]!,
1057 customModel,
1058 ],
1059 } satisfies ServerProvider;
1060 const pendingProvider = {
1061 ...cachedProvider,
1062 status: "warning",
1063 installed: false,
1064 auth: { status: "unknown" },
1065 models: [customModel],
1066 } satisfies ServerProvider;
1067 const failedProvider = {
1068 ...pendingProvider,
1069 checkedAt: "2026-09-04T19:02:00.000Z",
1070 status: "error",
1071 installed: true,
1072 } satisfies ServerProvider;
1073
1074 it("drops retired alpha models after discovery, including without OpenAI authentication", () => {
1075 for (const authStatus of ["authenticated", "unknown"] as const) {
1076 assert.deepStrictEqual(
1077 mergeProviderSnapshot(cachedProvider, {
1078 ...refreshedProvider,
1079 auth: { status: authStatus },
1080 }).models,
1081 refreshedProvider.models,
1082 );
1083 }
1084 });
1085
1086 it("keeps discovered models during startup and failed probes without restoring removed custom models", () => {
1087 for (const provider of [pendingProvider, failedProvider]) {
1088 assert.deepStrictEqual(
1089 mergeProviderSnapshot(
1090 {
1091 ...cachedProvider,
1092 models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }],
1093 },
1094 provider,
1095 ).models,
1096 [customModel, ...cachedProvider.models],
1097 );
1098 }
1099 });
1100
1101 it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => {
1102 const emptyProvider = { ...refreshedProvider, models: [customModel] };
1103 const clearedProviders = [
1104 { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } },
1105 { ...emptyProvider, status: "disabled", enabled: false },
1106 { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } },
1107 emptyProvider,
1108 { ...emptyProvider, models: [] },
1109 ] satisfies ReadonlyArray<ServerProvider>;
1110
1111 for (const provider of clearedProviders) {
1112 assert.deepStrictEqual(
1113 mergeProviderSnapshot(cachedProvider, provider).models,
1114 provider.models,
1115 );
1116 }
1117 });
1118
1119 it.effect("persists removals across failed refreshes and registry restarts", () =>
1120 Effect.gen(function* () {
1121 const config = yield* ServerConfig.ServerConfig;
1122 const filePath = yield* resolveProviderStatusCachePath({
1123 cacheDir: config.providerStatusCacheDir,
1124 instanceId: cachedProvider.instanceId,
1125 });
1126 yield* writeProviderStatusCache({ filePath, provider: cachedProvider });
1127 const nextProvider = yield* Ref.make<ServerProvider>(refreshedProvider);
1128 const instance = {
1129 instanceId: cachedProvider.instanceId,
1130 driverKind: cachedProvider.driver,
1131 continuationIdentity: {
1132 driverKind: cachedProvider.driver,
1133 continuationKey: "codex:instance:codex-personal",
1134 },
1135 displayName: undefined,
1136 enabled: true,
1137 snapshot: {
1138 resolveMaintenance: () =>
1139 Effect.succeed(
1140 makeManualOnlyProviderMaintenanceCapabilities({
1141 provider: cachedProvider.driver,
1142 packageName: null,
1143 }),
1144 ),
1145 getSnapshot: Effect.succeed(pendingProvider),
1146 refresh: Ref.get(nextProvider),
1147 streamChanges: Stream.empty,
1148 applyUsageLimits: () => Effect.void,
1149 },
1150 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1151 textGeneration: {} as ProviderInstance["textGeneration"],
1152 } satisfies ProviderInstance;
1153 const layerInstanceRegistry = Layer.succeed(
1154 ProviderInstanceRegistry.ProviderInstanceRegistry,
1155 {
1156 getInstance: (id) =>
1157 Effect.succeed(id === instance.instanceId ? instance : undefined),
1158 listInstances: Effect.succeed([instance]),
1159 listUnavailable: Effect.succeed([]),
1160 streamChanges: Stream.empty,
1161 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1162 },
1163 );
1164 const retainedModels = [
1165 customModel,
1166 ...refreshedProvider.models.filter((model) => !model.isCustom),
1167 ];
1168
1169 for (const restarted of [false, true]) {
1170 yield* Effect.gen(function* () {
1171 const registry = yield* ProviderRegistry.ProviderRegistry;
1172 const expectedModels = restarted
1173 ? retainedModels
1174 : [customModel, ...cachedProvider.models];
1175 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels);
1176
1177 yield* registry.refreshInstance(instance.instanceId);
1178 assert.deepStrictEqual(
1179 (yield* readProviderStatusCache(filePath))?.models,
1180 restarted ? retainedModels : refreshedProvider.models,
1181 );
1182
1183 yield* Ref.set(nextProvider, failedProvider);
1184 const afterFailure = yield* registry.refreshInstance(instance.instanceId);
1185 assert.deepStrictEqual(afterFailure[0]?.models, retainedModels);
1186 assert.deepStrictEqual(
1187 (yield* readProviderStatusCache(filePath))?.models,
1188 retainedModels,
1189 );
1190 }).pipe(
1191 Effect.provide(
1192 ProviderRegistryLayer.layer.pipe(Layer.provide(layerInstanceRegistry)),
1193 ),
1194 Effect.scoped,
1195 );
1196 }
1197 }).pipe(
1198 Effect.provide(
1199 ServerConfig.layerTest(process.cwd(), {
1200 prefix: "t3-codex-retired-model-cache-",
1201 }).pipe(Layer.provideMerge(NodeServices.layer)),
1202 ),
1203 ),
1204 );
1205 });
1206
1207 describe("Antigravity model inventories", () => {
1208 const previousProvider = {
1209 instanceId: ProviderInstanceId.make("antigravity-personal"),
1210 driver: ProviderDriverKind.make("antigravity"),
1211 status: "ready",
1212 enabled: true,
1213 installed: true,
1214 auth: { status: "authenticated" },
1215 checkedAt: "2026-09-02T00:00:00.000Z",
1216 version: "0.1.3",
1217 models: [
1218 {
1219 slug: "gemini-3.1-pro-high",
1220 name: "Gemini 3.1 Pro High",
1221 isCustom: false,
1222 capabilities: null,
1223 },
1224 {
1225 slug: "gemini-3-flash",
1226 name: "Gemini 3 Flash",
1227 isCustom: false,
1228 capabilities: null,
1229 },
1230 ],
1231 slashCommands: [],
1232 skills: [],
1233 } as const satisfies ServerProvider;
1234
1235 it("removes unavailable models after a successful refresh", () => {
1236 for (const status of ["ready", "warning"] as const) {
1237 const refreshedProvider = {
1238 ...previousProvider,
1239 status,
1240 checkedAt: "2026-09-02T00:01:00.000Z",
1241 models: [previousProvider.models[1]],
1242 } satisfies ServerProvider;
1243 const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider);
1244
1245 assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models);
1246
1247 const afterFailure = mergeProviderSnapshot(afterRefresh, {
1248 ...refreshedProvider,
1249 status: "error",
1250 auth: { status: "unknown" },
1251 models: [],
1252 });
1253 assert.deepStrictEqual(afterFailure.models, refreshedProvider.models);
1254 }
1255 });
1256
1257 it("keeps cached models during health checks and temporary failures", () => {
1258 for (const installed of [false, true]) {
1259 const pendingProvider = {
1260 ...previousProvider,
1261 status: "warning",
1262 installed,
1263 auth: { status: "unknown" },
1264 checkedAt: "2026-09-02T00:01:00.000Z",
1265 version: installed ? previousProvider.version : null,
1266 models: [],
1267 } satisfies ServerProvider;
1268
1269 assert.deepStrictEqual(
1270 mergeProviderSnapshot(previousProvider, pendingProvider).models,
1271 previousProvider.models,
1272 );
1273 }
1274
1275 for (const authStatus of ["unknown", "authenticated"] as const) {
1276 const failedProvider = {
1277 ...previousProvider,
1278 status: "error",
1279 auth: { status: authStatus },
1280 checkedAt: "2026-09-02T00:02:00.000Z",
1281 models: [],
1282 } satisfies ServerProvider;
1283
1284 assert.deepStrictEqual(
1285 mergeProviderSnapshot(previousProvider, failedProvider).models,
1286 previousProvider.models,
1287 );
1288 }
1289 });
1290
1291 it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => {
1292 const emptyProvider = {
1293 ...previousProvider,
1294 checkedAt: "2026-09-02T00:01:00.000Z",
1295 models: [],
1296 } satisfies ServerProvider;
1297 const clearedProviders = [
1298 { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } },
1299 { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } },
1300 { ...emptyProvider, status: "disabled", enabled: false },
1301 { ...emptyProvider, status: "error", enabled: false },
1302 { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } },
1303 emptyProvider,
1304 ] satisfies ReadonlyArray<ServerProvider>;
1305
1306 for (const provider of clearedProviders) {
1307 const afterRemoval = mergeProviderSnapshot(previousProvider, provider);
1308 assert.deepStrictEqual(afterRemoval.models, []);
1309
1310 const afterFailure = mergeProviderSnapshot(afterRemoval, {
1311 ...emptyProvider,
1312 status: "error",
1313 auth: { status: "unknown" },
1314 });
1315 assert.deepStrictEqual(afterFailure.models, []);
1316 }
1317 });
1318 });
1319
1320 describe("Antigravity saved account", () => {
1321 const signedIn = {
1322 instanceId: ProviderInstanceId.make("antigravity-personal"),
1323 driver: ProviderDriverKind.make("antigravity"),
1324 status: "ready",
1325 enabled: true,
1326 installed: true,
1327 auth: { status: "authenticated", type: "oauth-personal", label: "Google account" },
1328 checkedAt: "2026-09-05T00:00:00.000Z",
1329 version: "agy_acp_server_1.1.1",
1330 models: [
1331 {
1332 slug: "gemini-3.7-flash-high",
1333 name: "Gemini 3.7 Flash",
1334 isCustom: false,
1335 capabilities: null,
1336 },
1337 ],
1338 slashCommands: [{ name: "plan" }],
1339 skills: [],
1340 } as const satisfies ServerProvider;
1341 const uncheckedMessage =
1342 "Antigravity is installed. Google account access is not checked yet.";
1343 const restartProbe = {
1344 ...signedIn,
1345 status: "warning",
1346 auth: { status: "unknown" },
1347 checkedAt: "2026-09-05T00:01:00.000Z",
1348 message: uncheckedMessage,
1349 models: [],
1350 } as const satisfies ServerProvider;
1351
1352 it("keeps the saved Google account through restart health checks", () => {
1353 const merged = mergeProviderSnapshot(signedIn, restartProbe);
1354 const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe;
1355 assert.deepStrictEqual(merged, {
1356 ...probeWithoutMessage,
1357 status: "ready",
1358 auth: signedIn.auth,
1359 models: signedIn.models,
1360 });
1361 assert.equal("message" in merged, false);
1362 // The next periodic probe reads the merged snapshot as its previous state.
1363 assert.deepStrictEqual(mergeProviderSnapshot(merged, restartProbe), merged);
1364 });
1365
1366 it("carries the account through the boot probe and a failed probe without hiding them", () => {
1367 const booting = {
1368 ...restartProbe,
1369 installed: false,
1370 version: null,
1371 message: "Checking Antigravity availability.",
1372 } satisfies ServerProvider;
1373 assert.deepStrictEqual(mergeProviderSnapshot(signedIn, booting), {
1374 ...booting,
1375 auth: signedIn.auth,
1376 models: signedIn.models,
1377 });
1378
1379 const failed = {
1380 ...restartProbe,
1381 status: "error",
1382 message: "Antigravity did not respond to its local health check within 90 seconds.",
1383 } satisfies ServerProvider;
1384 assert.deepStrictEqual(mergeProviderSnapshot(signedIn, failed), {
1385 ...failed,
1386 auth: signedIn.auth,
1387 models: signedIn.models,
1388 });
1389 });
1390
1391 it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => {
1392 const untouched = [
1393 { ...restartProbe, auth: { status: "unauthenticated" } },
1394 { ...restartProbe, status: "disabled", enabled: false },
1395 { ...restartProbe, status: "error", installed: false },
1396 { ...restartProbe, driver: ProviderDriverKind.make("codex") },
1397 // The instance was rebuilt with another sign-in method.
1398 { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } },
1399 ] satisfies ReadonlyArray<ServerProvider>;
1400 for (const next of untouched) {
1401 const merged = mergeProviderSnapshot(signedIn, next);
1402 assert.deepStrictEqual(merged.auth, next.auth);
1403 assert.equal(merged.status, next.status);
1404 assert.equal(merged.message, next.message);
1405 }
1406 assert.deepStrictEqual(
1407 mergeProviderSnapshot({ ...signedIn, auth: { status: "unknown" } }, restartProbe).auth,
1408 { status: "unknown" },
1409 );
1410 assert.equal(
1411 mergeProviderSnapshot(
1412 { ...signedIn, driver: ProviderDriverKind.make("codex") },
1413 restartProbe,
1414 ).auth.status,
1415 "unknown",
1416 );
1417 assert.deepStrictEqual(
1418 mergeProviderSnapshot(signedIn, {
1419 ...restartProbe,
1420 auth: { status: "unknown", type: "oauth-personal" },
1421 }).auth,
1422 signedIn.auth,
1423 );
1424 });
1425 });
1426
1427 it("fills missing capabilities from the previous provider snapshot", () => {
1428 const previousProvider = {
1429 instanceId: ProviderInstanceId.make("cursor"),
1430 driver: ProviderDriverKind.make("cursor"),
1431 status: "ready",
1432 enabled: true,
1433 installed: true,
1434 auth: { status: "authenticated" },
1435 checkedAt: "2026-04-14T00:00:00.000Z",
1436 version: "2026.04.09-f2b0fcd",
1437 models: [
1438 {
1439 slug: "claude-opus-4-6",
1440 name: "Opus 4.6",
1441 isCustom: false,
1442 capabilities: createModelCapabilities({
1443 optionDescriptors: [
1444 selectDescriptor("reasoning", "Reasoning", [
1445 { id: "high", label: "High", isDefault: true },
1446 ]),
1447 booleanDescriptor("fastMode", "Fast Mode"),
1448 booleanDescriptor("thinking", "Thinking"),
1449 ],
1450 }),
1451 },
1452 ],
1453 slashCommands: [],
1454 skills: [],
1455 } as const satisfies ServerProvider;
1456 const refreshedProvider = {
1457 ...previousProvider,
1458 checkedAt: "2026-04-14T00:01:00.000Z",
1459 models: [
1460 {
1461 slug: "claude-opus-4-6",
1462 name: "Opus 4.6",
1463 isCustom: false,
1464 capabilities: createModelCapabilities({
1465 optionDescriptors: [],
1466 }),
1467 },
1468 ],
1469 } satisfies ServerProvider;
1470
1471 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
1472 ...previousProvider.models,
1473 ]);
1474 });
1475
1476 it.effect("does not run provider probes during layer construction", () =>
1477 Effect.gen(function* () {
1478 const codexDriver = ProviderDriverKind.make("codex");
1479 const codexInstanceId = ProviderInstanceId.make("codex");
1480 const initialProvider = {
1481 instanceId: codexInstanceId,
1482 driver: codexDriver,
1483 status: "warning",
1484 enabled: true,
1485 installed: false,
1486 auth: { status: "unknown" },
1487 checkedAt: "2026-06-10T00:00:00.000Z",
1488 version: null,
1489 message: "Checking Codex provider status.",
1490 models: [],
1491 slashCommands: [],
1492 skills: [],
1493 } as const satisfies ServerProvider;
1494 const refreshCalls = yield* Ref.make(0);
1495 const instance = {
1496 instanceId: codexInstanceId,
1497 driverKind: codexDriver,
1498 continuationIdentity: {
1499 driverKind: codexDriver,
1500 continuationKey: "codex:instance:codex",
1501 },
1502 displayName: undefined,
1503 enabled: true,
1504 snapshot: {
1505 resolveMaintenance: () =>
1506 Effect.succeed(
1507 makeManualOnlyProviderMaintenanceCapabilities({
1508 provider: codexDriver,
1509 packageName: null,
1510 }),
1511 ),
1512 getSnapshot: Effect.succeed(initialProvider),
1513 refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(
1514 Effect.andThen(Effect.never),
1515 ),
1516 streamChanges: Stream.empty,
1517 applyUsageLimits: () => Effect.void,
1518 },
1519 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1520 textGeneration: {} as ProviderInstance["textGeneration"],
1521 } satisfies ProviderInstance;
1522 const layerInstanceRegistry = Layer.succeed(
1523 ProviderInstanceRegistry.ProviderInstanceRegistry,
1524 {
1525 getInstance: (instanceId) =>
1526 Effect.succeed(instanceId === codexInstanceId ? instance : undefined),
1527 listInstances: Effect.succeed([instance]),
1528 listUnavailable: Effect.succeed([]),
1529 streamChanges: Stream.empty,
1530 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1531 },
1532 );
1533 const scope = yield* Scope.make();
1534 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1535 const runtimeServices = yield* Layer.build(
1536 ProviderRegistryLayer.layer.pipe(
1537 Layer.provideMerge(layerInstanceRegistry),
1538 Layer.provideMerge(
1539 ServerConfig.layerTest(process.cwd(), {
1540 prefix: "t3-provider-registry-background-refresh-",
1541 }),
1542 ),
1543 Layer.provideMerge(NodeServices.layer),
1544 ),
1545 ).pipe(Scope.provide(scope));
1546 yield* Effect.gen(function* () {
1547 const registry = yield* ProviderRegistry.ProviderRegistry;
1548 assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]);
1549 assert.strictEqual(yield* Ref.get(refreshCalls), 0);
1550 }).pipe(Effect.provide(runtimeServices));
1551 }),
1552 );
1553
1554 it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () =>
1555 Effect.gen(function* () {
1556 const driver = ProviderDriverKind.make("codex");
1557 const instanceId = ProviderInstanceId.make("codex");
1558 const machineProvider = {
1559 instanceId,
1560 driver,
1561 status: "ready",
1562 enabled: true,
1563 installed: true,
1564 auth: { status: "authenticated" },
1565 checkedAt: "2026-06-10T00:00:00.000Z",
1566 version: "1.0.0",
1567 models: [],
1568 slashCommands: [{ name: "global" }],
1569 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
1570 } as const satisfies ServerProvider;
1571 const scopedProvider = {
1572 ...machineProvider,
1573 checkedAt: "2026-06-10T00:01:00.000Z",
1574 slashCommands: [{ name: "project" }],
1575 skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }],
1576 } as const satisfies ServerProvider;
1577 const pendingScopedProvider = {
1578 ...scopedProvider,
1579 status: "error",
1580 installed: false,
1581 slashCommands: [],
1582 } as const satisfies ServerProvider;
1583 const snapshotCalls = yield* Ref.make(0);
1584 const scopedResult = yield* Ref.make<ProviderWorkspaceSnapshot>({
1585 ...scopedProvider,
1586 status: "error",
1587 slashCommands: [COMPACT_SLASH_COMMAND],
1588 slashCommandsPending: true,
1589 });
1590 const cacheInvalidations = yield* Ref.make(0);
1591 const scanGate = yield* Ref.make<{
1592 readonly started: Deferred.Deferred<void>;
1593 readonly release: Deferred.Deferred<void>;
1594 } | null>(null);
1595 const returnPendingSnapshot = yield* Ref.make(true);
1596 const probeStarted = yield* Deferred.make<void>();
1597 const releaseProbe = yield* Deferred.make<void>();
1598 const makeInstance = (
1599 provider: ServerProvider,
1600 snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>,
1601 ): ProviderInstance => ({
1602 instanceId,
1603 driverKind: driver,
1604 continuationIdentity: {
1605 driverKind: driver,
1606 continuationKey: "codex:instance:codex",
1607 },
1608 displayName: undefined,
1609 enabled: true,
1610 snapshot: {
1611 resolveMaintenance: () =>
1612 Effect.succeed(
1613 makeManualOnlyProviderMaintenanceCapabilities({
1614 provider: driver,
1615 packageName: null,
1616 }),
1617 ),
1618 getSnapshot: Effect.succeed(provider),
1619 refresh: Effect.succeed(provider),
1620 streamChanges: Stream.empty,
1621 applyUsageLimits: () => Effect.void,
1622 },
1623 snapshotForCwd,
1624 invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1),
1625 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1626 textGeneration: {} as ProviderInstance["textGeneration"],
1627 });
1628 const firstInstance = makeInstance(machineProvider, () =>
1629 Effect.gen(function* () {
1630 yield* Ref.update(snapshotCalls, (count) => count + 1);
1631 if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider;
1632 yield* Deferred.succeed(probeStarted, undefined);
1633 yield* Deferred.await(releaseProbe);
1634 const result = yield* Ref.get(scopedResult);
1635 const gate = yield* Ref.getAndSet(scanGate, null);
1636 if (gate) {
1637 yield* Deferred.succeed(gate.started, undefined);
1638 yield* Deferred.await(gate.release);
1639 }
1640 return result;
1641 }),
1642 );
1643 const rebuiltProvider = {
1644 ...machineProvider,
1645 checkedAt: "2026-06-10T00:02:00.000Z",
1646 status: "warning",
1647 installed: false,
1648 auth: { status: "unknown" },
1649 } satisfies ServerProvider;
1650 const rebuiltInstance = makeInstance(rebuiltProvider, () =>
1651 Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)),
1652 );
1653 const registryChanges = yield* PubSub.unbounded<void>();
1654 const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]);
1655 const layerInstanceRegistry = Layer.succeed(
1656 ProviderInstanceRegistry.ProviderInstanceRegistry,
1657 {
1658 getInstance: (requestedId) =>
1659 Ref.get(instancesRef).pipe(
1660 Effect.map((instances) =>
1661 instances.find((instance) => instance.instanceId === requestedId),
1662 ),
1663 ),
1664 listInstances: Ref.get(instancesRef),
1665 listUnavailable: Effect.succeed([]),
1666 streamChanges: Stream.fromPubSub(registryChanges),
1667 subscribeChanges: PubSub.subscribe(registryChanges),
1668 },
1669 );
1670 const scope = yield* Scope.make();
1671 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1672 const runtimeServices = yield* Layer.build(
1673 ProviderRegistryLayer.layer.pipe(
1674 Layer.provideMerge(layerInstanceRegistry),
1675 Layer.provideMerge(
1676 ServerConfig.layerTest(process.cwd(), {
1677 prefix: "t3-provider-registry-workspace-snapshot-",
1678 }),
1679 ),
1680 Layer.provideMerge(NodeServices.layer),
1681 ),
1682 ).pipe(Scope.provide(scope));
1683
1684 yield* Effect.gen(function* () {
1685 const registry = yield* ProviderRegistry.ProviderRegistry;
1686 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1687 assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined);
1688 yield* Ref.set(returnPendingSnapshot, false);
1689 const workspaceUpdate = yield* registry.streamChanges.pipe(
1690 Stream.runHead,
1691 Effect.forkChild,
1692 );
1693 yield* Effect.yieldNow;
1694 const firstRefresh = yield* registry
1695 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1696 .pipe(Effect.forkChild);
1697 yield* Deferred.await(probeStarted);
1698 const duplicateRefresh = yield* registry
1699 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1700 .pipe(Effect.forkChild);
1701 yield* Effect.yieldNow;
1702 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1703 yield* Deferred.succeed(releaseProbe, undefined);
1704 yield* Fiber.join(firstRefresh);
1705 yield* Fiber.join(duplicateRefresh);
1706 const published = yield* Fiber.join(workspaceUpdate);
1707 assert.strictEqual(published._tag, "Some");
1708 const providers = yield* registry.getProviders;
1709 assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills);
1710 assert.deepStrictEqual(
1711 providers[0]?.workspaceSnapshots?.[0]?.skills,
1712 scopedProvider.skills,
1713 );
1714 assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [
1715 COMPACT_SLASH_COMMAND,
1716 ]);
1717 assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true);
1718 yield* Ref.set(scopedResult, {
1719 ...scopedProvider,
1720 status: "error",
1721 slashCommandsPending: false,
1722 });
1723 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1724 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1725 assert.deepStrictEqual(
1726 (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands,
1727 scopedProvider.slashCommands,
1728 );
1729 assert.strictEqual(
1730 (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending,
1731 undefined,
1732 );
1733 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1734 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1735 const newSkills = [
1736 ...scopedProvider.skills,
1737 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
1738 ];
1739 yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills });
1740 yield* registry.refreshWorkspaceSnapshot({
1741 instanceId,
1742 cwd: "/workspace",
1743 fresh: true,
1744 });
1745 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);
1746 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
1747 assert.deepStrictEqual(
1748 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
1749 [newSkills],
1750 );
1751
1752 // A slow fresh scan that read older files must not overwrite a
1753 // newer scan that finished first.
1754 const slowStarted = yield* Deferred.make<void>();
1755 const releaseSlow = yield* Deferred.make<void>();
1756 yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow });
1757 yield* Ref.set(scopedResult, scopedProvider);
1758 const slowScan = yield* registry
1759 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true })
1760 .pipe(Effect.forkChild);
1761 yield* Deferred.await(slowStarted);
1762 const latestSkills = [
1763 ...newSkills,
1764 { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true },
1765 ];
1766 yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills });
1767 yield* registry.refreshWorkspaceSnapshot({
1768 instanceId,
1769 cwd: "/workspace",
1770 fresh: true,
1771 });
1772 yield* Deferred.succeed(releaseSlow, undefined);
1773 yield* Fiber.join(slowScan);
1774 assert.deepStrictEqual(
1775 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
1776 [latestSkills],
1777 );
1778
1779 yield* Ref.set(instancesRef, [rebuiltInstance]);
1780 yield* PubSub.publish(registryChanges, undefined);
1781 let rebuilt = yield* registry.getProviders;
1782 for (
1783 let attempt = 0;
1784 attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt;
1785 attempt += 1
1786 ) {
1787 yield* Effect.yieldNow;
1788 rebuilt = yield* registry.getProviders;
1789 }
1790 assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt);
1791 assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined);
1792 }).pipe(Effect.provide(runtimeServices));
1793 }),
1794 );
1795
1796 it.effect("refreshes OpenCode catalogs and preserves other providers", () =>
1797 Effect.gen(function* () {
1798 const codexDriver = ProviderDriverKind.make("codex");
1799 const openCodeDriver = ProviderDriverKind.make("opencode");
1800 const codexInstanceId = ProviderInstanceId.make("codex");
1801 const openCodeInstanceId = ProviderInstanceId.make("opencode");
1802 const codexRefreshCalls = yield* Ref.make(0);
1803 const openCodeRefreshCalls = yield* Ref.make(0);
1804 const codexProvider = {
1805 instanceId: codexInstanceId,
1806 driver: codexDriver,
1807 status: "ready",
1808 enabled: true,
1809 installed: true,
1810 auth: { status: "authenticated" },
1811 checkedAt: "2026-06-10T00:00:00.000Z",
1812 version: "1.0.0",
1813 models: [],
1814 slashCommands: [],
1815 skills: [],
1816 } as const satisfies ServerProvider;
1817 const failedOpenCodeProvider = {
1818 instanceId: openCodeInstanceId,
1819 driver: openCodeDriver,
1820 status: "error",
1821 enabled: true,
1822 installed: true,
1823 auth: { status: "unknown" },
1824 checkedAt: "2026-06-10T00:00:00.000Z",
1825 version: "1.0.0",
1826 message: "Failed to refresh OpenCode models.",
1827 models: [],
1828 slashCommands: [],
1829 skills: [],
1830 } as const satisfies ServerProvider;
1831 const recoveredOpenCodeProvider = {
1832 ...failedOpenCodeProvider,
1833 status: "ready",
1834 auth: { status: "authenticated" },
1835 checkedAt: "2026-06-10T00:01:00.000Z",
1836 message: "One upstream provider connected through OpenCode.",
1837 models: [
1838 {
1839 slug: "github/gpt-5",
1840 name: "GPT-5",
1841 subProvider: "GitHub",
1842 isCustom: false,
1843 capabilities: null,
1844 },
1845 ],
1846 } as const satisfies ServerProvider;
1847 const changedCatalogProvider = {
1848 ...recoveredOpenCodeProvider,
1849 checkedAt: "2026-06-10T00:02:00.000Z",
1850 models: [
1851 {
1852 slug: "anthropic/claude-sonnet-4",
1853 name: "Claude Sonnet 4",
1854 subProvider: "Anthropic",
1855 isCustom: false,
1856 capabilities: null,
1857 },
1858 ],
1859 } as const satisfies ServerProvider;
1860 const catalogSnapshot = yield* Ref.make<ServerProvider>(recoveredOpenCodeProvider);
1861 const instances = [
1862 {
1863 instanceId: codexInstanceId,
1864 driverKind: codexDriver,
1865 continuationIdentity: {
1866 driverKind: codexDriver,
1867 continuationKey: "codex:instance:codex",
1868 },
1869 displayName: undefined,
1870 enabled: true,
1871 snapshot: {
1872 resolveMaintenance: () =>
1873 Effect.succeed(
1874 makeManualOnlyProviderMaintenanceCapabilities({
1875 provider: codexDriver,
1876 packageName: null,
1877 }),
1878 ),
1879 getSnapshot: Effect.succeed(codexProvider),
1880 refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe(
1881 Effect.as(codexProvider),
1882 ),
1883 streamChanges: Stream.empty,
1884 applyUsageLimits: () => Effect.void,
1885 },
1886 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1887 textGeneration: {} as ProviderInstance["textGeneration"],
1888 },
1889 {
1890 instanceId: openCodeInstanceId,
1891 driverKind: openCodeDriver,
1892 continuationIdentity: {
1893 driverKind: openCodeDriver,
1894 continuationKey: "opencode:instance:opencode",
1895 },
1896 displayName: undefined,
1897 enabled: true,
1898 snapshot: {
1899 resolveMaintenance: () =>
1900 Effect.succeed(
1901 makeManualOnlyProviderMaintenanceCapabilities({
1902 provider: openCodeDriver,
1903 packageName: null,
1904 }),
1905 ),
1906 getSnapshot: Effect.succeed(failedOpenCodeProvider),
1907 refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe(
1908 Effect.andThen(Ref.get(catalogSnapshot)),
1909 ),
1910 streamChanges: Stream.empty,
1911 applyUsageLimits: () => Effect.void,
1912 },
1913 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1914 textGeneration: {} as ProviderInstance["textGeneration"],
1915 },
1916 ] satisfies ReadonlyArray<ProviderInstance>;
1917 const layerInstanceRegistry = Layer.succeed(
1918 ProviderInstanceRegistry.ProviderInstanceRegistry,
1919 {
1920 getInstance: (instanceId) =>
1921 Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)),
1922 listInstances: Effect.succeed(instances),
1923 listUnavailable: Effect.succeed([]),
1924 streamChanges: Stream.empty,
1925 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1926 },
1927 );
1928 const scope = yield* Scope.make();
1929 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1930 const runtimeServices = yield* Layer.build(
1931 ProviderRegistryLayer.layer.pipe(
1932 Layer.provideMerge(layerInstanceRegistry),
1933 Layer.provideMerge(
1934 ServerConfig.layerTest(process.cwd(), {
1935 prefix: "t3-provider-registry-reconnect-refresh-",
1936 }),
1937 ),
1938 Layer.provideMerge(NodeServices.layer),
1939 ),
1940 ).pipe(Scope.provide(scope));
1941
1942 yield* Effect.gen(function* () {
1943 const registry = yield* ProviderRegistry.ProviderRegistry;
1944 const initialProviders = yield* registry.getProviders;
1945 assert.strictEqual(
1946 initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status,
1947 "error",
1948 );
1949
1950 const recoveredProviders = yield* registry.refresh();
1951 assert.deepStrictEqual(
1952 recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId)
1953 ?.models,
1954 recoveredOpenCodeProvider.models,
1955 );
1956 assert.deepStrictEqual(
1957 recoveredProviders.find((provider) => provider.instanceId === codexInstanceId),
1958 withBundledCompatibility(codexProvider),
1959 );
1960
1961 yield* Ref.set(catalogSnapshot, changedCatalogProvider);
1962 const changedProviders = yield* registry.refresh();
1963 assert.deepStrictEqual(
1964 changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models,
1965 changedCatalogProvider.models,
1966 );
1967 assert.deepStrictEqual(
1968 changedProviders.find((provider) => provider.instanceId === codexInstanceId),
1969 withBundledCompatibility(codexProvider),
1970 );
1971 }).pipe(Effect.provide(runtimeServices));
1972
1973 assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2);
1974 assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2);
1975 }),
1976 );
1977
1978 it("persists merged provider snapshots for the providers that were refreshed", () => {
1979 const previousProviders = [
1980 {
1981 instanceId: ProviderInstanceId.make("cursor"),
1982 driver: ProviderDriverKind.make("cursor"),
1983 status: "ready",
1984 enabled: true,
1985 installed: true,
1986 auth: { status: "authenticated" },
1987 checkedAt: "2026-04-14T00:00:00.000Z",
1988 version: "2026.04.09-f2b0fcd",
1989 models: [
1990 {
1991 slug: "claude-opus-4-6",
1992 name: "Opus 4.6",
1993 isCustom: false,
1994 capabilities: createModelCapabilities({
1995 optionDescriptors: [
1996 selectDescriptor("reasoning", "Reasoning", [
1997 { id: "high", label: "High", isDefault: true },
1998 ]),
1999 booleanDescriptor("fastMode", "Fast Mode"),
2000 booleanDescriptor("thinking", "Thinking"),
2001 ],
2002 }),
2003 },
2004 ],
2005 slashCommands: [],
2006 skills: [],
2007 },
2008 {
2009 instanceId: ProviderInstanceId.make("codex"),
2010 driver: ProviderDriverKind.make("codex"),
2011 status: "ready",
2012 enabled: true,
2013 installed: true,
2014 auth: { status: "authenticated" },
2015 checkedAt: "2026-04-14T00:00:00.000Z",
2016 version: "1.0.0",
2017 models: [],
2018 slashCommands: [],
2019 skills: [],
2020 },
2021 ] as const satisfies ReadonlyArray<ServerProvider>;
2022 const refreshedCursor = {
2023 ...previousProviders[0],
2024 checkedAt: "2026-04-14T00:01:00.000Z",
2025 models: [],
2026 } satisfies ServerProvider;
2027
2028 const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]);
2029 const persistedProviders = selectProvidersByKind(
2030 mergedProviders,
2031 new Set([ProviderDriverKind.make("cursor")]),
2032 );
2033
2034 assert.deepStrictEqual(persistedProviders, [
2035 {
2036 ...refreshedCursor,
2037 models: [...previousProviders[0].models],
2038 },
2039 ]);
2040 });
2041
2042 it.effect("persists the merged snapshot when a live update has empty models", () =>
2043 Effect.gen(function* () {
2044 const cursorDriver = ProviderDriverKind.make("cursor");
2045 const cursorInstanceId = ProviderInstanceId.make("cursor");
2046 const initialProvider = {
2047 instanceId: cursorInstanceId,
2048 driver: cursorDriver,
2049 status: "ready",
2050 enabled: true,
2051 installed: true,
2052 auth: { status: "authenticated" },
2053 checkedAt: "2026-04-14T00:00:00.000Z",
2054 version: "2026.04.09-f2b0fcd",
2055 models: [
2056 {
2057 slug: "claude-opus-4-6",
2058 name: "Opus 4.6",
2059 isCustom: false,
2060 capabilities: createModelCapabilities({
2061 optionDescriptors: [
2062 selectDescriptor("reasoning", "Reasoning", [
2063 { id: "high", label: "High", isDefault: true },
2064 ]),
2065 ],
2066 }),
2067 },
2068 ],
2069 slashCommands: [],
2070 skills: [],
2071 } as const satisfies ServerProvider;
2072 const refreshedProvider = {
2073 ...initialProvider,
2074 checkedAt: "2026-04-14T00:01:00.000Z",
2075 models: [],
2076 } satisfies ServerProvider;
2077 const changes = yield* PubSub.unbounded<ServerProvider>();
2078 const instance = {
2079 instanceId: cursorInstanceId,
2080 driverKind: cursorDriver,
2081 continuationIdentity: {
2082 driverKind: cursorDriver,
2083 continuationKey: "cursor:instance:cursor",
2084 },
2085 displayName: undefined,
2086 enabled: true,
2087 snapshot: {
2088 resolveMaintenance: () =>
2089 Effect.succeed(
2090 makeManualOnlyProviderMaintenanceCapabilities({
2091 provider: cursorDriver,
2092 packageName: null,
2093 }),
2094 ),
2095 getSnapshot: Effect.succeed(initialProvider),
2096 refresh: Effect.succeed(refreshedProvider),
2097 streamChanges: Stream.fromPubSub(changes),
2098 applyUsageLimits: () => Effect.void,
2099 },
2100 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2101 textGeneration: {} as ProviderInstance["textGeneration"],
2102 } satisfies ProviderInstance;
2103 const layerInstanceRegistry = Layer.succeed(
2104 ProviderInstanceRegistry.ProviderInstanceRegistry,
2105 {
2106 getInstance: (instanceId) =>
2107 Effect.succeed(instanceId === cursorInstanceId ? instance : undefined),
2108 listInstances: Effect.succeed([instance]),
2109 listUnavailable: Effect.succeed([]),
2110 streamChanges: Stream.empty,
2111 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
2112 PubSub.subscribe(pubsub),
2113 ),
2114 },
2115 );
2116 const scope = yield* Scope.make();
2117 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2118 const runtimeServices = yield* Layer.build(
2119 ProviderRegistryLayer.layer.pipe(
2120 Layer.provideMerge(layerInstanceRegistry),
2121 Layer.provideMerge(
2122 ServerConfig.layerTest(process.cwd(), {
2123 prefix: "t3-provider-registry-merged-persist-",
2124 }),
2125 ),
2126 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2127 Layer.provideMerge(NodeServices.layer),
2128 ),
2129 ).pipe(Scope.provide(scope));
2130
2131 yield* Effect.gen(function* () {
2132 const registry = yield* ProviderRegistry.ProviderRegistry;
2133 const config = yield* ServerConfig.ServerConfig;
2134 const filePath = yield* resolveProviderStatusCachePath({
2135 cacheDir: config.providerStatusCacheDir,
2136 instanceId: cursorInstanceId,
2137 });
2138
2139 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
2140 ...initialProvider.models,
2141 ]);
2142 const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt);
2143 yield* PubSub.publish(changes, refreshedProvider);
2144 yield* Fiber.join(persisted);
2145 const cachedProvider = yield* readProviderStatusCache(filePath);
2146
2147 assert.deepStrictEqual(
2148 cachedProvider,
2149 withBundledCompatibility({
2150 ...refreshedProvider,
2151 models: [...initialProvider.models],
2152 }),
2153 );
2154 }).pipe(Effect.provide(runtimeServices));
2155 }),
2156 );
2157
2158 it.effect(
2159 "persists authoritative OpenCode removals without resurrecting them on a failed live refresh",
2160 () =>
2161 Effect.gen(function* () {
2162 const openCodeDriver = ProviderDriverKind.make("opencode");
2163 const openCodeInstanceId = ProviderInstanceId.make("opencode");
2164 const initialProvider = {
2165 instanceId: openCodeInstanceId,
2166 driver: openCodeDriver,
2167 status: "ready",
2168 enabled: true,
2169 installed: true,
2170 auth: { status: "authenticated" },
2171 checkedAt: "2026-07-17T00:00:00.000Z",
2172 version: "1.0.0",
2173 models: [
2174 {
2175 slug: "github/gpt-5",
2176 name: "GPT-5",
2177 subProvider: "GitHub",
2178 isCustom: false,
2179 capabilities: null,
2180 },
2181 {
2182 slug: "removed-plugin/model",
2183 name: "Removed Plugin Model",
2184 subProvider: "Removed Plugin",
2185 isCustom: false,
2186 capabilities: null,
2187 },
2188 ],
2189 slashCommands: [],
2190 skills: [],
2191 } as const satisfies ServerProvider;
2192 const authoritativeProvider = {
2193 ...initialProvider,
2194 checkedAt: "2026-07-17T00:01:00.000Z",
2195 models: [initialProvider.models[0]!],
2196 } satisfies ServerProvider;
2197 const failedProvider = {
2198 ...authoritativeProvider,
2199 status: "error",
2200 auth: { status: "unknown" },
2201 checkedAt: "2026-07-17T00:02:00.000Z",
2202 models: [],
2203 message: "Failed to refresh OpenCode models.",
2204 } satisfies ServerProvider;
2205 const changes = yield* PubSub.unbounded<ServerProvider>();
2206 const instance = {
2207 instanceId: openCodeInstanceId,
2208 driverKind: openCodeDriver,
2209 continuationIdentity: {
2210 driverKind: openCodeDriver,
2211 continuationKey: "opencode:instance:opencode",
2212 },
2213 displayName: undefined,
2214 enabled: true,
2215 snapshot: {
2216 resolveMaintenance: () =>
2217 Effect.succeed(
2218 makeManualOnlyProviderMaintenanceCapabilities({
2219 provider: openCodeDriver,
2220 packageName: null,
2221 }),
2222 ),
2223 getSnapshot: Effect.succeed(initialProvider),
2224 refresh: Effect.succeed(authoritativeProvider),
2225 streamChanges: Stream.fromPubSub(changes),
2226 applyUsageLimits: () => Effect.void,
2227 },
2228 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2229 textGeneration: {} as ProviderInstance["textGeneration"],
2230 } satisfies ProviderInstance;
2231 const layerInstanceRegistry = Layer.succeed(
2232 ProviderInstanceRegistry.ProviderInstanceRegistry,
2233 {
2234 getInstance: (instanceId) =>
2235 Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined),
2236 listInstances: Effect.succeed([instance]),
2237 listUnavailable: Effect.succeed([]),
2238 streamChanges: Stream.empty,
2239 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
2240 PubSub.subscribe(pubsub),
2241 ),
2242 },
2243 );
2244 const scope = yield* Scope.make();
2245 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2246 const runtimeServices = yield* Layer.build(
2247 ProviderRegistryLayer.layer.pipe(
2248 Layer.provideMerge(layerInstanceRegistry),
2249 Layer.provideMerge(
2250 ServerConfig.layerTest(process.cwd(), {
2251 prefix: "t3-provider-registry-opencode-authoritative-persist-",
2252 }),
2253 ),
2254 Layer.provideMerge(NodeServices.layer),
2255 ),
2256 ).pipe(Scope.provide(scope));
2257
2258 yield* Effect.gen(function* () {
2259 const registry = yield* ProviderRegistry.ProviderRegistry;
2260 const config = yield* ServerConfig.ServerConfig;
2261 const filePath = yield* resolveProviderStatusCachePath({
2262 cacheDir: config.providerStatusCacheDir,
2263 instanceId: openCodeInstanceId,
2264 });
2265
2266 const authoritativePersisted = yield* awaitPersistedProvider(
2267 registry,
2268 authoritativeProvider.checkedAt,
2269 );
2270 yield* PubSub.publish(changes, authoritativeProvider);
2271 yield* Fiber.join(authoritativePersisted);
2272 let cachedProvider = yield* readProviderStatusCache(filePath);
2273
2274 assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]);
2275
2276 const failedPersisted = yield* awaitPersistedProvider(
2277 registry,
2278 failedProvider.checkedAt,
2279 );
2280 yield* PubSub.publish(changes, failedProvider);
2281 yield* Fiber.join(failedPersisted);
2282 cachedProvider = yield* readProviderStatusCache(filePath);
2283
2284 assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]);
2285 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
2286 authoritativeProvider.models[0]!,
2287 ]);
2288 }).pipe(Effect.provide(runtimeServices));
2289 }),
2290 );
2291
2292 it.effect("returns the cached provider list when a manual refresh fails", () =>
2293 Effect.gen(function* () {
2294 const codexDriver = ProviderDriverKind.make("codex");
2295 const codexInstanceId = ProviderInstanceId.make("codex");
2296 const cachedProvider = {
2297 instanceId: codexInstanceId,
2298 driver: codexDriver,
2299 status: "ready",
2300 enabled: true,
2301 installed: true,
2302 auth: { status: "authenticated" },
2303 checkedAt: "2026-04-29T10:00:00.000Z",
2304 version: "1.0.0",
2305 models: [],
2306 slashCommands: [],
2307 skills: [],
2308 } as const satisfies ServerProvider;
2309 const instance = {
2310 instanceId: codexInstanceId,
2311 driverKind: codexDriver,
2312 continuationIdentity: {
2313 driverKind: codexDriver,
2314 continuationKey: "codex:instance:codex",
2315 },
2316 displayName: undefined,
2317 enabled: true,
2318 snapshot: {
2319 resolveMaintenance: () =>
2320 Effect.succeed(
2321 makeManualOnlyProviderMaintenanceCapabilities({
2322 provider: codexDriver,
2323 packageName: null,
2324 }),
2325 ),
2326 getSnapshot: Effect.succeed(cachedProvider),
2327 refresh: Effect.die(new Error("simulated refresh failure")),
2328 streamChanges: Stream.empty,
2329 applyUsageLimits: () => Effect.void,
2330 },
2331 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2332 textGeneration: {} as ProviderInstance["textGeneration"],
2333 } satisfies ProviderInstance;
2334 const layerInstanceRegistry = Layer.succeed(
2335 ProviderInstanceRegistry.ProviderInstanceRegistry,
2336 {
2337 getInstance: (instanceId) =>
2338 Effect.succeed(instanceId === codexInstanceId ? instance : undefined),
2339 listInstances: Effect.succeed([instance]),
2340 listUnavailable: Effect.succeed([]),
2341 streamChanges: Stream.empty,
2342 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
2343 PubSub.subscribe(pubsub),
2344 ),
2345 },
2346 );
2347 const scope = yield* Scope.make();
2348 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2349 const runtimeServices = yield* Layer.build(
2350 ProviderRegistryLayer.layer.pipe(
2351 Layer.provideMerge(layerInstanceRegistry),
2352 Layer.provideMerge(
2353 ServerConfig.layerTest(process.cwd(), {
2354 prefix: "t3-provider-registry-refresh-failure-",
2355 }),
2356 ),
2357 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2358 Layer.provideMerge(NodeServices.layer),
2359 ),
2360 ).pipe(Scope.provide(scope));
2361
2362 yield* Effect.gen(function* () {
2363 const registry = yield* ProviderRegistry.ProviderRegistry;
2364
2365 assert.deepStrictEqual(yield* registry.getProviders, [
2366 withBundledCompatibility(cachedProvider),
2367 ]);
2368 assert.deepStrictEqual(yield* registry.refresh(codexDriver), [
2369 withBundledCompatibility(cachedProvider),
2370 ]);
2371 assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [
2372 withBundledCompatibility(cachedProvider),
2373 ]);
2374 }).pipe(Effect.provide(runtimeServices));
2375 }),
2376 );
2377
2378 it.effect("keeps consuming registry changes after one sync fails", () =>
2379 Effect.gen(function* () {
2380 const codexDriver = ProviderDriverKind.make("codex");
2381 const codexInstanceId = ProviderInstanceId.make("codex");
2382 const claudeDriver = ProviderDriverKind.make("claudeAgent");
2383 const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
2384 const codexProvider = {
2385 instanceId: codexInstanceId,
2386 driver: codexDriver,
2387 status: "ready",
2388 enabled: true,
2389 installed: true,
2390 auth: { status: "authenticated" },
2391 checkedAt: "2026-04-29T10:00:00.000Z",
2392 version: "1.0.0",
2393 models: [],
2394 slashCommands: [],
2395 skills: [],
2396 } as const satisfies ServerProvider;
2397 const claudeProvider = {
2398 instanceId: claudeInstanceId,
2399 driver: claudeDriver,
2400 status: "ready",
2401 enabled: true,
2402 installed: true,
2403 auth: { status: "authenticated" },
2404 checkedAt: "2026-04-29T10:01:00.000Z",
2405 version: "1.0.0",
2406 models: [],
2407 slashCommands: [],
2408 skills: [],
2409 } as const satisfies ServerProvider;
2410 const makeInstance = (provider: ServerProvider): ProviderInstance => ({
2411 instanceId: provider.instanceId,
2412 driverKind: provider.driver,
2413 continuationIdentity: {
2414 driverKind: provider.driver,
2415 continuationKey: `${provider.driver}:instance:${provider.instanceId}`,
2416 },
2417 displayName: undefined,
2418 enabled: true,
2419 snapshot: {
2420 resolveMaintenance: () =>
2421 Effect.succeed(
2422 makeManualOnlyProviderMaintenanceCapabilities({
2423 provider: provider.driver,
2424 packageName: null,
2425 }),
2426 ),
2427 getSnapshot: Effect.succeed(provider),
2428 refresh: Effect.succeed(provider),
2429 streamChanges: Stream.empty,
2430 applyUsageLimits: () => Effect.void,
2431 },
2432 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2433 textGeneration: {} as ProviderInstance["textGeneration"],
2434 });
2435 const codexInstance = makeInstance(codexProvider);
2436 const claudeInstance = makeInstance(claudeProvider);
2437 const changes = yield* PubSub.unbounded<void>();
2438 const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([codexInstance]);
2439 const failNextList = yield* Ref.make(false);
2440 const wait = () => Effect.yieldNow;
2441 const layerInstanceRegistry = Layer.succeed(
2442 ProviderInstanceRegistry.ProviderInstanceRegistry,
2443 {
2444 getInstance: (instanceId) =>
2445 Ref.get(instancesRef).pipe(
2446 Effect.map((instances) =>
2447 instances.find((instance) => instance.instanceId === instanceId),
2448 ),
2449 ),
2450 listInstances: Effect.gen(function* () {
2451 const shouldFail = yield* Ref.get(failNextList);
2452 if (shouldFail) {
2453 yield* Ref.set(failNextList, false);
2454 return yield* Effect.die(new Error("simulated registry list failure"));
2455 }
2456 return yield* Ref.get(instancesRef);
2457 }),
2458 listUnavailable: Effect.succeed([]),
2459 streamChanges: Stream.fromPubSub(changes),
2460 subscribeChanges: PubSub.subscribe(changes),
2461 },
2462 );
2463 const scope = yield* Scope.make();
2464 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2465 const runtimeServices = yield* Layer.build(
2466 ProviderRegistryLayer.layer.pipe(
2467 Layer.provideMerge(layerInstanceRegistry),
2468 Layer.provideMerge(
2469 ServerConfig.layerTest(process.cwd(), {
2470 prefix: "t3-provider-registry-sync-failure-",
2471 }),
2472 ),
2473 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2474 Layer.provideMerge(NodeServices.layer),
2475 ),
2476 ).pipe(Scope.provide(scope));
2477
2478 yield* Effect.gen(function* () {
2479 const registry = yield* ProviderRegistry.ProviderRegistry;
2480 assert.deepStrictEqual(yield* registry.getProviders, [
2481 withBundledCompatibility(codexProvider),
2482 ]);
2483
2484 yield* Ref.set(failNextList, true);
2485 yield* PubSub.publish(changes, undefined);
2486
2487 yield* Ref.set(instancesRef, [codexInstance, claudeInstance]);
2488 yield* PubSub.publish(changes, undefined);
2489
2490 let providers = yield* registry.getProviders;
2491 for (
2492 let attempt = 0;
2493 attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId);
2494 attempt += 1
2495 ) {
2496 yield* wait();
2497 providers = yield* registry.getProviders;
2498 }
2499
2500 assert.deepStrictEqual(
2501 providers.map((provider) => provider.instanceId).toSorted(),
2502 [codexInstanceId, claudeInstanceId].toSorted(),
2503 );
2504 }).pipe(Effect.provide(runtimeServices));
2505 }),
2506 );
2507
2508 // This test intentionally avoids `mockCommandSpawnerLayer` so the real
2509 // `probeCodexAppServerProvider` path runs — including the full
2510 // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`.
2511 // We point `binaryPath` at a name that cannot exist on any machine so
2512 // the real `ChildProcessSpawner` deterministically returns ENOENT; the
2513 // probe wraps that as `CodexAppServerSpawnError` and
2514 // `checkCodexProviderStatus` turns it into the user-visible "not
2515 // installed" error snapshot. If the aggregator's `syncLiveSources`
2516 // breaks — the `codex_personal`-never-probes bug we are guarding
2517 // against — that snapshot never lands in `getProviders` and the
2518 // assertions below fail.
2519 it.effect("propagates real Codex probe failures to the aggregator at boot", () =>
2520 Effect.gen(function* () {
2521 const missingBinary = `t3code_codex_missing_`;
2522 const serverSettings = yield* makeMutableServerSettingsService(
2523 decodeServerSettings(
2524 deepMerge(encodedDefaultServerSettings, {
2525 providers: {
2526 // Disable every built-in probe that would otherwise spawn
2527 // on the CI host. `enabled: false` short-circuits each
2528 // driver's probe *before* it touches the spawner, so the
2529 // test environment stays isolated from the dev
2530 // machine's PATH.
2531 codex: { enabled: false },
2532 claudeAgent: { enabled: false },
2533 cursor: { enabled: false },
2534 grok: { enabled: false },
2535 opencode: { enabled: false },
2536 },
2537 // `providerInstances` keys are branded `ProviderInstanceId`;
2538 // the branded index signature rejects plain string literals
2539 // at the TS level even though the runtime schema happily
2540 // accepts + decodes them. Cast the patch to `unknown` so
2541 // the `Schema.decodeSync` below does the real validation.
2542 providerInstances: {
2543 // Matches the shape the user had in `.t3/dev/settings.json`
2544 // when the bug was reported: a custom enabled Codex instance
2545 // pointing at a binary the server has to actually spawn.
2546 codex_personal: {
2547 driver: "codex",
2548 displayName: "Codex Personal",
2549 enabled: true,
2550 config: {
2551 binaryPath: missingBinary,
2552 homePath: `/tmp/${missingBinary}_home`,
2553 },
2554 },
2555 } as unknown as ContractServerSettings["providerInstances"],
2556 }),
2557 ),
2558 );
2559 const scope = yield* Scope.make();
2560 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2561 const layerProviderRegistry = ProviderRegistryLayer.layer.pipe(
2562 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2563 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2564 Layer.provideMerge(
2565 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2566 ),
2567 Layer.provideMerge(ServerSecretStore.layer),
2568 Layer.provideMerge(
2569 ServerConfig.layerTest(process.cwd(), {
2570 prefix: "t3-provider-registry-",
2571 }),
2572 ),
2573 Layer.provideMerge(layerTestHttpClient),
2574 Layer.provideMerge(
2575 Layer.succeed(
2576 ProviderEventLoggers.ProviderEventLoggers,
2577 ProviderEventLoggers.NoOpProviderEventLoggers,
2578 ),
2579 ),
2580 Layer.provideMerge(ModelManifest.layerTest),
2581 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2582 Layer.provideMerge(
2583 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2584 ),
2585 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2586 // NO spawner mock — `ChildProcessSpawner` is supplied by the
2587 // outer `NodeServices.layer` on `it.layer(...)` and will
2588 // genuinely spawn a subprocess. The missing-binary ENOENT is
2589 // what exercises the same failure mode as a misconfigured
2590 // production `binaryPath`.
2591 );
2592 const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe(
2593 Scope.provide(scope),
2594 );
2595
2596 yield* Effect.gen(function* () {
2597 const registry = yield* ProviderRegistry.ProviderRegistry;
2598 let providers = yield* registry.getProviders;
2599 for (
2600 let attempts = 0;
2601 attempts < 50 &&
2602 providers.find((provider) => provider.instanceId === "codex_personal")?.status !==
2603 "error";
2604 attempts += 1
2605 ) {
2606 yield* Effect.yieldNow;
2607 providers = yield* registry.getProviders;
2608 }
2609 const codexPersonal = providers.find(
2610 (provider) => provider.instanceId === "codex_personal",
2611 );
2612 assert.notStrictEqual(
2613 codexPersonal,
2614 undefined,
2615 `Expected the aggregator to know about codex_personal; instead saw: ${providers
2616 .map((provider) => provider.instanceId)
2617 .join(", ")}`,
2618 );
2619 assert.strictEqual(
2620 codexPersonal?.status,
2621 "error",
2622 "Real Codex probe against a missing binary should surface as 'error' in the aggregator",
2623 );
2624 assert.strictEqual(codexPersonal?.installed, false);
2625 assert.include(codexPersonal?.message, missingBinary);
2626 assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path");
2627 }).pipe(Effect.provide(runtimeServices));
2628 }),
2629 );
2630
2631 // A binary path change must rebuild Codex and publish its new probe result.
2632 it.effect("re-probes when settings change the codex binaryPath", () =>
2633 Effect.gen(function* () {
2634 const firstMissing = `t3code_codex_first_`;
2635 const secondMissing = `t3code_codex_second_`;
2636 const spawnedCommands: Array<string> = [];
2637 const secondProbeStarted = yield* Deferred.make<void>();
2638 const releaseSecondProbe = yield* Deferred.make<void>();
2639 const allowLazySettingsStream = yield* Deferred.make<void>();
2640 const mutableServerSettings = yield* makeMutableServerSettingsService(
2641 decodeServerSettings(
2642 deepMerge(encodedDefaultServerSettings, {
2643 providers: {
2644 codex: { enabled: true, binaryPath: firstMissing },
2645 claudeAgent: { enabled: false },
2646 cursor: { enabled: false },
2647 grok: { enabled: false },
2648 opencode: { enabled: false },
2649 },
2650 }),
2651 ),
2652 );
2653 const serverSettings = {
2654 ...mutableServerSettings,
2655 streamChanges: Stream.unwrap(
2656 Deferred.await(allowLazySettingsStream).pipe(
2657 Effect.as(mutableServerSettings.streamChanges),
2658 ),
2659 ),
2660 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
2661 const scope = yield* Scope.make();
2662 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2663 const layerProviderRegistry = ProviderRegistryLayer.layer.pipe(
2664 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2665 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2666 Layer.provideMerge(
2667 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2668 ),
2669 Layer.provideMerge(ServerSecretStore.layer),
2670 Layer.provideMerge(
2671 ServerConfig.layerTest(process.cwd(), {
2672 prefix: "t3-provider-registry-",
2673 }),
2674 ),
2675 Layer.provideMerge(layerTestHttpClient),
2676 Layer.provideMerge(
2677 Layer.succeed(
2678 ProviderEventLoggers.ProviderEventLoggers,
2679 ProviderEventLoggers.NoOpProviderEventLoggers,
2680 ),
2681 ),
2682 Layer.provideMerge(ModelManifest.layerTest),
2683 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2684 Layer.provideMerge(
2685 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2686 ),
2687 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
2688 ChildProcessSpawner.make((command) => {
2689 if (command._tag !== "StandardCommand") return spawner.spawn(command);
2690 spawnedCommands.push(command.command);
2691 const beforeSpawn =
2692 command.command === secondMissing
2693 ? Deferred.succeed(secondProbeStarted, undefined).pipe(
2694 Effect.andThen(Deferred.await(releaseSecondProbe)),
2695 )
2696 : Effect.void;
2697 return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command)));
2698 }),
2699 ),
2700 Layer.provideMerge(NodeServices.layer),
2701 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2702 );
2703 const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe(
2704 Scope.provide(scope),
2705 );
2706
2707 yield* Effect.gen(function* () {
2708 const registry = yield* ProviderRegistry.ProviderRegistry;
2709 const codexSnapshots = registry.streamChanges.pipe(
2710 Stream.map((providers) =>
2711 providers.find((provider) => provider.instanceId === "codex"),
2712 ),
2713 Stream.filter((provider): provider is ServerProvider => provider !== undefined),
2714 );
2715 const firstError = yield* Stream.toPull(
2716 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2717 );
2718 const currentCodex = (yield* registry.getProviders).find(
2719 (provider) => provider.instanceId === "codex",
2720 );
2721 const initialCodex =
2722 currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0];
2723 assert.strictEqual(initialCodex?.status, "error");
2724 assert.strictEqual(initialCodex?.installed, false);
2725 assert.deepStrictEqual(spawnedCommands, [firstMissing]);
2726
2727 const pendingRebuild = yield* Stream.toPull(
2728 codexSnapshots.pipe(
2729 Stream.filter((provider) => provider.status === "warning" && !provider.installed),
2730 ),
2731 );
2732 yield* serverSettings.updateSettings({
2733 providers: {
2734 codex: { enabled: true, binaryPath: secondMissing },
2735 },
2736 });
2737 // Start the lazy stream only after publishing. A watcher that did
2738 // not subscribe before forking has already lost this update.
2739 yield* Deferred.succeed(allowLazySettingsStream, undefined);
2740
2741 // Hold the second probe until the aggregator sees the rebuilt
2742 // instance. Its next error must come from the new executable.
2743 yield* Deferred.await(secondProbeStarted);
2744 yield* pendingRebuild;
2745 const rebuiltError = yield* Stream.toPull(
2746 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2747 );
2748 yield* Deferred.succeed(releaseSecondProbe, undefined);
2749 const [reprobedCodex] = yield* rebuiltError;
2750 assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);
2751 assert.strictEqual(reprobedCodex?.status, "error");
2752 assert.strictEqual(reprobedCodex?.installed, false);
2753 }).pipe(Effect.provide(runtimeServices));
2754 }),
2755 );
2756
2757 it.effect("includes unavailable instance snapshots in getProviders", () =>
2758 Effect.gen(function* () {
2759 const serverSettings = yield* makeMutableServerSettingsService(
2760 decodeServerSettings(
2761 deepMerge(encodedDefaultServerSettings, {
2762 providers: {
2763 codex: { enabled: false },
2764 claudeAgent: { enabled: false },
2765 cursor: { enabled: false },
2766 grok: { enabled: false },
2767 opencode: { enabled: false },
2768 },
2769 providerInstances: {
2770 ghost_main: {
2771 driver: "ghostDriver",
2772 displayName: "A fork-only driver we don't ship",
2773 enabled: false,
2774 config: { arbitrary: "payload" },
2775 },
2776 } as unknown as ContractServerSettings["providerInstances"],
2777 }),
2778 ),
2779 );
2780 const scope = yield* Scope.make();
2781 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2782 const layerProviderRegistry = ProviderRegistryLayer.layer.pipe(
2783 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2784 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2785 Layer.provideMerge(
2786 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2787 ),
2788 Layer.provideMerge(ServerSecretStore.layer),
2789 Layer.provideMerge(
2790 ServerConfig.layerTest(process.cwd(), {
2791 prefix: "t3-provider-registry-",
2792 }),
2793 ),
2794 Layer.provideMerge(layerTestHttpClient),
2795 Layer.provideMerge(
2796 Layer.succeed(
2797 ProviderEventLoggers.ProviderEventLoggers,
2798 ProviderEventLoggers.NoOpProviderEventLoggers,
2799 ),
2800 ),
2801 Layer.provideMerge(ModelManifest.layerTest),
2802 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2803 Layer.provideMerge(
2804 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2805 ),
2806 Layer.provideMerge(NodeServices.layer),
2807 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2808 );
2809 const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe(
2810 Scope.provide(scope),
2811 );
2812
2813 yield* Effect.gen(function* () {
2814 const registry = yield* ProviderRegistry.ProviderRegistry;
2815 const providers = yield* registry.getProviders;
2816 const ghost = providers.find((provider) => provider.instanceId === "ghost_main");
2817
2818 assert.notStrictEqual(ghost, undefined);
2819 assert.strictEqual(ghost?.driver, "ghostDriver");
2820 assert.strictEqual(ghost?.availability, "unavailable");
2821 assert.match(ghost?.unavailableReason ?? "", /ghostDriver/);
2822 }).pipe(Effect.provide(runtimeServices));
2823 }),
2824 );
2825
2826 it.effect(
2827 "keeps Cursor disabled and skips provider probing when settings use their defaults",
2828 () =>
2829 Effect.gen(function* () {
2830 const serverSettings = yield* makeMutableServerSettingsService(
2831 decodeServerSettings(
2832 deepMerge(encodedDefaultServerSettings, {
2833 providers: {
2834 codex: {
2835 enabled: false,
2836 },
2837 grok: {
2838 enabled: false,
2839 },
2840 },
2841 }),
2842 ),
2843 );
2844 let cursorSpawned = false;
2845 const scope = yield* Scope.make();
2846 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2847 const layerProviderRegistry = ProviderRegistryLayer.layer.pipe(
2848 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2849 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2850 Layer.provideMerge(
2851 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2852 ),
2853 Layer.provideMerge(ServerSecretStore.layer),
2854 Layer.provideMerge(
2855 ServerConfig.layerTest(process.cwd(), {
2856 prefix: "t3-provider-registry-",
2857 }),
2858 ),
2859 Layer.provideMerge(layerTestHttpClient),
2860 Layer.provideMerge(
2861 Layer.succeed(
2862 ProviderEventLoggers.ProviderEventLoggers,
2863 ProviderEventLoggers.NoOpProviderEventLoggers,
2864 ),
2865 ),
2866 Layer.provideMerge(ModelManifest.layerTest),
2867 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2868 Layer.provideMerge(
2869 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2870 ),
2871 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2872 Layer.provideMerge(
2873 layerMockCommandSpawner((command, args) => {
2874 if (command === "cursor-agent") {
2875 cursorSpawned = true;
2876 }
2877 const joined = args.join(" ");
2878 if (joined === "--version") {
2879 return {
2880 stdout: `${command} 1.0.0\n`,
2881 stderr: "",
2882 code: 0,
2883 };
2884 }
2885 if (joined === "auth status") {
2886 return {
2887 stdout: '{"authenticated":true}\n',
2888 stderr: "",
2889 code: 0,
2890 };
2891 }
2892 throw new Error(`Unexpected args: ${command} ${joined}`);
2893 }),
2894 ),
2895 );
2896 const runtimeServices = yield* Layer.build(
2897 Layer.mergeAll(
2898 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2899 layerProviderRegistry,
2900 ),
2901 ).pipe(Scope.provide(scope));
2902
2903 yield* Effect.gen(function* () {
2904 const registry = yield* ProviderRegistry.ProviderRegistry;
2905 const providers = yield* registry.getProviders;
2906 const cursorProvider = providers.find(
2907 (provider) => provider.instanceId === ProviderInstanceId.make("cursor"),
2908 );
2909
2910 assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [
2911 "antigravity",
2912 "claudeAgent",
2913 "codex",
2914 "cursor",
2915 "grok",
2916 "opencode",
2917 "pi",
2918 ]);
2919 assert.strictEqual(cursorProvider?.enabled, false);
2920 assert.strictEqual(cursorProvider?.status, "disabled");
2921 assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings.");
2922 assert.strictEqual(cursorSpawned, false);
2923 }).pipe(Effect.provide(runtimeServices));
2924 }),
2925 );
2926
2927 it.effect("skips codex probes entirely when the provider is disabled", () =>
2928 Effect.gen(function* () {
2929 const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe(
2930 Effect.provide(layerFailingSpawner("spawn codex ENOENT")),
2931 );
2932 assert.strictEqual(status.enabled, false);
2933 assert.strictEqual(status.status, "disabled");
2934 assert.strictEqual(status.installed, false);
2935 assert.strictEqual(status.message, "Codex is disabled in T3 Code settings.");
2936 }),
2937 );
2938 });
2939
2940 // ── checkClaudeProviderStatus tests ──────────────────────────
2941
2942 describe("checkClaudeProviderStatus", () => {
2943 it.effect("returns ready when claude is installed and authenticated", () =>
2944 Effect.gen(function* () {
2945 const status = yield* checkClaudeProviderStatus(
2946 defaultClaudeSettings,
2947 claudeCapabilities(),
2948 );
2949 assert.strictEqual(status.status, "ready");
2950 assert.strictEqual(status.installed, true);
2951 assert.strictEqual(status.auth.status, "authenticated");
2952 }).pipe(
2953 Effect.provide(
2954 layerMockSpawner((args) => {
2955 const joined = args.join(" ");
2956 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
2957 if (joined === "auth status")
2958 return {
2959 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2960 stderr: "",
2961 code: 0,
2962 };
2963 throw new Error(`Unexpected args: ${joined}`);
2964 }),
2965 ),
2966 ),
2967 );
2968
2969 it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () =>
2970 Effect.gen(function* () {
2971 // Bedrock authenticates via external AWS credentials, so the SDK init
2972 // reports only `apiProvider` with no subscription or token.
2973 const status = yield* checkClaudeProviderStatus(
2974 defaultClaudeSettings,
2975 claudeCapabilities({ apiProvider: "bedrock" }),
2976 );
2977 assert.strictEqual(status.status, "ready");
2978 assert.strictEqual(status.installed, true);
2979 assert.strictEqual(status.auth.status, "authenticated");
2980 assert.strictEqual(status.auth.type, "bedrock");
2981 assert.strictEqual(status.auth.label, "Amazon Bedrock");
2982 }).pipe(
2983 Effect.provide(
2984 layerMockSpawner((args) => {
2985 const joined = args.join(" ");
2986 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
2987 throw new Error(`Unexpected args: ${joined}`);
2988 }),
2989 ),
2990 ),
2991 );
2992
2993 it.effect("returns a display label for claude subscription types", () =>
2994 Effect.gen(function* () {
2995 const status = yield* checkClaudeProviderStatus(
2996 defaultClaudeSettings,
2997 claudeCapabilities({ subscriptionType: "maxplan" }),
2998 );
2999 assert.strictEqual(status.status, "ready");
3000 assert.strictEqual(status.auth.status, "authenticated");
3001 assert.strictEqual(status.auth.type, "maxplan");
3002 assert.strictEqual(status.auth.label, "Claude Max Subscription");
3003 }).pipe(
3004 Effect.provide(
3005 layerMockSpawner((args) => {
3006 const joined = args.join(" ");
3007 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3008 if (joined === "auth status")
3009 return {
3010 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3011 stderr: "",
3012 code: 0,
3013 };
3014 throw new Error(`Unexpected args: ${joined}`);
3015 }),
3016 ),
3017 ),
3018 );
3019
3020 it.effect("reads banked resets only for subscription logins", () =>
3021 Effect.gen(function* () {
3022 const check = (overrides: Partial<TestClaudeCapabilities>) =>
3023 checkClaudeProviderStatus(
3024 defaultClaudeSettings,
3025 () =>
3026 Effect.succeed({
3027 email: undefined,
3028 subscriptionType: undefined,
3029 tokenSource: undefined,
3030 apiProvider: undefined,
3031 slashCommands: [],
3032 usage: { rate_limits_available: true, rate_limits: {} },
3033 ...overrides,
3034 }),
3035 undefined,
3036 undefined,
3037 undefined,
3038 undefined,
3039 () => Effect.succeed({ availableCount: 2 }),
3040 );
3041 const subscription = yield* check({ subscriptionType: "max" });
3042 const bedrock = yield* check({ apiProvider: "bedrock" });
3043 assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 });
3044 assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined);
3045 }).pipe(
3046 Effect.provide(
3047 layerMockSpawner((args) => {
3048 const joined = args.join(" ");
3049 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3050 throw new Error(`Unexpected args: ${joined}`);
3051 }),
3052 ),
3053 ),
3054 );
3055
3056 it.effect("does not duplicate Claude in full subscription labels", () =>
3057 Effect.gen(function* () {
3058 const status = yield* checkClaudeProviderStatus(
3059 defaultClaudeSettings,
3060 claudeCapabilities({
3061 subscriptionType: "Claude Max Subscription",
3062 }),
3063 );
3064 assert.strictEqual(status.auth.status, "authenticated");
3065 assert.strictEqual(status.auth.type, "Claude Max Subscription");
3066 assert.strictEqual(status.auth.label, "Claude Max Subscription");
3067 }).pipe(
3068 Effect.provide(
3069 layerMockSpawner((args) => {
3070 const joined = args.join(" ");
3071 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3072 throw new Error(`Unexpected args: ${joined}`);
3073 }),
3074 ),
3075 ),
3076 );
3077
3078 it.effect("does not duplicate Claude in provider-prefixed subscription names", () =>
3079 Effect.gen(function* () {
3080 const status = yield* checkClaudeProviderStatus(
3081 defaultClaudeSettings,
3082 claudeCapabilities({
3083 subscriptionType: "Claude Max",
3084 }),
3085 );
3086 assert.strictEqual(status.auth.status, "authenticated");
3087 assert.strictEqual(status.auth.type, "Claude Max");
3088 assert.strictEqual(status.auth.label, "Claude Max Subscription");
3089 }).pipe(
3090 Effect.provide(
3091 layerMockSpawner((args) => {
3092 const joined = args.join(" ");
3093 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3094 throw new Error(`Unexpected args: ${joined}`);
3095 }),
3096 ),
3097 ),
3098 );
3099
3100 it.effect("returns claude auth email from initialization result", () =>
3101 Effect.gen(function* () {
3102 const status = yield* checkClaudeProviderStatus(
3103 defaultClaudeSettings,
3104 claudeCapabilities({ email: "claude@example.com" }),
3105 );
3106 assert.strictEqual(status.auth.status, "authenticated");
3107 assert.strictEqual(status.auth.email, "claude@example.com");
3108 }).pipe(
3109 Effect.provide(
3110 layerMockSpawner((args) => {
3111 const joined = args.join(" ");
3112 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3113 if (joined === "auth status")
3114 return {
3115 stdout:
3116 '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n',
3117 stderr: "",
3118 code: 0,
3119 };
3120 throw new Error(`Unexpected args: ${joined}`);
3121 }),
3122 ),
3123 ),
3124 );
3125
3126 it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => {
3127 const claudeConfigDir = "/tmp/t3code-claude-home";
3128 const recorded = recordingMockSpawnerLayer((args) => {
3129 const joined = args.join(" ");
3130 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3131 if (joined === "auth status")
3132 return {
3133 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3134 stderr: "",
3135 code: 0,
3136 };
3137 throw new Error(`Unexpected args: ${joined}`);
3138 });
3139
3140 return Effect.gen(function* () {
3141 const status = yield* checkClaudeProviderStatus(
3142 {
3143 ...defaultClaudeSettings,
3144 homePath: claudeConfigDir,
3145 },
3146 claudeCapabilities(),
3147 );
3148 assert.strictEqual(status.status, "ready");
3149 // The home is resolved through the host Path before it reaches the env.
3150 assert.deepStrictEqual(
3151 recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR),
3152 [(yield* Path.Path).resolve(claudeConfigDir)],
3153 );
3154 }).pipe(Effect.provide(recorded.layer));
3155 });
3156
3157 it.effect("includes probed claude slash commands in the provider snapshot", () =>
3158 Effect.gen(function* () {
3159 const status = yield* checkClaudeProviderStatus(
3160 defaultClaudeSettings,
3161 claudeCapabilities({
3162 subscriptionType: "maxplan",
3163 slashCommands: [
3164 {
3165 name: "review",
3166 description: "Review a pull request",
3167 input: { hint: "pr-or-branch" },
3168 },
3169 ],
3170 }),
3171 );
3172
3173 assert.deepStrictEqual(status.slashCommands.slice(1), [
3174 {
3175 name: "review",
3176 description: "Review a pull request",
3177 input: { hint: "pr-or-branch" },
3178 },
3179 ]);
3180 }).pipe(
3181 Effect.provide(
3182 layerMockSpawner((args) => {
3183 const joined = args.join(" ");
3184 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3185 if (joined === "auth status")
3186 return {
3187 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3188 stderr: "",
3189 code: 0,
3190 };
3191 throw new Error(`Unexpected args: ${joined}`);
3192 }),
3193 ),
3194 ),
3195 );
3196
3197 it.effect("deduplicates probed claude slash commands by name", () =>
3198 Effect.gen(function* () {
3199 const status = yield* checkClaudeProviderStatus(
3200 defaultClaudeSettings,
3201 claudeCapabilities({
3202 subscriptionType: "maxplan",
3203 slashCommands: [
3204 {
3205 name: "ui",
3206 description: "Explore and refine UI",
3207 },
3208 {
3209 name: "ui",
3210 input: { hint: "component-or-screen" },
3211 },
3212 ],
3213 }),
3214 );
3215
3216 assert.deepStrictEqual(status.slashCommands, [
3217 COMPACT_SLASH_COMMAND,
3218 {
3219 name: "ui",
3220 description: "Explore and refine UI",
3221 input: { hint: "component-or-screen" },
3222 },
3223 ]);
3224 }).pipe(
3225 Effect.provide(
3226 layerMockSpawner((args) => {
3227 const joined = args.join(" ");
3228 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3229 if (joined === "auth status")
3230 return {
3231 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3232 stderr: "",
3233 code: 0,
3234 };
3235 throw new Error(`Unexpected args: ${joined}`);
3236 }),
3237 ),
3238 ),
3239 );
3240
3241 it.effect("returns an api key label for claude api key auth", () =>
3242 Effect.gen(function* () {
3243 const status = yield* checkClaudeProviderStatus(
3244 defaultClaudeSettings,
3245 claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }),
3246 );
3247 assert.strictEqual(status.status, "ready");
3248 assert.strictEqual(status.auth.status, "authenticated");
3249 assert.strictEqual(status.auth.type, "apiKey");
3250 assert.strictEqual(status.auth.label, "Claude API Key");
3251 }).pipe(
3252 Effect.provide(
3253 layerMockSpawner((args) => {
3254 const joined = args.join(" ");
3255 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3256 if (joined === "auth status")
3257 return {
3258 stdout: '{"loggedIn":true,"authMethod":"api-key"}\n',
3259 stderr: "",
3260 code: 0,
3261 };
3262 throw new Error(`Unexpected args: ${joined}`);
3263 }),
3264 ),
3265 ),
3266 );
3267
3268 it.effect("returns unavailable when claude is missing", () =>
3269 Effect.gen(function* () {
3270 const status = yield* checkClaudeProviderStatus(
3271 defaultClaudeSettings,
3272 claudeCapabilities(),
3273 );
3274 assert.strictEqual(status.status, "error");
3275 assert.strictEqual(status.installed, false);
3276 assert.strictEqual(status.auth.status, "unknown");
3277 assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH.");
3278 }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))),
3279 );
3280
3281 it.effect("returns error when version check fails with non-zero exit code", () => {
3282 const secretStderr = "Something went wrong: secret-token-value";
3283 return Effect.gen(function* () {
3284 const status = yield* checkClaudeProviderStatus(
3285 defaultClaudeSettings,
3286 claudeCapabilities(),
3287 );
3288 assert.strictEqual(status.status, "error");
3289 assert.strictEqual(status.installed, true);
3290 assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run.");
3291 assert.ok(!(status.message ?? "").includes(secretStderr));
3292 }).pipe(
3293 Effect.provide(
3294 layerMockSpawner((args) => {
3295 const joined = args.join(" ");
3296 if (joined === "--version")
3297 return {
3298 stdout: "",
3299 stderr: secretStderr,
3300 code: 1,
3301 };
3302 throw new Error(`Unexpected args: ${joined}`);
3303 }),
3304 ),
3305 );
3306 });
3307
3308 it.effect("returns warning when the Claude initialization result is unavailable", () =>
3309 Effect.gen(function* () {
3310 const status = yield* checkClaudeProviderStatus(
3311 defaultClaudeSettings,
3312 noClaudeCapabilities,
3313 );
3314 assert.strictEqual(status.status, "warning");
3315 assert.strictEqual(status.installed, true);
3316 assert.strictEqual(status.auth.status, "unknown");
3317 assert.strictEqual(
3318 status.message,
3319 "Could not verify Claude authentication status from initialization result.",
3320 );
3321 }).pipe(
3322 Effect.provide(
3323 layerMockSpawner((args) => {
3324 const joined = args.join(" ");
3325 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3326 if (joined === "auth status")
3327 return {
3328 stdout: '{"loggedIn":false}\n',
3329 stderr: "",
3330 code: 1,
3331 };
3332 throw new Error(`Unexpected args: ${joined}`);
3333 }),
3334 ),
3335 ),
3336 );
3337 });
3338});
apps/server/src/provider/Layers/ProviderRegistry.ts deleted-1000
......@@ -1,1000 +0,0 @@
1/**
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.
22 *
23 * @module ProviderRegistryLive
24 */
25import {
26 defaultInstanceIdForDriver,
27 ProviderDriverKind,
28 type ProviderInstanceId,
29 type ServerProvider,
30 type ServerProviderUpdateState,
31} from "@t3tools/contracts";
32import * as Cause from "effect/Cause";
33import * as Effect from "effect/Effect";
34import * as Equal from "effect/Equal";
35import * as FileSystem from "effect/FileSystem";
36import * as Layer from "effect/Layer";
37import * as Path from "effect/Path";
38import * as PubSub from "effect/PubSub";
39import * as Ref from "effect/Ref";
40import * as Stream from "effect/Stream";
41import * as Semaphore from "effect/Semaphore";
42
43import * as ModelManifest from "../ModelManifest.ts";
44import { applyProviderCompatibility } from "../providerCompatibility.ts";
45import * as ServerConfig from "../../config.ts";
46import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
47import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
48import {
49 hydrateCachedProvider,
50 isCachedProviderCorrelated,
51 orderProviderSnapshots,
52 readProviderStatusCache,
53 resolveProviderStatusCachePath,
54 writeProviderStatusCache,
55} from "../providerStatusCache.ts";
56import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts";
57import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
58import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
59
60const loadProviders = (
61 providerSources: ReadonlyArray<ProviderSnapshotSource>,
62): Effect.Effect<ReadonlyArray<ServerProvider>> =>
63 Effect.forEach(
64 providerSources,
65 (providerSource) =>
66 providerSource.getSnapshot.pipe(
67 Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)),
68 ),
69 {
70 concurrency: "unbounded",
71 },
72 );
73
74const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) =>
75 makeManualOnlyProviderMaintenanceCapabilities({
76 provider,
77 packageName: null,
78 });
79
80const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
81 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
82
83const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16;
84
85function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider {
86 return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd)
87 ? {
88 ...provider,
89 workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd),
90 }
91 : provider;
92}
93
94export function upsertProviderWorkspaceSnapshot(
95 provider: ServerProvider,
96 cwd: string,
97 scopedSnapshot: ProviderWorkspaceSnapshot,
98): ServerProvider {
99 const workspaceSnapshot = {
100 cwd,
101 checkedAt: scopedSnapshot.checkedAt,
102 slashCommands: scopedSnapshot.slashCommandsPending
103 ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ??
104 scopedSnapshot.slashCommands)
105 : scopedSnapshot.slashCommands,
106 ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}),
107 skills: scopedSnapshot.skills,
108 } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];
109 return {
110 ...provider,
111 workspaceSnapshots: [
112 ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd),
113 workspaceSnapshot,
114 ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER),
115 };
116}
117
118const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {
119 if (provider.driver === ProviderDriverKind.make("acpRegistry")) {
120 // ACP Registry discovery probes return the agent's complete inventory, so
121 // a completed probe (ready and authenticated) replaces the model list —
122 // otherwise agents that rename or collapse models leave stale entries
123 // pinned forever through the snapshot cache. Readiness-only and failed
124 // probe snapshots only know the "default" placeholder and stay partial.
125 return !(
126 provider.installed &&
127 provider.status === "ready" &&
128 provider.auth.status === "authenticated"
129 );
130 }
131
132 const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity");
133 const isCodex = provider.driver === ProviderDriverKind.make("codex");
134 if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) {
135 return true;
136 }
137
138 if (
139 (isAntigravity || isCodex) &&
140 (!provider.enabled || provider.auth.status === "unauthenticated")
141 ) {
142 return false;
143 }
144
145 // Successful discovery replaces these inventories so cached retired models disappear.
146 // Antigravity's local health check does not authenticate or discover models.
147 const isPendingAntigravityAuthentication =
148 isAntigravity && provider.status === "warning" && provider.auth.status === "unknown";
149 const isPendingInitialProbe =
150 provider.enabled && !provider.installed && provider.status === "warning";
151 const didInstalledProviderProbeFail = provider.installed && provider.status === "error";
152 return (
153 isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail
154 );
155};
156
157const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>
158 provider.driver === ProviderDriverKind.make("opencode") &&
159 shouldRetainMissingProviderModels(provider);
160
161const mergeProviderModels = (
162 provider: ServerProvider,
163 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
164 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
165): ReadonlyArray<ServerProvider["models"][number]> => {
166 const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider);
167 // Custom rows are derived from settings and every snapshot carries the full
168 // current list, so a custom model missing from `nextModels` was removed by
169 // the user and must not be resurrected from the previous snapshot.
170 const retainablePreviousModels = previousModels.filter((model) => !model.isCustom);
171
172 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {
173 return retainablePreviousModels;
174 }
175
176 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
177 const mergedModels = nextModels.map((model) => {
178 const previousModel = previousBySlug.get(model.slug);
179 if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) {
180 return model;
181 }
182 return {
183 ...model,
184 capabilities: previousModel.capabilities,
185 };
186 });
187 const nextSlugs = new Set(nextModels.map((model) => model.slug));
188 return shouldRetainMissingModels
189 ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))]
190 : mergedModels;
191};
192
193/**
194 * Antigravity's health check only initializes the agent, so after a server
195 * restart it reports the account as unchecked. The saved Google login still
196 * works, and the previous snapshot proves it. Carry that account state until
197 * a session, refresh, or sign-out reports something new. A confirmed missing
198 * installation, sign-out, disabled instance, or a changed sign-in method is
199 * never overridden.
200 */
201const carrySavedAntigravityAccount = (
202 previousProvider: ServerProvider,
203 nextProvider: ServerProvider,
204): Pick<ServerProvider, "auth" | "status"> | undefined => {
205 const antigravity = ProviderDriverKind.make("antigravity");
206 if (
207 nextProvider.driver !== antigravity ||
208 previousProvider.driver !== antigravity ||
209 !nextProvider.enabled ||
210 nextProvider.auth.status !== "unknown" ||
211 previousProvider.auth.status !== "authenticated" ||
212 (nextProvider.auth.type !== undefined &&
213 nextProvider.auth.type !== previousProvider.auth.type) ||
214 (!nextProvider.installed && nextProvider.status !== "warning")
215 ) {
216 return undefined;
217 }
218 // The pending boot probe (`installed: false`, warning) and a failed probe
219 // keep their own status; only a passed health check reads as ready.
220 const status =
221 nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status;
222 return { auth: previousProvider.auth, status };
223};
224
225export const mergeProviderSnapshot = (
226 previousProvider: ServerProvider | undefined,
227 nextProvider: ServerProvider,
228): ServerProvider => {
229 if (!previousProvider) {
230 return nextProvider;
231 }
232 const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider);
233 // "Google account access is not checked yet" describes the probe, not the
234 // account; it must not outlive the state it explained.
235 const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider;
236 return {
237 ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider),
238 ...savedAccount,
239 models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models),
240 ...(nextProvider.workspaceSnapshots !== undefined
241 ? { workspaceSnapshots: nextProvider.workspaceSnapshots }
242 : previousProvider.workspaceSnapshots !== undefined
243 ? { workspaceSnapshots: previousProvider.workspaceSnapshots }
244 : {}),
245 ...(shouldRetainMissingOpenCodeMetadata(nextProvider)
246 ? {
247 slashCommands:
248 nextProvider.slashCommands.length === 0
249 ? previousProvider.slashCommands
250 : nextProvider.slashCommands,
251 skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills,
252 }
253 : {}),
254 };
255};
256
257export const mergeProviderSnapshots = (
258 previousProviders: ReadonlyArray<ServerProvider>,
259 nextProviders: ReadonlyArray<ServerProvider>,
260): ReadonlyArray<ServerProvider> => {
261 const mergedProviders = new Map(
262 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
263 );
264
265 for (const provider of nextProviders) {
266 mergedProviders.set(
267 snapshotInstanceKey(provider),
268 mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider),
269 );
270 }
271
272 return orderProviderSnapshots([...mergedProviders.values()]);
273};
274
275export const selectProvidersByKind = (
276 providers: ReadonlyArray<ServerProvider>,
277 providerKinds: ReadonlySet<ProviderDriverKind>,
278): ReadonlyArray<ServerProvider> =>
279 providers.filter((provider) => providerKinds.has(provider.driver));
280
281const haveProvidersChanged = (
282 previousProviders: ReadonlyArray<ServerProvider>,
283 nextProviders: ReadonlyArray<ServerProvider>,
284): boolean => !Equal.equals(previousProviders, nextProviders);
285
286const correlateSnapshotWithSource = (
287 source: ProviderSnapshotSource,
288 snapshot: ServerProvider,
289): Effect.Effect<ServerProvider> => {
290 if (snapshot.instanceId !== source.instanceId) {
291 return Effect.die(
292 new Error(
293 `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`,
294 ),
295 );
296 }
297 if (snapshot.driver !== source.driverKind) {
298 return Effect.die(
299 new Error(
300 `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`,
301 ),
302 );
303 }
304 return Effect.succeed(snapshot);
305};
306
307/**
308 * Key a snapshot for aggregation and persistence. Snapshot sources
309 * must be correlated by instance id before reaching this map; missing
310 * identities are defects, not runtime routing fallbacks.
311 */
312const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => {
313 return provider.instanceId;
314};
315
316// Project a live `ProviderInstance` into the aggregator's consumption
317// shape. Each call re-captures the instance's `snapshot` closures, so
318// after `ProviderInstanceRegistry` rebuilds an instance (e.g. because
319// its settings changed), a fresh source rides the new PubSub instead
320// of a closed one.
321const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({
322 instanceId: instance.instanceId,
323 driverKind: instance.driverKind,
324 getSnapshot: instance.snapshot.getSnapshot,
325 refresh: instance.snapshot.refresh,
326 streamChanges: instance.snapshot.streamChanges,
327});
328
329export const layer = Layer.effect(
330 ProviderRegistry.ProviderRegistry,
331 Effect.gen(function* () {
332 const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
333 const manifestService = yield* ModelManifest.ModelManifest;
334 const serviceScope = yield* Effect.scope;
335 const config = yield* ServerConfig.ServerConfig;
336 const fileSystem = yield* FileSystem.FileSystem;
337 const path = yield* Path.Path;
338
339 // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for
340 // coalesced updates across every instance.
341 const changesPubSub = yield* Effect.acquireRelease(
342 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
343 PubSub.shutdown,
344 );
345
346 // Boot-only: hydrate `providersRef` from the on-disk per-instance
347 // cache so the UI has something to render during the first refresh.
348 // Instances added post-boot skip this path; their first entry in
349 // `providersRef` comes from the reactive `syncLiveSources` pass
350 // below.
351 const bootInstances = yield* instanceRegistry.listInstances;
352 const bootSources = bootInstances.map(buildSnapshotSource);
353 const fallbackProviders = yield* loadProviders(bootSources);
354 const fallbackByInstance = new Map<ProviderInstanceId, ServerProvider>();
355 for (let index = 0; index < fallbackProviders.length; index++) {
356 const provider = fallbackProviders[index];
357 const source = bootSources[index];
358 if (provider === undefined || source === undefined) {
359 continue;
360 }
361 fallbackByInstance.set(source.instanceId, provider);
362 }
363
364 const cachedProviders = yield* Effect.forEach(
365 bootSources,
366 (source) =>
367 Effect.gen(function* () {
368 // One cache file per configured instance. For the default
369 // instance of a built-in kind the path equals `<kind>.json` —
370 // identical to the legacy filename. We still require the cache
371 // payload to carry matching instance id + driver kind; old
372 // identity-less payloads are discarded and the awaited refresh
373 // below repopulates the cache.
374 const filePath = yield* resolveProviderStatusCachePath({
375 cacheDir: config.providerStatusCacheDir,
376 instanceId: source.instanceId,
377 }).pipe(Effect.provideService(Path.Path, path));
378 const fallbackProvider = fallbackByInstance.get(source.instanceId);
379 if (fallbackProvider === undefined) {
380 return undefined;
381 }
382 return yield* readProviderStatusCache(filePath).pipe(
383 Effect.provideService(FileSystem.FileSystem, fileSystem),
384 Effect.flatMap((cachedProvider) => {
385 if (cachedProvider === undefined) {
386 return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined));
387 }
388 const correlation = {
389 cachedProvider,
390 fallbackProvider,
391 } as const;
392 if (!isCachedProviderCorrelated(correlation)) {
393 return Effect.logWarning("provider status cache identity mismatch, ignoring", {
394 path: filePath,
395 instanceId: source.instanceId,
396 cachedInstanceId: cachedProvider.instanceId ?? null,
397 driver: source.driverKind,
398 cachedDriver: cachedProvider.driver ?? null,
399 }).pipe(Effect.as(undefined as ServerProvider | undefined));
400 }
401 return Effect.succeed(hydrateCachedProvider(correlation));
402 }),
403 );
404 }),
405 { concurrency: "unbounded" },
406 ).pipe(
407 Effect.map((providers) =>
408 orderProviderSnapshots(
409 providers.filter((provider): provider is ServerProvider => provider !== undefined),
410 ),
411 ),
412 );
413 const initialManifest = yield* manifestService.current;
414 const classifyCompatibility = (
415 provider: ServerProvider,
416 manifest: ModelManifest.ModelManifestData,
417 ) =>
418 applyProviderCompatibility(
419 provider,
420 manifest.compatibility,
421 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
422 );
423 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(
424 cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)),
425 );
426 const workspaceRefreshesRef = yield* Ref.make<
427 ReadonlyMap<ProviderInstance, ReadonlySet<string>>
428 >(new Map());
429 const maintenanceActionStatesRef = yield* Ref.make<
430 ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
431 >(new Map());
432
433 // Live-source registry — the dynamic counterpart to the boot-time
434 // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance`
435 // reference is used for identity equality so "no-op" reconciles
436 // (settings unchanged) skip re-subscribing + re-probing.
437 const liveSubsRef = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ProviderInstance>>(
438 new Map(),
439 );
440 // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't
441 // interleave two passes clobbering each other's fiber bookkeeping.
442 const syncSemaphore = yield* Semaphore.make(1);
443
444 const getLiveSources: Effect.Effect<ReadonlyArray<ProviderSnapshotSource>> = Ref.get(
445 liveSubsRef,
446 ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource)));
447
448 const persistProvider = (provider: ServerProvider) =>
449 Effect.gen(function* () {
450 // Persist every instance — the file name is the instance id, so
451 // multi-instance setups (e.g. `codex_personal`, `codex_work`) each
452 // get their own cache. We resolve the path fresh so snapshots
453 // produced by newly-added instances post-boot still land on disk
454 // without the aggregator holding a stale `cachePathByInstance`
455 // entry.
456 const key = snapshotInstanceKey(provider);
457 const filePath = yield* resolveProviderStatusCachePath({
458 cacheDir: config.providerStatusCacheDir,
459 instanceId: key,
460 }).pipe(Effect.provideService(Path.Path, path));
461 const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider;
462 yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe(
463 Effect.provideService(FileSystem.FileSystem, fileSystem),
464 Effect.provideService(Path.Path, path),
465 Effect.tapError(Effect.logError),
466 Effect.ignore,
467 );
468 });
469
470 const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* (
471 provider: ServerProvider,
472 ) {
473 const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef);
474 const updateState = maintenanceActionStates.get(provider.instanceId)?.update;
475 if (!updateState) {
476 const { updateState: _updateState, ...providerWithoutUpdateState } = provider;
477 return providerWithoutUpdateState;
478 }
479 return {
480 ...provider,
481 updateState,
482 };
483 });
484
485 const upsertProviders = Effect.fn("upsertProviders")(function* (
486 nextProviders: ReadonlyArray<ServerProvider>,
487 options?: {
488 readonly publish?: boolean;
489 readonly persist?: boolean;
490 readonly replace?: boolean;
491 },
492 ) {
493 const manifest = yield* manifestService.current;
494 const nextProvidersWithUpdateState = yield* Effect.forEach(
495 nextProviders,
496 applyProviderUpdateState,
497 {
498 concurrency: "unbounded",
499 },
500 );
501 const [previousProviders, providers, providersToPersist] = yield* Ref.modify(
502 providersRef,
503 (previousProviders) => {
504 const mergedProviders = new Map(
505 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
506 );
507 const updatedKeys = new Set<ProviderInstanceId>();
508
509 for (const provider of nextProvidersWithUpdateState) {
510 const key = snapshotInstanceKey(provider);
511 updatedKeys.add(key);
512 mergedProviders.set(
513 key,
514 options?.replace === true
515 ? provider
516 : mergeProviderSnapshot(mergedProviders.get(key), provider),
517 );
518 }
519
520 const providers = orderProviderSnapshots(
521 [...mergedProviders.values()].map((provider) =>
522 classifyCompatibility(provider, manifest),
523 ),
524 );
525 const providersToPersist = providers.filter((provider) =>
526 updatedKeys.has(snapshotInstanceKey(provider)),
527 );
528 return [[previousProviders, providers, providersToPersist] as const, providers];
529 },
530 );
531
532 if (haveProvidersChanged(previousProviders, providers)) {
533 if (options?.persist !== false) {
534 yield* Effect.forEach(providersToPersist, persistProvider, {
535 concurrency: "unbounded",
536 discard: true,
537 });
538 }
539 if (options?.publish !== false) {
540 yield* PubSub.publish(changesPubSub, providers);
541 }
542 }
543
544 return providers;
545 });
546
547 const compatibilityRefreshRunning = yield* Ref.make(false);
548 const syncProvider = Effect.fn("syncProvider")(function* (
549 provider: ServerProvider,
550 options?: {
551 readonly publish?: boolean;
552 },
553 ) {
554 const providers = yield* upsertProviders([provider], options);
555 // Reclassify the current read model after fetching. Never republish the
556 // probe captured before the fetch: a newer health result may have landed.
557 if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) {
558 yield* manifestService.refresh.pipe(
559 Effect.andThen(upsertProviders([], { persist: false })),
560 Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)),
561 Effect.forkIn(serviceScope),
562 );
563 }
564 return providers;
565 });
566
567 const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
568 function* (input: {
569 readonly instanceId: ProviderInstanceId;
570 readonly action: "update";
571 readonly state: ServerProviderUpdateState | null;
572 }) {
573 yield* Ref.update(maintenanceActionStatesRef, (previous) => {
574 const previousActions = previous.get(input.instanceId);
575 const nextActions = { ...previousActions };
576 if (input.state === null || input.state.status === "idle") {
577 delete nextActions[input.action];
578 } else {
579 nextActions[input.action] = input.state;
580 }
581
582 const next = new Map(previous);
583 if (Object.keys(nextActions).length === 0) {
584 next.delete(input.instanceId);
585 } else {
586 next.set(input.instanceId, nextActions);
587 }
588 return next;
589 });
590
591 const existingProviders = yield* Ref.get(providersRef);
592 const matchingProvider = existingProviders.find(
593 (candidate) => candidate.instanceId === input.instanceId,
594 );
595 if (!matchingProvider) {
596 return existingProviders;
597 }
598
599 const nextProvider = yield* applyProviderUpdateState(matchingProvider);
600 return yield* upsertProviders([nextProvider], {
601 persist: false,
602 });
603 },
604 );
605
606 const refreshOneSource = Effect.fn("refreshOneSource")(function* (
607 providerSource: ProviderSnapshotSource,
608 ) {
609 return yield* providerSource.refresh.pipe(
610 Effect.flatMap((nextProvider) =>
611 correlateSnapshotWithSource(providerSource, nextProvider).pipe(
612 Effect.flatMap(syncProvider),
613 ),
614 ),
615 );
616 });
617
618 const refreshAll = Effect.fn("refreshAll")(function* () {
619 const sources = yield* getLiveSources;
620 return yield* Effect.forEach(sources, (source) => refreshOneSource(source), {
621 concurrency: "unbounded",
622 discard: true,
623 }).pipe(Effect.andThen(Ref.get(providersRef)));
624 });
625
626 const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) {
627 if (provider === undefined) {
628 return yield* refreshAll();
629 }
630 // Kind-scoped refreshes target the default instance for that driver.
631 const defaultInstanceId = defaultInstanceIdForDriver(provider);
632 const sources = yield* getLiveSources;
633 const providerSource = sources.find(
634 (candidate) => candidate.instanceId === defaultInstanceId,
635 );
636 if (!providerSource) {
637 return yield* Ref.get(providersRef);
638 }
639 return yield* refreshOneSource(providerSource);
640 });
641
642 const refreshInstance = Effect.fn("refreshInstance")(function* (
643 instanceId: ProviderInstanceId,
644 ) {
645 const sources = yield* getLiveSources;
646 const providerSource = sources.find((candidate) => candidate.instanceId === instanceId);
647 if (!providerSource) {
648 return yield* Ref.get(providersRef);
649 }
650 return yield* refreshOneSource(providerSource);
651 });
652
653 const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
654 "getProviderMaintenanceCapabilitiesForInstance",
655 )(function* (
656 instanceId: ProviderInstanceId,
657 provider: ProviderDriverKind,
658 options?: { readonly fresh?: boolean },
659 ) {
660 // Read the instance registry, not `liveSubsRef`: the latter trails
661 // reconciliation, and an update must never run a retired instance's
662 // command against a freshly configured executable.
663 const instance = yield* instanceRegistry.getInstance(instanceId);
664 if (!instance || instance.driverKind !== provider) {
665 return makeManualProviderMaintenanceCapabilities(provider);
666 }
667 return yield* instance.snapshot.resolveMaintenance(options);
668 });
669
670 /**
671 * Diff the aggregator's live-source set against the current
672 * `ProviderInstanceRegistry` and:
673 * - subscribe to each newly-added or rebuilt instance's
674 * `streamChanges` (so periodic + enrichment refreshes land in
675 * `providersRef`);
676 * - read each newly-added/rebuilt instance's current snapshot after
677 * subscribing, closing the race with its independently-running
678 * background startup probe;
679 * - prune `providersRef` of instances that no longer exist.
680 *
681 * Provider refreshes are owned by each managed provider and never run
682 * on this layer's construction path. Consumers see cached or pending
683 * snapshots immediately, then receive live probe results through the
684 * already-attached change stream.
685 *
686 * Per-instance subscription fibers are not tracked explicitly. When
687 * a rebuilt instance's old child scope closes, its PubSub shuts
688 * down and our `Stream.runForEach` fiber exits naturally.
689 */
690 const syncLiveSources = syncSemaphore.withPermits(1)(
691 Effect.gen(function* () {
692 const instances = yield* instanceRegistry.listInstances;
693 const unavailableProviders = yield* instanceRegistry.listUnavailable;
694 const nextByInstance = new Map<ProviderInstanceId, ProviderInstance>(
695 instances.map((instance) => [instance.instanceId, instance] as const),
696 );
697 const knownInstanceIds = new Set<ProviderInstanceId>(nextByInstance.keys());
698 for (const provider of unavailableProviders) {
699 knownInstanceIds.add(snapshotInstanceKey(provider));
700 }
701 const previousSubs = yield* Ref.get(liveSubsRef);
702
703 // Carry over subscriptions for instances whose identity is
704 // unchanged (reconcile treated them as no-op). Instances that
705 // disappeared, or were rebuilt with a different reference,
706 // fall through to the "newly-added" branch below.
707 const carriedOver = new Map<ProviderInstanceId, ProviderInstance>();
708 for (const [instanceId, previousInstance] of previousSubs) {
709 const nextInstance = nextByInstance.get(instanceId);
710 if (nextInstance !== undefined && nextInstance === previousInstance) {
711 carriedOver.set(instanceId, previousInstance);
712 }
713 }
714
715 // Collect new/rebuilt instances in `nextByInstance` insertion
716 // order (which preserves settings-author order).
717 const newlyAdded: Array<readonly [ProviderInstanceId, ProviderInstance]> = [];
718 for (const [instanceId, instance] of nextByInstance) {
719 if (carriedOver.has(instanceId)) {
720 continue;
721 }
722 newlyAdded.push([instanceId, instance] as const);
723 }
724
725 const rebuiltInstanceIds = new Set(
726 newlyAdded
727 .map(([instanceId]) => instanceId)
728 .filter((instanceId) => previousSubs.has(instanceId)),
729 );
730 if (rebuiltInstanceIds.size > 0) {
731 const [previousProviders, providers] = yield* Ref.modify(
732 providersRef,
733 (previousProviders) => {
734 const providers = previousProviders.map((provider) => {
735 if (!rebuiltInstanceIds.has(provider.instanceId)) return provider;
736 const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider;
737 return machineSnapshot;
738 });
739 return [[previousProviders, providers] as const, providers];
740 },
741 );
742 if (haveProvidersChanged(previousProviders, providers)) {
743 yield* PubSub.publish(changesPubSub, providers);
744 }
745 }
746
747 // Fork long-lived subscriptions to each new/rebuilt instance's
748 // change stream before reading its current snapshot. If the
749 // driver's own initial probe finishes during this sync, either
750 // the current read or the active subscriber observes the result.
751 for (const [, instance] of newlyAdded) {
752 const source = buildSnapshotSource(instance);
753 yield* Stream.runForEach(source.streamChanges, (provider) =>
754 correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)),
755 ).pipe(Effect.forkScoped);
756 }
757 yield* Effect.yieldNow;
758
759 // Snapshot current state without starting a probe. Managed providers
760 // launch their startup refresh independently, so this closes the
761 // subscription race without putting external work on the registry
762 // or HTTP server construction path.
763 yield* Effect.forEach(
764 newlyAdded,
765 ([, instance]) =>
766 Effect.gen(function* () {
767 const source = buildSnapshotSource(instance);
768 const provider = yield* source.getSnapshot;
769 yield* correlateSnapshotWithSource(source, provider).pipe(
770 Effect.flatMap(syncProvider),
771 );
772 }).pipe(Effect.ignoreCause({ log: true })),
773 { concurrency: "unbounded", discard: true },
774 );
775 yield* upsertProviders(unavailableProviders, {
776 persist: false,
777 replace: true,
778 });
779
780 const nextSubs = new Map(carriedOver);
781 for (const [instanceId, instance] of newlyAdded) {
782 nextSubs.set(instanceId, instance);
783 }
784 yield* Ref.set(liveSubsRef, nextSubs);
785
786 // Drop aggregator state for instances that have disappeared —
787 // otherwise the UI would keep rendering ghosts.
788 const [previousProviders, providers] = yield* Ref.modify(
789 providersRef,
790 (previousProviders) => {
791 const providers = orderProviderSnapshots(
792 previousProviders.filter((provider) =>
793 knownInstanceIds.has(snapshotInstanceKey(provider)),
794 ),
795 );
796 return [[previousProviders, providers] as const, providers];
797 },
798 );
799 if (haveProvidersChanged(previousProviders, providers)) {
800 yield* PubSub.publish(changesPubSub, providers);
801 }
802 yield* Ref.update(maintenanceActionStatesRef, (previous) => {
803 const next = new Map(previous);
804 for (const instanceId of previous.keys()) {
805 if (!knownInstanceIds.has(instanceId)) {
806 next.delete(instanceId);
807 }
808 }
809 return next;
810 });
811 }),
812 );
813 const syncLiveSourcesAndContinue = syncLiveSources.pipe(
814 Effect.catchCause((cause) => {
815 if (Cause.hasInterruptsOnly(cause)) {
816 return Effect.interrupt;
817 }
818 return Effect.logError(
819 "provider registry instance sync failed; keeping subscription alive",
820 {
821 cause: Cause.pretty(cause),
822 },
823 );
824 }),
825 );
826
827 // Seed `providersRef` with the boot-time fallback snapshots so
828 // consumers calling `getProviders` immediately after layer build see
829 // a populated list — even before the first `syncLiveSources` refresh
830 // resolves. Cached snapshots (already in `providersRef`) merge with
831 // these via `upsertProviders` so on-disk state wins where present
832 // and pending fallbacks fill the gaps.
833 yield* upsertProviders(fallbackProviders, { publish: false });
834 // Subscribe to registry mutations BEFORE running the initial sync.
835 // `subscribeChanges` acquires the dequeue synchronously in this
836 // fibre; the subscription is active the instant this `yield*`
837 // returns. Forking the consumer loop later cannot lose a publish
838 // because no publish can reach a not-yet-subscribed dequeue.
839 //
840 // (Contrast with the pre-fix code that did
841 // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`.
842 // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start,
843 // and `forkScoped` only schedules the fibre — so a reconcile that
844 // published between "fibre scheduled" and "fibre starts running"
845 // was dropped, which made any settings change that replaced an
846 // instance never propagate to the aggregator's `providersRef`.)
847 // Subscribe to registry mutations BEFORE running the initial sync.
848 // `subscribeChanges` acquires the `PubSub.Subscription` synchronously
849 // in this fibre; the subscription is registered with the PubSub the
850 // instant this `yield*` returns, so any subsequent publish is
851 // buffered in the subscription regardless of when the consumer
852 // fibre below actually starts running.
853 //
854 // (Contrast with the pre-fix code that did
855 // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`.
856 // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`,
857 // which defers `PubSub.subscribe` to stream start. `forkScoped` only
858 // schedules the consumer fibre — so a reconcile that published
859 // between "fibre scheduled" and "fibre starts running + subscribes"
860 // was dropped, which made any settings change that replaced an
861 // instance never propagate to the aggregator's `providersRef`.)
862 const instanceChanges = yield* instanceRegistry.subscribeChanges;
863 // Initial sync attaches subscriptions and snapshots current state for
864 // every instance present at boot. Provider probes are already running in
865 // their managed background fibers and never block this layer.
866 yield* syncLiveSources;
867 // React to registry mutations — instance added / removed / rebuilt.
868 // `Stream.fromSubscription` builds a stream over the pre-acquired
869 // subscription rather than subscribing on stream start, which is
870 // what closes the race.
871 yield* Stream.runForEach(
872 Stream.fromSubscription(instanceChanges),
873 () => syncLiveSourcesAndContinue,
874 ).pipe(Effect.forkScoped);
875
876 const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* (
877 cause: Cause.Cause<unknown>,
878 ) {
879 if (Cause.hasInterruptsOnly(cause)) {
880 return yield* Effect.interrupt;
881 }
882 yield* Effect.logError("provider registry refresh failed; preserving cached providers", {
883 cause: Cause.pretty(cause),
884 });
885 return yield* Ref.get(providersRef);
886 });
887
888 const updateProviders = (
889 update: (providers: ReadonlyArray<ServerProvider>) => ReadonlyArray<ServerProvider>,
890 ) =>
891 Ref.modify(providersRef, (currentProviders) => {
892 const nextProviders = update(currentProviders);
893 return [[currentProviders, nextProviders] as const, nextProviders];
894 }).pipe(
895 Effect.tap(([previousProviders, nextProviders]) =>
896 haveProvidersChanged(previousProviders, nextProviders)
897 ? PubSub.publish(changesPubSub, nextProviders)
898 : Effect.void,
899 ),
900 Effect.map(([, nextProviders]) => nextProviders),
901 );
902
903 const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: {
904 readonly instanceId: ProviderInstanceId;
905 readonly cwd: string;
906 readonly fresh?: boolean;
907 }) {
908 // Fresh scans drop other instances' snapshots for this cwd first, so a
909 // composer on one of them scans again on next use, even when this
910 // instance is gone or cannot be scanned.
911 if (input.fresh) {
912 yield* updateProviders((providers) =>
913 providers.map((candidate) =>
914 candidate.instanceId === input.instanceId
915 ? candidate
916 : dropProviderWorkspaceSnapshot(candidate, input.cwd),
917 ),
918 );
919 }
920 const providers = yield* Ref.get(providersRef);
921 const provider = providers.find((candidate) => candidate.instanceId === input.instanceId);
922 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
923 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
924 const scannedFrom = workspaceSnapshotOf(provider);
925 if (
926 !provider ||
927 !provider.enabled ||
928 (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending)
929 ) {
930 return providers;
931 }
932 const instance = yield* instanceRegistry.getInstance(input.instanceId);
933 if (!instance?.snapshotForCwd) return providers;
934 const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => {
935 const current = refreshes.get(instance);
936 if (current?.has(input.cwd)) return [false, refreshes] as const;
937 const next = new Map(refreshes);
938 next.set(instance, new Set(current).add(input.cwd));
939 return [true, next] as const;
940 });
941 // A fresh scan never joins a running one, which may predate the change.
942 if (!claimed && !input.fresh) return yield* Ref.get(providersRef);
943 // Fresh scans also re-read the machine snapshot after invalidating caches.
944 const refreshMachineSnapshot = input.fresh
945 ? (instance.invalidateCaches ?? Effect.void).pipe(
946 Effect.andThen(refreshInstance(input.instanceId)),
947 )
948 : Effect.void;
949 return yield* refreshMachineSnapshot.pipe(
950 Effect.andThen(instance.snapshotForCwd(input.cwd)),
951 Effect.flatMap((scopedSnapshot) =>
952 scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined
953 ? Ref.get(providersRef)
954 : instanceRegistry.getInstance(input.instanceId).pipe(
955 Effect.flatMap((currentInstance) => {
956 if (currentInstance !== instance) return Ref.get(providersRef);
957 // Write only if the cwd's snapshot did not change during the
958 // scan. A session event or another scan that landed first is newer.
959 return updateProviders((currentProviders) =>
960 currentProviders.map((candidate) =>
961 candidate.instanceId === input.instanceId &&
962 Equal.equals(workspaceSnapshotOf(candidate), scannedFrom)
963 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot)
964 : candidate,
965 ),
966 );
967 }),
968 ),
969 ),
970 Effect.ensuring(
971 claimed
972 ? Ref.update(workspaceRefreshesRef, (refreshes) => {
973 const next = new Map(refreshes);
974 const current = new Set(next.get(instance));
975 current.delete(input.cwd);
976 if (current.size) next.set(instance, current);
977 else next.delete(instance);
978 return next;
979 })
980 : Effect.void,
981 ),
982 );
983 });
984
985 return {
986 getProviders: Ref.get(providersRef),
987 refresh: (provider?: ProviderDriverKind) =>
988 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
989 refreshInstance: (instanceId: ProviderInstanceId) =>
990 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
991 refreshWorkspaceSnapshot: (input) =>
992 refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)),
993 getProviderMaintenanceCapabilitiesForInstance,
994 setProviderMaintenanceActionState,
995 get streamChanges() {
996 return Stream.fromPubSub(changesPubSub);
997 },
998 } satisfies ProviderRegistry.ProviderRegistryShape;
999 }),
1000);
apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts deleted-7
......@@ -1,7 +0,0 @@
1/**
2 * V2 drivers fold live account usage events directly into their managed
3 * snapshots. Retained as an empty layer for the shared server composition.
4 */
5import * as Layer from "effect/Layer";
6
7export const layer = Layer.empty;
apps/server/src/provider/Layers/claudeResetCredits.test.ts deleted-259
......@@ -1,259 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import { HttpClient, HttpClientResponse, UrlParams } from "effect/http";
12import { describe, expect, it } from "vite-plus/test";
13
14import * as ClaudeResetCredits from "./claudeResetCredits.ts";
15
16const NOW = Date.parse("2026-09-22T12:00:00.000Z");
17const grant = (overrides: Record<string, unknown>) => ({
18 id: "grant_a",
19 resets_left: 1,
20 usable_now: true,
21 ...overrides,
22});
23
24const writeLogin = Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const directory = yield* fs.makeTempDirectoryScoped();
28 yield* fs.writeFileString(
29 path.join(directory, ".credentials.json"),
30 '{"claudeAiOauth":{"accessToken":"oauth-token"}}',
31 );
32 const accountConfigPath = path.join(directory, ".claude.json");
33 yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}');
34 return { configDir: directory, accountConfigPath };
35});
36
37const respond = (status: number, body: unknown) =>
38 HttpClient.make((request) =>
39 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))),
40 );
41const refuseRequests = HttpClient.make(() => Effect.die("must not send a request"));
42
43describe("claudeResetCreditsToContract", () => {
44 it("counts live grants and pins the next usable one", () => {
45 expect(
46 ClaudeResetCredits.claudeResetCreditsToContract(
47 {
48 eligible: true,
49 next_grant_id: "grant_a",
50 grants: [
51 grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }),
52 grant({ id: "paused", paused: true }),
53 grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }),
54 grant({ id: "garbled", ends_at: "not a date" }),
55 grant({ id: "date_only", ends_at: "2026-10-01" }),
56 grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }),
57 grant({ id: "empty", ends_at: "" }),
58 grant({ id: "Not Valid" }),
59 grant({ id: "grant_b", resets_left: 3, usable_now: false }),
60 ],
61 },
62 NOW,
63 ),
64 ).toEqual({
65 availableCount: 2,
66 nextCreditId: "grant_a",
67 nextExpiresAt: "2026-10-01T00:00:00.000Z",
68 });
69 });
70
71 it("offers nothing to redeem without a usable next grant or an eligible account", () => {
72 expect(
73 ClaudeResetCredits.claudeResetCreditsToContract(
74 { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] },
75 NOW,
76 ),
77 ).toEqual({ availableCount: 0 });
78 expect(
79 ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW),
80 ).toEqual({
81 availableCount: 0,
82 });
83 expect(
84 ClaudeResetCredits.claudeResetCreditsToContract(
85 { eligible: false, grants: [grant({})] },
86 NOW,
87 ),
88 ).toBeUndefined();
89 expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined();
90 });
91});
92
93effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => {
94 it.effect("reads the grants with the CLI's request", () =>
95 Effect.gen(function* () {
96 const { configDir } = yield* writeLogin;
97 const client = HttpClient.make((request) => {
98 expect(request.method).toBe("GET");
99 expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage");
100 expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1");
101 expect(request.headers.authorization).toBe("Bearer oauth-token");
102 expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
103 expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)");
104 return Effect.succeed(
105 HttpClientResponse.fromWeb(
106 request,
107 Response.json({
108 cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] },
109 }),
110 ),
111 );
112 });
113 const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
114 Effect.provideService(HostProcessPlatform, "linux"),
115 Effect.provideService(HttpClient.HttpClient, client),
116 );
117 expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" });
118 }),
119 );
120
121 it.effect("reads nothing from keychain logins or failed requests", () =>
122 Effect.gen(function* () {
123 const { configDir } = yield* writeLogin;
124 const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
125 Effect.provideService(HostProcessPlatform, "darwin"),
126 Effect.provideService(HttpClient.HttpClient, refuseRequests),
127 );
128 const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
129 Effect.provideService(HostProcessPlatform, "linux"),
130 Effect.provideService(HttpClient.HttpClient, respond(429, {})),
131 );
132 expect([darwin, limited]).toEqual([undefined, undefined]);
133 }),
134 );
135});
136
137const ClaimBody = Schema.fromJsonString(
138 Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }),
139);
140const decodeClaimBody = Schema.decodeEffect(ClaimBody);
141
142const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) =>
143 Effect.gen(function* () {
144 const login = yield* writeLogin;
145 return yield* ClaudeResetCredits.consumeClaudeResetCredit({
146 ...login,
147 version: "2.1.0",
148 ...ids,
149 }).pipe(
150 Effect.provideService(HostProcessPlatform, "linux"),
151 Effect.provideService(HttpClient.HttpClient, client),
152 Effect.result,
153 );
154 });
155
156effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => {
157 it.effect("claims the grant for the organization", () =>
158 Effect.gen(function* () {
159 const client = HttpClient.make((request) =>
160 Effect.gen(function* () {
161 expect(request.method).toBe("POST");
162 expect(request.url).toBe(
163 "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits",
164 );
165 expect(request.headers.authorization).toBe("Bearer oauth-token");
166 const body =
167 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
168 expect(yield* decodeClaimBody(body)).toEqual({
169 program: "cedar_ember",
170 grant_id: "grant_a",
171 request_id: "r-1",
172 });
173 return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }));
174 }).pipe(Effect.orDie),
175 );
176 expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" });
177 }),
178 );
179
180 it.effect("maps each answer to an outcome or a failure", () =>
181 Effect.gen(function* () {
182 for (const [result, outcome] of [
183 ["not_limited", "nothingToReset"],
184 ["already_used", "alreadyRedeemed"],
185 ["ineligible", "noCredit"],
186 ] as const) {
187 expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome });
188 }
189 for (const client of [
190 respond(200, { result: "cooldown" }),
191 respond(429, {}),
192 respond(401, {}),
193 ]) {
194 const result = yield* consume(client);
195 expect(result).toMatchObject({ _tag: "Failure" });
196 // Claude answered, so a retry must be a new claim.
197 if (result._tag === "Failure") {
198 expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true);
199 }
200 }
201 // No answer, or Claude could not confirm the claim: a retry is the same claim.
202 for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) {
203 const unanswered = yield* consume(client);
204 expect(unanswered).toMatchObject({ _tag: "Failure" });
205 if (unanswered._tag === "Failure") {
206 expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe(
207 false,
208 );
209 }
210 }
211 }),
212 );
213
214 it.effect("times out a stalled claim body", () =>
215 Effect.gen(function* () {
216 const login = yield* writeLogin;
217 const readingBody = yield* Deferred.make<void>();
218 const client = HttpClient.make((request) => {
219 const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }));
220 Object.defineProperty(response, "json", {
221 value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)),
222 });
223 return Effect.succeed(response);
224 });
225 const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({
226 ...login,
227 version: "2.1.0",
228 grantId: "grant_a",
229 requestId: "r-1",
230 }).pipe(
231 Effect.provideService(HostProcessPlatform, "linux"),
232 Effect.provideService(HttpClient.HttpClient, client),
233 Effect.result,
234 Effect.forkChild,
235 );
236 yield* Deferred.await(readingBody);
237 yield* TestClock.adjust("26 seconds");
238 expect(yield* Fiber.join(claim)).toMatchObject({
239 _tag: "Failure",
240 failure: {
241 _tag: "ClaudeResetCreditError",
242 reason: "requestFailed",
243 cause: { _tag: "TimeoutError" },
244 },
245 });
246 }).pipe(Effect.provide(TestClock.layer())),
247 );
248
249 it.effect("refuses malformed ids without sending anything", () =>
250 Effect.gen(function* () {
251 for (const ids of [
252 { grantId: "Bad Grant", requestId: "r-1" },
253 { grantId: "grant_a", requestId: "has space" },
254 ]) {
255 expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" });
256 }
257 }),
258 );
259});
apps/server/src/provider/Layers/claudeResetCredits.ts deleted-270
......@@ -1,270 +0,0 @@
1/**
2 * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the
3 * grants from the OAuth usage endpoint and claims one against the
4 * organization; this module does the same with the credentials the CLI keeps
5 * in its config directory. macOS keeps them in the keychain, so there the
6 * feature is not offered.
7 *
8 * @module provider/Layers/claudeResetCredits
9 */
10import * as NodeOS from "node:os";
11import type {
12 ProviderConsumeResetCreditOutcome,
13 ServerProviderResetCredits,
14} from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
23
24const API_BASE = "https://api.anthropic.com";
25const PROGRAM = "cedar_ember";
26const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
27const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
28const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
29
30const Credentials = Schema.Struct({
31 claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })),
32});
33const Config = Schema.Struct({
34 oauthAccount: Schema.optional(
35 Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }),
36 ),
37});
38const Grant = Schema.Struct({
39 id: Schema.String.check(Schema.isPattern(GRANT_ID)),
40 resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
41 ends_at: Schema.optional(Schema.NullOr(Schema.String)),
42 paused: Schema.optional(Schema.Boolean),
43 usable_now: Schema.optional(Schema.Boolean),
44});
45const decodeGrant = Schema.decodeUnknownOption(Grant);
46const CedarEmber = Schema.Struct({
47 eligible: Schema.Boolean,
48 grants: Schema.optional(Schema.Array(Schema.Unknown)),
49 next_grant_id: Schema.optional(Schema.NullOr(Schema.String)),
50});
51const UsageResponse = Schema.Struct({
52 cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)),
53});
54const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber);
55const ClaimResponse = Schema.Struct({
56 result: Schema.Literals([
57 "reset",
58 "already_used",
59 "not_limited",
60 "cooldown",
61 "ineligible",
62 "unavailable",
63 ]),
64});
65
66const RESET_CREDIT_FAILURES = {
67 malformedCredit: "Claude returned a malformed reset credit.",
68 loginUnreadable: "Claude could not read its login.",
69 accountUnreadable: "Claude could not read its account.",
70 signedOut: "Sign in to Claude again to redeem resets.",
71 rateLimited: "Claude is rate limiting resets. Try again soon.",
72 coolingDown: "Claude resets are cooling down. Try again later.",
73 unconfirmed:
74 "Claude could not confirm the reset. If you are still limited in a moment, try again.",
75 requestFailed: "Claude could not redeem the reset.",
76} as const;
77
78class ClaudeResetCreditError extends Schema.TaggedError<ClaudeResetCreditError>()(
79 "ClaudeResetCreditError",
80 {
81 reason: Schema.Literals(
82 Object.keys(RESET_CREDIT_FAILURES) as Array<keyof typeof RESET_CREDIT_FAILURES>,
83 ),
84 cause: Schema.optional(Schema.Defect()),
85 },
86) {
87 override get message(): string {
88 return RESET_CREDIT_FAILURES[this.reason];
89 }
90}
91
92const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError);
93
94/**
95 * Every reset failure except `requestFailed` and `unconfirmed` is final:
96 * Claude answered, or nothing was sent. An unanswered or unconfirmed claim
97 * retries with the same request id.
98 */
99export const isSettledClaudeResetCreditFailure = (error: unknown) =>
100 isClaudeResetCreditError(error) &&
101 error.reason !== "requestFailed" &&
102 error.reason !== "unconfirmed";
103
104/** Rejects unparseable and calendar-invalid timestamps such as February 30. */
105const isFutureTimestamp = (value: string, nowMs: number) => {
106 if (!COMPLETE_TIMESTAMP.test(value)) return false;
107 const [year, month, day] = value.slice(0, 10).split("-").map(Number);
108 return (
109 Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0)
110 );
111};
112
113/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */
114export function claudeResetCreditsToContract(
115 block: unknown,
116 nowMs: number,
117): ServerProviderResetCredits | undefined {
118 const parsed = decodeCedarEmber(block);
119 if (Option.isNone(parsed) || !parsed.value.eligible) return undefined;
120 const live = (parsed.value.grants ?? [])
121 .flatMap((raw) => Option.toArray(decodeGrant(raw)))
122 .filter(
123 (grant) =>
124 !grant.paused &&
125 grant.usable_now &&
126 (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)),
127 );
128 const next = live.find((grant) => grant.id === parsed.value.next_grant_id);
129 const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none();
130 return {
131 availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0,
132 ...(Option.isSome(nextExpiresAt)
133 ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) }
134 : {}),
135 ...(next ? { nextCreditId: next.id } : {}),
136 };
137}
138
139const readJson = <S extends Schema.Top>(schema: S, file: string) =>
140 Effect.gen(function* () {
141 const fs = yield* FileSystem.FileSystem;
142 return yield* fs.readFileString(file).pipe(
143 Effect.catchTags({
144 PlatformError: (error) =>
145 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
146 }),
147 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))),
148 );
149 });
150
151const readAccessToken = (configDir: string) =>
152 Effect.gen(function* () {
153 if ((yield* HostProcessPlatform) === "darwin") return undefined;
154 const path = yield* Path.Path;
155 const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json"));
156 return credentials.claudeAiOauth?.accessToken?.trim() || undefined;
157 });
158
159const withClaudeHeaders = (token: string, version: string) =>
160 HttpClientRequest.setHeaders({
161 authorization: `Bearer ${token}`,
162 "anthropic-beta": "oauth-2025-04-20",
163 "user-agent": `claude-cli/${version} (external, cli)`,
164 });
165
166/**
167 * Reads the banked resets for the login in `configDir`. Any failure reads as
168 * "no resets" so the usage bars never break on this optional extra.
169 */
170export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")(
171 function* (configDir: string, version: string) {
172 const token = yield* readAccessToken(configDir);
173 if (!token) return undefined;
174 const client = yield* HttpClient.HttpClient;
175 const response = yield* client.execute(
176 HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, {
177 urlParams: { cedar_ember: "1", skip_spend: "1" },
178 }).pipe(withClaudeHeaders(token, version)),
179 );
180 const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)(
181 yield* HttpClientResponse.filterStatusOk(response),
182 );
183 return claudeResetCreditsToContract(
184 body.cedar_ember,
185 DateTime.toEpochMillis(yield* DateTime.now),
186 );
187 },
188 Effect.timeout("10 seconds"),
189 Effect.orElseSucceed(() => undefined),
190);
191
192/** The CLI keeps the account record beside its settings, or in the home directory by default. */
193export const claudeAccountConfigPath = (configDir: string | undefined) =>
194 Effect.map(Path.Path, (path) =>
195 configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"),
196 );
197
198const CLAIM_OUTCOMES = {
199 reset: "reset",
200 not_limited: "nothingToReset",
201 already_used: "alreadyRedeemed",
202 ineligible: "noCredit",
203} as const satisfies Record<string, ProviderConsumeResetCreditOutcome>;
204
205/**
206 * Claims `grantId`. `requestId` is the idempotency key: a retry with the same
207 * id is the same claim. Ids are checked before anything is sent.
208 */
209export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: {
210 readonly configDir: string;
211 readonly accountConfigPath: string;
212 readonly version: string;
213 readonly grantId: string;
214 readonly requestId: string;
215}) {
216 if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) {
217 return yield* new ClaudeResetCreditError({ reason: "malformedCredit" });
218 }
219 const token = yield* readAccessToken(input.configDir).pipe(
220 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })),
221 );
222 const config = yield* readJson(Config, input.accountConfigPath).pipe(
223 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })),
224 );
225 const organization = config.oauthAccount?.organizationUuid?.trim();
226 if (!token || !organization) {
227 return yield* new ClaudeResetCreditError({ reason: "signedOut" });
228 }
229 const client = yield* HttpClient.HttpClient;
230 const response = yield* client
231 .execute(
232 HttpClientRequest.post(
233 new URL(
234 `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`,
235 API_BASE,
236 ),
237 ).pipe(
238 withClaudeHeaders(token, input.version),
239 HttpClientRequest.bodyJsonUnsafe({
240 program: PROGRAM,
241 grant_id: input.grantId,
242 request_id: input.requestId,
243 }),
244 ),
245 )
246 .pipe(
247 Effect.timeout("25 seconds"),
248 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
249 );
250 if (response.status === 429) {
251 return yield* new ClaudeResetCreditError({ reason: "rateLimited" });
252 }
253 if (response.status === 401 || response.status === 403) {
254 return yield* new ClaudeResetCreditError({ reason: "signedOut" });
255 }
256 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
257 Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)),
258 Effect.timeout("25 seconds"),
259 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
260 );
261 if (body.result === "cooldown") {
262 return yield* new ClaudeResetCreditError({ reason: "coolingDown" });
263 }
264 // Claude could not say whether the claim landed, so, like the CLI, keep the
265 // request id and let the retry ask about the same claim.
266 if (body.result === "unavailable") {
267 return yield* new ClaudeResetCreditError({ reason: "unconfirmed" });
268 }
269 return CLAIM_OUTCOMES[body.result];
270});
apps/server/src/provider/Layers/claudeUsageLimits.test.ts deleted-180
......@@ -1,180 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts";
4
5const checkedAt = "2026-07-18T10:00:00.000Z";
6const noNames = { overageIncluded: undefined } as const;
7
8describe("claudeUsageResponseToLimits", () => {
9 it("maps the session, weekly, and model-scoped weekly windows", () => {
10 expect(
11 claudeUsageResponseToLimits({
12 checkedAt,
13 response: {
14 rate_limits_available: true,
15 rate_limits: {
16 five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" },
17 seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" },
18 seven_day_opus: { utilization: 3, resets_at: null },
19 // Newer CLIs add this on top of the typed keys; the pinned SDK
20 // typings do not know it yet.
21 ...({
22 model_scoped: [
23 { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" },
24 { display_name: "Ghost", utilization: null, resets_at: null },
25 ],
26 } as object),
27 extra_usage: {
28 is_enabled: false,
29 monthly_limit: null,
30 used_credits: null,
31 utilization: null,
32 },
33 },
34 },
35 }),
36 ).toEqual({
37 names: { overageIncluded: "Fable" },
38 limits: {
39 checkedAt,
40 windows: [
41 {
42 id: "five_hour",
43 kind: "session",
44 label: "Session",
45 usedPercent: 54,
46 windowDurationMins: 300,
47 resetsAt: "2026-07-18T14:39:00.000Z",
48 },
49 {
50 id: "seven_day",
51 kind: "weekly",
52 label: "Weekly",
53 usedPercent: 18.4,
54 windowDurationMins: 10080,
55 resetsAt: "2026-07-24T08:59:00.000Z",
56 },
57 {
58 id: "seven_day_fable",
59 kind: "weekly",
60 label: "Weekly · Fable",
61 usedPercent: 73,
62 windowDurationMins: 10080,
63 resetsAt: "2026-07-24T08:59:00.000Z",
64 },
65 ],
66 },
67 });
68 });
69
70 it("names the overage-included bucket only from a scoped entry that drew a row", () => {
71 expect(
72 claudeUsageResponseToLimits({
73 checkedAt,
74 response: {
75 rate_limits_available: true,
76 rate_limits: {
77 ...({
78 model_scoped: [
79 { display_name: "Ghost", utilization: null, resets_at: null },
80 { display_name: "Fable", utilization: 5, resets_at: null },
81 ],
82 } as object),
83 },
84 },
85 }).names,
86 ).toEqual({ overageIncluded: "Fable" });
87 });
88
89 it("reports API key and Bedrock accounts as unsupported", () => {
90 expect(
91 claudeUsageResponseToLimits({
92 checkedAt,
93 response: { rate_limits_available: false, rate_limits: null },
94 }).limits,
95 ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } });
96 });
97
98 it("skips a window the endpoint reports without a utilization", () => {
99 expect(
100 claudeUsageResponseToLimits({
101 checkedAt,
102 response: {
103 rate_limits_available: true,
104 rate_limits: {
105 five_hour: { utilization: null, resets_at: null },
106 seven_day: { utilization: 250, resets_at: null },
107 },
108 },
109 }).limits.windows,
110 ).toEqual([
111 {
112 id: "seven_day",
113 kind: "weekly",
114 label: "Weekly",
115 usedPercent: 100,
116 windowDurationMins: 10080,
117 },
118 ]);
119 });
120});
121
122describe("claudeRateLimitEventToUpdate", () => {
123 it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => {
124 expect(
125 claudeRateLimitEventToUpdate(
126 {
127 status: "allowed_warning",
128 rateLimitType: "seven_day",
129 utilization: 0.85,
130 resetsAt: 1_784_000_000,
131 },
132 noNames,
133 ),
134 ).toEqual({
135 windows: [
136 {
137 id: "seven_day",
138 kind: "weekly",
139 label: "Weekly",
140 usedPercent: 85,
141 windowDurationMins: 10080,
142 resetsAt: "2026-07-14T03:33:20.000Z",
143 },
144 ],
145 });
146 });
147
148 it("lands the streamed overage-included bucket on the row the probe named", () => {
149 const event = {
150 status: "allowed",
151 rateLimitType: "seven_day_overage_included" as never,
152 utilization: 0.4,
153 } as const;
154 // No probe has named the bucket yet: guessing would open a stray row.
155 expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined();
156 expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({
157 windows: [
158 {
159 id: "seven_day_fable",
160 kind: "weekly",
161 label: "Weekly · Fable",
162 usedPercent: 40,
163 windowDurationMins: 10080,
164 },
165 ],
166 });
167 });
168
169 it("ignores windows the page does not render and events without a utilization", () => {
170 expect(
171 claudeRateLimitEventToUpdate(
172 { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 },
173 noNames,
174 ),
175 ).toBeUndefined();
176 expect(
177 claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames),
178 ).toBeUndefined();
179 });
180});
apps/server/src/provider/Layers/claudeUsageLimits.ts deleted-199
......@@ -1,199 +0,0 @@
1/**
2 * Claude Code subscription usage. Both sources produce windows with the same
3 * ids so a turn-driven `rate_limit_event` lands on the row the SDK's
4 * `get_usage` read established:
5 *
6 * - `get_usage` (on demand, during the capabilities probe) reports every
7 * window at once as 0–100 percentages with ISO reset times.
8 * - `rate_limit_event` (streamed during a turn) names one window at a time
9 * with a 0–1 utilization fraction and an epoch-seconds reset.
10 *
11 * @module provider/Layers/claudeUsageLimits
12 */
13import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
14import type {
15 ProviderUsageLimitsUpdate,
16 ServerProviderUsageLimits,
17 ServerProviderUsageWindow,
18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Ref from "effect/Ref";
23
24import {
25 clampPercent,
26 makeUnavailableUsageLimits,
27 makeUsageLimits,
28} from "../providerUsageLimits.ts";
29
30const SESSION_MINS = 5 * 60;
31const WEEK_MINS = 7 * 24 * 60;
32
33/**
34 * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped
35 * weeklies are additive on top of these: the CLI reports them under
36 * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included
37 * model bucket (Fable today) as `seven_day_overage_included`.
38 */
39const WINDOWS: Readonly<
40 Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">>
41> = {
42 five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS },
43 seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS },
44};
45
46/**
47 * The streamed event names the overage-included bucket by type
48 * (`seven_day_overage_included`), while `get_usage` names it by the model's
49 * `display_name`. Which model that is changes over time, so the probe records
50 * the name it saw and the event mapper reuses it; the mid-turn update then
51 * lands on the row the probe drew instead of opening a second one.
52 */
53const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included";
54
55export interface ClaudeScopedLimitNames {
56 readonly overageIncluded: string | undefined;
57}
58
59export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
60 overageIncluded: undefined,
61});
62
63function scopedWindowId(displayName: string): string {
64 return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
65}
66
67function scopedWindow(
68 displayName: string,
69 usedPercent: number,
70 resetsAt: string | undefined,
71): ServerProviderUsageWindow {
72 return {
73 id: scopedWindowId(displayName),
74 kind: "weekly",
75 label: `Weekly · ${displayName}`,
76 windowDurationMins: WEEK_MINS,
77 usedPercent: clampPercent(usedPercent),
78 ...(resetsAt ? { resetsAt } : {}),
79 };
80}
81
82/**
83 * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is
84 * read structurally until the `.d.ts` catches up.
85 */
86interface ModelScopedWindow {
87 readonly display_name: string;
88 readonly utilization: number | null;
89 readonly resets_at: string | null;
90}
91
92function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> {
93 const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped;
94 if (!Array.isArray(raw)) return [];
95 return raw.filter(
96 (entry): entry is ModelScopedWindow =>
97 typeof entry === "object" &&
98 entry !== null &&
99 typeof (entry as ModelScopedWindow).display_name === "string",
100 );
101}
102
103function isoFromEpochSeconds(value: number | undefined): string | undefined {
104 if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined;
105 const dt = DateTime.make(value * 1000);
106 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
107}
108
109function isoFromString(value: string | null | undefined): string | undefined {
110 if (!value) return undefined;
111 const dt = DateTime.make(value);
112 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
113}
114
115function makeWindow(
116 id: keyof typeof WINDOWS & string,
117 usedPercent: number,
118 resetsAt: string | undefined,
119): ServerProviderUsageWindow {
120 const window = WINDOWS[id]!;
121 return {
122 id,
123 ...window,
124 usedPercent: clampPercent(usedPercent),
125 ...(resetsAt ? { resetsAt } : {}),
126 };
127}
128
129/**
130 * Utilization is a 0–1 fraction on the streamed event. An overage-included
131 * event before any probe has named the bucket is dropped: guessing a name
132 * would draw a row the next probe cannot reconcile.
133 */
134export function claudeRateLimitEventToUpdate(
135 info: SDKRateLimitInfo,
136 names: ClaudeScopedLimitNames,
137): ProviderUsageLimitsUpdate | undefined {
138 const type: string | undefined = info.rateLimitType;
139 if (!type || typeof info.utilization !== "number") {
140 return undefined;
141 }
142 const usedPercent = info.utilization * 100;
143 const resetsAt = isoFromEpochSeconds(info.resetsAt);
144 if (type in WINDOWS) {
145 return { windows: [makeWindow(type, usedPercent, resetsAt)] };
146 }
147 if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) {
148 return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] };
149 }
150 return undefined;
151}
152
153/**
154 * Percentages on the `get_usage` response are already 0–100. Also yields the
155 * scoped-bucket names the response carried, for the event mapper to reuse.
156 */
157export function claudeUsageResponseToLimits(input: {
158 readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
159 readonly checkedAt: string;
160}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
161 const { response, checkedAt } = input;
162 if (!response.rate_limits_available || !response.rate_limits) {
163 return {
164 limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }),
165 names: { overageIncluded: undefined },
166 };
167 }
168 const windows: ServerProviderUsageWindow[] = [];
169 for (const id of Object.keys(WINDOWS)) {
170 const window = response.rate_limits[id as "five_hour" | "seven_day"];
171 if (!window || typeof window.utilization !== "number") continue;
172 windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at)));
173 }
174 // The CLI filters `model_scoped` to the overage-included allowlist, which
175 // today holds one model; the first entry is the one the event refers to.
176 let overageIncluded: string | undefined;
177 for (const entry of readModelScoped(response.rate_limits)) {
178 if (typeof entry.utilization !== "number") continue;
179 windows.push(
180 scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)),
181 );
182 // Only a bucket that drew a row may receive events; naming one that was
183 // skipped would let a mid-turn event open a row the probe never showed.
184 overageIncluded ??= entry.display_name;
185 }
186 return {
187 limits: makeUsageLimits({ checkedAt, windows }),
188 names: { overageIncluded },
189 };
190}
191
192/** Probe-side helper: map the response and remember the scoped names for events. */
193export const recordClaudeUsageResponse = (
194 namesRef: Ref.Ref<ClaudeScopedLimitNames>,
195 input: Parameters<typeof claudeUsageResponseToLimits>[0],
196): Effect.Effect<ServerProviderUsageLimits> => {
197 const { limits, names } = claudeUsageResponseToLimits(input);
198 return Ref.set(namesRef, names).pipe(Effect.as(limits));
199};
apps/server/src/provider/Layers/codexLaunchArgs.test.ts deleted-59
......@@ -1,59 +0,0 @@
1import * as NodeAssert from "node:assert/strict";
2
3import { describe, it } from "vite-plus/test";
4
5import {
6 codexAppServerArgs,
7 codexExecLaunchArgs,
8 resolveCodexLaunchArgs,
9} from "./codexLaunchArgs.ts";
10
11describe("resolveCodexLaunchArgs", () => {
12 it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => {
13 NodeAssert.equal(
14 resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }),
15 "--enable foo",
16 );
17 });
18
19 it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => {
20 NodeAssert.equal(
21 resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }),
22 "--strict-config",
23 );
24 });
25
26 it("ignores whitespace-only environment values", () => {
27 NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), "");
28 });
29});
30
31describe("codexAppServerArgs", () => {
32 it("returns the app-server command for empty launch args", () => {
33 NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]);
34 });
35
36 it("appends parsed launch args after app-server", () => {
37 NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [
38 "app-server",
39 "--strict-config",
40 "--enable",
41 "foo",
42 ]);
43 });
44});
45
46describe("codexExecLaunchArgs", () => {
47 it("keeps shared codex flags and omits app-server-only flags", () => {
48 NodeAssert.deepStrictEqual(
49 codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'),
50 ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"],
51 );
52 });
53
54 it("does not pair value-taking flags with adjacent flags", () => {
55 NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [
56 "--strict-config",
57 ]);
58 });
59});
apps/server/src/provider/Layers/codexLaunchArgs.ts deleted-39
......@@ -1,39 +0,0 @@
1import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
2
3const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS";
4
5export const resolveCodexLaunchArgs = (
6 launchArgs?: string,
7 environment: NodeJS.ProcessEnv = process.env,
8) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || "";
9
10const codexLaunchArgv = (launchArgs?: string): ReadonlyArray<string> => tokenizeCliArgs(launchArgs);
11
12export const codexAppServerArgs = (launchArgs?: string) => [
13 "app-server",
14 ...codexLaunchArgv(launchArgs),
15];
16
17export const codexExecLaunchArgs = (launchArgs?: string) => {
18 const args = codexLaunchArgv(launchArgs);
19 const execArgs: Array<string> = [];
20
21 for (let index = 0; index < args.length; index++) {
22 const arg = args[index];
23 if (arg === undefined) continue;
24
25 if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) {
26 execArgs.push(arg);
27 } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") {
28 const value = args[index + 1];
29 if (value !== undefined && !value.startsWith("-")) {
30 execArgs.push(arg, value);
31 index++;
32 }
33 } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) {
34 execArgs.push(arg);
35 }
36 }
37
38 return execArgs;
39};
apps/server/src/provider/Layers/codexUsageLimits.test.ts deleted-316
......@@ -1,316 +0,0 @@
1import * as CodexErrors from "effect-codex-app-server/errors";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 codexRateLimitsFailureMessage,
6 codexRateLimitsToLimits,
7 codexRateLimitsToUpdate,
8 codexResetCreditsToContract,
9 codexUsageLimitMessage,
10 mergeCodexRateLimits,
11} from "./codexUsageLimits.ts";
12
13const checkedAt = "2026-07-18T10:00:00.000Z";
14
15describe("codexRateLimitsToLimits", () => {
16 it("maps primary and secondary onto the session and weekly windows", () => {
17 expect(
18 codexRateLimitsToLimits({
19 checkedAt,
20 snapshot: {
21 planType: "plus",
22 primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 },
23 secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 },
24 },
25 }),
26 ).toEqual({
27 checkedAt,
28 windows: [
29 {
30 id: "primary",
31 kind: "session",
32 label: "Session",
33 usedPercent: 12,
34 windowDurationMins: 300,
35 resetsAt: "2026-07-14T03:33:20.000Z",
36 },
37 {
38 id: "secondary",
39 kind: "weekly",
40 label: "Weekly",
41 usedPercent: 47,
42 windowDurationMins: 10080,
43 resetsAt: "2026-07-19T22:26:40.000Z",
44 },
45 ],
46 });
47 });
48
49 it("treats a lone duration-less primary as monthly on Free and Go", () => {
50 expect(
51 codexRateLimitsToLimits({
52 checkedAt,
53 snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } },
54 }).windows,
55 ).toEqual([
56 {
57 id: "primary",
58 kind: "monthly",
59 label: "Monthly",
60 usedPercent: 80,
61 windowDurationMins: 43_200,
62 },
63 ]);
64 });
65
66 it("selects the main Codex allowance and leaves Spark out", () => {
67 const spark = {
68 limitId: "codex_bengalfox",
69 primary: { usedPercent: 0, windowDurationMins: 300 },
70 secondary: { usedPercent: 90, windowDurationMins: 10080 },
71 };
72 expect(
73 codexRateLimitsToLimits({
74 checkedAt,
75 snapshot: spark,
76 rateLimitsByLimitId: {
77 codex_bengalfox: spark,
78 codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } },
79 },
80 }).windows,
81 ).toEqual([
82 {
83 id: "secondary",
84 kind: "weekly",
85 label: "Weekly",
86 usedPercent: 42,
87 windowDurationMins: 10080,
88 },
89 ]);
90 });
91
92 it.each([undefined, null, {}])(
93 "supports legacy reads with no bucket map: %j",
94 (rateLimitsByLimitId) => {
95 const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } };
96 expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual(
97 codexRateLimitsToLimits({ checkedAt, snapshot }),
98 );
99 },
100 );
101
102 it("does not show a model-specific legacy snapshot as the main allowance", () => {
103 expect(
104 codexRateLimitsToLimits({
105 checkedAt,
106 snapshot: {
107 limitId: "codex_bengalfox",
108 secondary: { usedPercent: 90 },
109 },
110 }).windows,
111 ).toEqual([]);
112 });
113});
114
115describe("codexRateLimitsToUpdate", () => {
116 it("carries only the windows the notification names", () => {
117 expect(
118 codexRateLimitsToUpdate({
119 secondary: { usedPercent: 51, windowDurationMins: 10080 },
120 }),
121 ).toEqual({
122 windows: [
123 {
124 id: "secondary",
125 kind: "weekly",
126 label: "Weekly",
127 usedPercent: 51,
128 windowDurationMins: 10080,
129 },
130 ],
131 });
132 expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined();
133 });
134
135 it("ignores Spark notifications so they cannot overwrite the main allowance", () => {
136 expect(
137 codexRateLimitsToUpdate({
138 limitId: "codex_bengalfox",
139 primary: { usedPercent: 0, windowDurationMins: 300 },
140 secondary: { usedPercent: 90, windowDurationMins: 10080 },
141 }),
142 ).toBeUndefined();
143 expect(
144 codexRateLimitsToUpdate({
145 limitId: "codex",
146 secondary: { usedPercent: 42, windowDurationMins: 10080 },
147 })?.windows,
148 ).toEqual([
149 {
150 id: "secondary",
151 kind: "weekly",
152 label: "Weekly",
153 usedPercent: 42,
154 windowDurationMins: 10080,
155 },
156 ]);
157 });
158});
159
160describe("codexRateLimitsFailureMessage", () => {
161 it("keeps the JSON-RPC code and nothing else from a request failure", () => {
162 expect(
163 codexRateLimitsFailureMessage(
164 new CodexErrors.CodexAppServerRequestError({
165 code: -32603,
166 errorMessage:
167 "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized",
168 }),
169 ),
170 ).toBe("Codex could not read usage (JSON-RPC -32603).");
171 });
172
173 it("phrases a dead process differently from a bad answer", () => {
174 expect(
175 codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })),
176 ).toBe("Codex exited before it could report usage.");
177 });
178});
179
180describe("codexResetCreditsToContract", () => {
181 it("counts available credits and reports the soonest expiry", () => {
182 expect(
183 codexResetCreditsToContract({
184 availableCount: 2,
185 credits: [
186 { status: "available", expiresAt: 1_784_500_000 },
187 { status: "redeemed", expiresAt: 1_700_000_000 },
188 { status: "available", expiresAt: 1_784_000_000 },
189 ],
190 }),
191 ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" });
192 expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 });
193 expect(codexResetCreditsToContract(null)).toBeUndefined();
194 });
195
196 it("rides along on the probe's limits", () => {
197 expect(
198 codexRateLimitsToLimits({
199 checkedAt,
200 snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } },
201 resetCredits: { availableCount: 1 },
202 }).resetCredits,
203 ).toEqual({ availableCount: 1 });
204 });
205});
206
207describe("codexUsageLimitMessage", () => {
208 const at = "2026-01-01T00:00:00.000Z";
209 const atSeconds = Date.parse(at) / 1000;
210
211 it("names the exhausted window and the workspace's missing credits", () => {
212 expect(
213 codexUsageLimitMessage(
214 {
215 limitId: "codex",
216 rateLimitReachedType: "workspace_owner_credits_depleted",
217 primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 },
218 secondary: {
219 usedPercent: 100,
220 resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600,
221 windowDurationMins: 10_080,
222 },
223 },
224 at,
225 ),
226 ).toBe(
227 "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.",
228 );
229 });
230
231 it("points a reached spend cap at the workspace owner", () => {
232 expect(
233 codexUsageLimitMessage(
234 {
235 limitId: "codex",
236 rateLimitReachedType: "workspace_member_usage_limit_reached",
237 primary: {
238 usedPercent: 100,
239 resetsAt: atSeconds + 3 * 3_600 + 20 * 60,
240 windowDurationMins: 300,
241 },
242 },
243 at,
244 ),
245 ).toBe(
246 "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.",
247 );
248 });
249
250 it("names no window when credits run out without one", () => {
251 expect(
252 codexUsageLimitMessage(
253 { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" },
254 at,
255 ),
256 ).toBe(
257 "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.",
258 );
259 });
260
261 it("says only what it knows without a snapshot", () => {
262 expect(codexUsageLimitMessage(undefined, at)).toBe(
263 "Codex usage limit reached. Send the message again once the limit resets.",
264 );
265 });
266});
267
268describe("mergeCodexRateLimits", () => {
269 it("keeps windows an update does not carry", () => {
270 const merged = mergeCodexRateLimits(
271 {
272 limitId: "codex",
273 planType: "business",
274 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
275 },
276 { rateLimitReachedType: "rate_limit_reached" },
277 );
278
279 expect(merged).toEqual({
280 limitId: "codex",
281 planType: "business",
282 rateLimitReachedType: "rate_limit_reached",
283 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
284 });
285 });
286
287 it("ignores a model-specific snapshot so it cannot replace the main allowance", () => {
288 const main = {
289 limitId: "codex",
290 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
291 };
292 expect(
293 mergeCodexRateLimits(main, {
294 limitId: "spark",
295 primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 },
296 }),
297 ).toBe(main);
298 });
299});
300
301import { codexUsageLimitResetAt } from "./codexUsageLimits.ts";
302
303describe("codexUsageLimitResetAt", () => {
304 it("waits for every exhausted window and never invents an unknown reset", () => {
305 expect(
306 codexUsageLimitResetAt({
307 primary: { usedPercent: 100, resetsAt: 2000000000 },
308 secondary: { usedPercent: 100, resetsAt: 2000100000 },
309 }),
310 ).toBe("2033-05-19T07:20:00.000Z");
311 expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull();
312 expect(
313 codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }),
314 ).toBeNull();
315 });
316});
apps/server/src/provider/Layers/codexUsageLimits.ts deleted-250
......@@ -1,250 +0,0 @@
1/**
2 * Codex subscription usage. The `account/rateLimits/read` response and the
3 * `account/rateLimits/updated` notification carry the same snapshot shape, so
4 * one mapper serves the status probe and the turn-driven update; both emit
5 * windows with the same ids so they merge onto the same rows.
6 *
7 * @module provider/Layers/codexUsageLimits
8 */
9import type {
10 ProviderUsageLimitsUpdate,
11 ServerProviderResetCredits,
12 ServerProviderUsageLimits,
13 ServerProviderUsageWindow,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import * as Option from "effect/Option";
17import type * as CodexErrors from "effect-codex-app-server/errors";
18
19import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts";
20
21interface CodexRateLimitWindow {
22 readonly usedPercent: number;
23 readonly resetsAt?: number | null;
24 readonly windowDurationMins?: number | null;
25}
26
27/** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */
28export interface CodexRateLimitSnapshot {
29 readonly limitId?: string | null;
30 readonly planType?: string | null;
31 readonly rateLimitReachedType?: string | null;
32 readonly primary?: CodexRateLimitWindow | null;
33 readonly secondary?: CodexRateLimitWindow | null;
34}
35
36/** Structural view of the read response's `rateLimitResetCredits`. */
37export interface CodexResetCreditsSummary {
38 readonly availableCount: number;
39 readonly credits?: ReadonlyArray<{
40 readonly status: string;
41 readonly expiresAt?: number | null;
42 }> | null;
43}
44
45const SESSION_MINS = 5 * 60;
46const WEEK_MINS = 7 * 24 * 60;
47const MONTH_MINS = 30 * 24 * 60;
48
49function isoFromEpochSeconds(value: number | null | undefined): string | undefined {
50 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
51 const dt = DateTime.make(value * 1000);
52 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
53}
54
55function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] {
56 if (mins >= MONTH_MINS) return "monthly";
57 if (mins >= WEEK_MINS) return "weekly";
58 return "session";
59}
60
61function labelForKind(kind: ServerProviderUsageWindow["kind"]): string {
62 return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly";
63}
64
65/**
66 * `primary` / `secondary` are positions, not durations. Codex usually sends
67 * `windowDurationMins`; when it does not, paid plans expose the 5-hour and
68 * weekly pair and Free/Go expose one monthly allowance.
69 */
70function codexRateLimitsToWindows(
71 snapshot: CodexRateLimitSnapshot,
72): ReadonlyArray<ServerProviderUsageWindow> {
73 // Show the main allowance only. Model-specific notifications (such as Spark)
74 // must not replace its primary/secondary rows. Older CLIs omit the limit id.
75 if (snapshot.limitId && snapshot.limitId !== "codex") return [];
76 const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go";
77 const positions = [
78 ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS],
79 ["secondary", snapshot.secondary, WEEK_MINS],
80 ] as const;
81 const windows: ServerProviderUsageWindow[] = [];
82 for (const [id, window, fallbackMins] of positions) {
83 if (!window || !Number.isFinite(window.usedPercent)) continue;
84 const windowDurationMins =
85 typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins;
86 const kind = kindForDuration(windowDurationMins);
87 const resetsAt = isoFromEpochSeconds(window.resetsAt);
88 windows.push({
89 id,
90 kind,
91 label: labelForKind(kind),
92 usedPercent: clampPercent(window.usedPercent),
93 windowDurationMins,
94 ...(resetsAt ? { resetsAt } : {}),
95 });
96 }
97 return windows;
98}
99
100export function codexResetCreditsToContract(
101 summary: CodexResetCreditsSummary | null | undefined,
102): ServerProviderResetCredits | undefined {
103 if (!summary) return undefined;
104 const expiries = (summary.credits ?? [])
105 .filter((credit) => credit.status === "available")
106 .map((credit) => credit.expiresAt)
107 .filter((value): value is number => typeof value === "number");
108 const nextExpiresAt =
109 expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined;
110 return {
111 availableCount: Math.max(0, summary.availableCount),
112 ...(nextExpiresAt ? { nextExpiresAt } : {}),
113 };
114}
115
116export function codexRateLimitsToLimits(input: {
117 readonly snapshot: CodexRateLimitSnapshot;
118 readonly rateLimitsByLimitId?:
119 | Readonly<Record<string, CodexRateLimitSnapshot>>
120 | null
121 | undefined;
122 readonly resetCredits?: CodexResetCreditsSummary | null | undefined;
123 readonly checkedAt: string;
124}): ServerProviderUsageLimits {
125 const resetCredits = codexResetCreditsToContract(input.resetCredits);
126 // Select the main bucket explicitly; the legacy snapshot can name another limit.
127 const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot);
128 return {
129 ...makeUsageLimits({
130 checkedAt: input.checkedAt,
131 windows,
132 }),
133 ...(resetCredits ? { resetCredits } : {}),
134 };
135}
136
137export function codexRateLimitsToUpdate(
138 snapshot: CodexRateLimitSnapshot,
139): ProviderUsageLimitsUpdate | undefined {
140 const windows = codexRateLimitsToWindows(snapshot);
141 return windows.length > 0 ? { windows } : undefined;
142}
143
144/**
145 * A bounded, client-safe reason for a failed `account/rateLimits/read`. The
146 * raw error is for the log; only the category and, for a JSON-RPC failure,
147 * the code reach the Limits view.
148 */
149export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string {
150 switch (error._tag) {
151 case "CodexAppServerRequestError":
152 return `Codex could not read usage (JSON-RPC ${error.code}).`;
153 case "CodexAppServerSpawnError":
154 return "Codex could not be started to read usage.";
155 case "CodexAppServerProcessExitedError":
156 return "Codex exited before it could report usage.";
157 default:
158 return "Codex did not answer the usage request.";
159 }
160}
161
162/**
163 * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a
164 * field the update omits keeps the value observed earlier in the session, so a
165 * later notification that only names the limit it reached must not drop the
166 * windows an earlier one carried.
167 */
168export function mergeCodexRateLimits(
169 previous: CodexRateLimitSnapshot | undefined,
170 update: CodexRateLimitSnapshot,
171): CodexRateLimitSnapshot | undefined {
172 // Model-specific snapshots (such as Spark) describe a different allowance
173 // and must not overwrite the main one, the same rule the usage rows apply.
174 if (update.limitId && update.limitId !== "codex") return previous;
175 if (!previous) return update;
176 return {
177 ...previous,
178 ...(update.limitId !== undefined ? { limitId: update.limitId } : {}),
179 ...(update.planType !== undefined ? { planType: update.planType } : {}),
180 ...(update.rateLimitReachedType !== undefined
181 ? { rateLimitReachedType: update.rateLimitReachedType }
182 : {}),
183 ...(update.primary !== undefined ? { primary: update.primary } : {}),
184 ...(update.secondary !== undefined ? { secondary: update.secondary } : {}),
185 };
186}
187
188/** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */
189function formatCodexUsageLimitWait(waitMs: number): string {
190 const totalMinutes = Math.ceil(waitMs / 60_000);
191 const days = Math.floor(totalMinutes / (24 * 60));
192 const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
193 const minutes = totalMinutes % 60;
194 if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`;
195 if (hours === 0) return `${totalMinutes}m`;
196 return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
197}
198
199function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string {
200 switch (rateLimitReachedType) {
201 case "workspace_owner_credits_depleted":
202 case "workspace_member_credits_depleted":
203 return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.";
204 case "workspace_owner_usage_limit_reached":
205 case "workspace_member_usage_limit_reached":
206 return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.";
207 default:
208 return " Send the message again once the limit resets.";
209 }
210}
211
212/**
213 * The message a usage-limit stop shows instead of the provider sentence, which
214 * on a Business workspace blames credits for a window that simply ran out. The
215 * window named is the exhausted one that has yet to reset, latest first; `atIso`
216 * is the stopping event's timestamp, not the wall clock.
217 */
218export function codexUsageLimitMessage(
219 snapshot: CodexRateLimitSnapshot | undefined,
220 atIso: string,
221): string {
222 const atMs = Date.parse(atIso);
223 const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : [];
224 let reset = "";
225 let latestResetMs = Number.NEGATIVE_INFINITY;
226 for (const window of windows) {
227 if (window.usedPercent < 100 || !window.resetsAt) continue;
228 const resetMs = Date.parse(window.resetsAt);
229 if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue;
230 latestResetMs = resetMs;
231 reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`;
232 }
233 return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`;
234}
235
236/** All exhausted windows must reset before a continuation can run. */
237export function codexUsageLimitResetAt(
238 snapshot: CodexRateLimitSnapshot | undefined,
239): string | null {
240 if (!snapshot) return null;
241 const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100);
242 if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null;
243 return windows.reduce<string | null>(
244 (latest, window) =>
245 latest === null || Date.parse(window.resetsAt!) > Date.parse(latest)
246 ? window.resetsAt!
247 : latest,
248 null,
249 );
250}
apps/server/src/provider/Layers/cursorUsageLimits.test.ts deleted-264
......@@ -1,264 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { NodeServices } from "@effect/platform-node";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { HttpClient, HttpClientResponse } from "effect/http";
8import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts";
9
10const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
11 effect.pipe(Effect.provide(NodeServices.layer));
12
13describe("Cursor usage limits", () => {
14 const checkedAt = "2026-09-16T00:00:00.000Z";
15
16 it("uses the advertised percentages and billing-cycle reset", () => {
17 const limits = cursorUsageResponseToLimits(
18 {
19 billingCycleEnd: "1789876386000",
20 planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 },
21 },
22 checkedAt,
23 );
24 expect(limits.windows).toEqual(
25 expect.arrayContaining([
26 {
27 id: "totalPercentUsed",
28 kind: "monthly",
29 label: "Overall",
30 usedPercent: 72.4,
31 resetsAt: "2026-09-20T03:53:06.000Z",
32 },
33 {
34 id: "autoPercentUsed",
35 kind: "monthly",
36 label: "Cursor Models",
37 usedPercent: 69.5,
38 resetsAt: "2026-09-20T03:53:06.000Z",
39 },
40 {
41 id: "apiPercentUsed",
42 kind: "monthly",
43 label: "Other Models",
44 usedPercent: 100,
45 resetsAt: "2026-09-20T03:53:06.000Z",
46 },
47 ]),
48 );
49 });
50
51 it("does not invent unused allowance for absent buckets", () => {
52 expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe(
53 "unsupported",
54 );
55 expect(
56 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,
57 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]);
58 expect(
59 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,
60 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]);
61 });
62
63 it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () =>
64 Effect.gen(function* () {
65 yield* withNodeServices(
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const directory = yield* fs.makeTempDirectoryScoped();
70 yield* fs.makeDirectory(path.join(directory, "cursor"));
71 yield* fs.writeFileString(
72 path.join(directory, "cursor", "auth.json"),
73 '{"accessToken":"instance-token"}',
74 );
75 const client = HttpClient.make((request) => {
76 expect(request.url).toBe(
77 "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
78 );
79 expect(request.method).toBe("POST");
80 expect(request.headers.authorization).toBe("Bearer instance-token");
81 expect(request.headers["connect-protocol-version"]).toBe("1");
82 return Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }),
86 ),
87 );
88 });
89 yield* fs.makeDirectory(path.join(directory, ".cursor"));
90 yield* fs.writeFileString(
91 path.join(directory, ".cursor", "auth.json"),
92 '{"accessToken":"instance-token"}',
93 );
94 for (const platform of ["linux", "darwin"] as const) {
95 const limits = yield* readCursorUsageLimits(
96 { apiEndpoint: "https://cursor.example/" },
97 { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" },
98 ).pipe(
99 Effect.provideService(HostProcessPlatform, platform),
100 Effect.provideService(HttpClient.HttpClient, client),
101 );
102 expect(limits.windows[0]?.usedPercent).toBe(42);
103 }
104 }).pipe(Effect.scoped),
105 );
106 }),
107 );
108
109 it.effect(
110 "never reads stale files for keychain or memory logins, but accepts an explicit auth token",
111 () =>
112 Effect.gen(function* () {
113 for (const platform of ["linux", "darwin"] as const) {
114 for (const token of [undefined, "explicit-token"]) {
115 const limits = yield* withNodeServices(
116 readCursorUsageLimits(
117 { apiEndpoint: "" },
118 {
119 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",
120 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
121 },
122 false,
123 async () => {
124 throw new Error("must not read Keychain before opt-in");
125 },
126 ).pipe(
127 Effect.provideService(HostProcessPlatform, platform),
128 Effect.provideService(
129 FileSystem.FileSystem,
130 FileSystem.makeNoop({
131 readFileString: () => Effect.die("must not read an unrelated credential file"),
132 }),
133 ),
134 Effect.provideService(
135 HttpClient.HttpClient,
136 HttpClient.make((request) => {
137 expect(token).toBe("explicit-token");
138 expect(request.headers.authorization).toBe("Bearer explicit-token");
139 return Effect.succeed(
140 HttpClientResponse.fromWeb(
141 request,
142 Response.json({ planUsage: { totalPercentUsed: 10 } }),
143 ),
144 );
145 }),
146 ),
147 ),
148 );
149 if (token) expect(limits.windows[0]?.usedPercent).toBe(10);
150 else expect(limits.unavailable?.reason).toBe("unsupported");
151 }
152 }
153 }),
154 );
155
156 it.effect("reports failed requests without exposing credentials or response bodies", () =>
157 Effect.gen(function* () {
158 const limits = yield* withNodeServices(
159 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(
160 Effect.provideService(
161 HttpClient.HttpClient,
162 HttpClient.make((request) =>
163 Effect.succeed(
164 HttpClientResponse.fromWeb(
165 request,
166 new Response("private response", { status: 401 }),
167 ),
168 ),
169 ),
170 ),
171 ),
172 );
173 expect(limits.unavailable).toEqual({
174 reason: "probeFailed",
175 message: "Cursor could not read usage limits.",
176 });
177 }),
178 );
179
180 it.effect("does not use a stored login for an explicit API key", () =>
181 Effect.gen(function* () {
182 const limits = yield* withNodeServices(
183 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe(
184 Effect.provideService(
185 HttpClient.HttpClient,
186 HttpClient.make(() => Effect.die("must not request usage")),
187 ),
188 ),
189 );
190 expect(limits.unavailable?.reason).toBe("unsupported");
191 }),
192 );
193
194 it.effect("reads the default macOS Cursor login from Keychain for limits", () =>
195 Effect.gen(function* () {
196 const limits = yield* withNodeServices(
197 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe(
198 Effect.provideService(HostProcessPlatform, "darwin"),
199 Effect.provideService(
200 FileSystem.FileSystem,
201 FileSystem.makeNoop({
202 readFileString: () => Effect.die("must not read a stale credential file"),
203 }),
204 ),
205 Effect.provideService(
206 HttpClient.HttpClient,
207 HttpClient.make((request) => {
208 expect(request.headers.authorization).toBe("Bearer keychain-token");
209 return Effect.succeed(
210 HttpClientResponse.fromWeb(
211 request,
212 Response.json({ planUsage: { totalPercentUsed: 42 } }),
213 ),
214 );
215 }),
216 ),
217 ),
218 );
219 expect(limits.windows[0]?.usedPercent).toBe(42);
220 }),
221 );
222
223 it.effect("reports a Keychain initialization failure without failing the provider refresh", () =>
224 Effect.gen(function* () {
225 const limits = yield* withNodeServices(
226 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => {
227 throw new Error("Keychain initialization failed");
228 }).pipe(
229 Effect.provideService(HostProcessPlatform, "darwin"),
230 Effect.provideService(
231 HttpClient.HttpClient,
232 HttpClient.make(() => Effect.die("must not request limits without a login")),
233 ),
234 ),
235 );
236 expect(limits.unavailable?.reason).toBe("probeFailed");
237 }),
238 );
239
240 it.effect("does not read Keychain or send its token to a custom endpoint", () =>
241 Effect.gen(function* () {
242 for (const [apiEndpoint, environment] of [
243 ["http://localhost:3000", {}],
244 ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }],
245 ["https://cursor-proxy.example", {}],
246 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],
247 ] as const) {
248 const limits = yield* withNodeServices(
249 readCursorUsageLimits({ apiEndpoint }, environment, true, async () => {
250 throw new Error("must not read Keychain for a custom endpoint");
251 }).pipe(
252 Effect.provideService(HostProcessPlatform, "darwin"),
253 Effect.provideService(
254 HttpClient.HttpClient,
255 HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")),
256 ),
257 ),
258 );
259 expect(limits.unavailable?.reason).toBe("unsupported");
260 expect(limits.unavailable?.message).toContain("default Cursor endpoint");
261 }
262 }),
263 );
264});
apps/server/src/provider/Layers/cursorUsageLimits.ts deleted-151
......@@ -1,151 +0,0 @@
1import * as NodeOS from "node:os";
2import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12import {
13 clampPercent,
14 makeUnavailableUsageLimits,
15 makeUsageLimits,
16} from "../providerUsageLimits.ts";
17import { readMacCursorAccessToken } from "../cursorKeychainToken.ts";
18
19const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
20const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
21const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
22const CursorUsageResponse = Schema.Struct({
23 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
24 planUsage: Schema.optional(
25 Schema.Struct({
26 totalPercentUsed: Schema.optional(Schema.Number),
27 autoPercentUsed: Schema.optional(Schema.Number),
28 apiPercentUsed: Schema.optional(Schema.Number),
29 }),
30 ),
31});
32
33/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */
34export function cursorUsageResponseToLimits(
35 response: typeof CursorUsageResponse.Type,
36 checkedAt: string,
37) {
38 const reset = DateTime.make(Number(response.billingCycleEnd));
39 const resetsAt =
40 Number(response.billingCycleEnd) > 0 && Option.isSome(reset)
41 ? DateTime.formatIso(reset.value)
42 : undefined;
43 const windows: ServerProviderUsageWindow[] = [];
44 if (response.planUsage) {
45 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
46 const usedPercent = response.planUsage[id];
47 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
48 windows.push({
49 id,
50 kind: "monthly",
51 label,
52 usedPercent: clampPercent(usedPercent),
53 ...(resetsAt ? { resetsAt } : {}),
54 });
55 }
56 }
57 return windows.length > 0
58 ? makeUsageLimits({ checkedAt, windows })
59 : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
60}
61
62export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
63 settings: Pick<CursorSettings, "apiEndpoint">,
64 environment: NodeJS.ProcessEnv = process.env,
65 allowKeychain = false,
66 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
67) {
68 const checkedAt = DateTime.formatIso(yield* DateTime.now);
69 return yield* Effect.gen(function* () {
70 const fs = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
72 const platform = yield* HostProcessPlatform;
73 const endpoint = (
74 settings.apiEndpoint?.trim() ||
75 environment.CURSOR_API_ENDPOINT?.trim() ||
76 DEFAULT_CURSOR_API_ENDPOINT
77 ).replace(/\/$/, "");
78 let token = environment.CURSOR_AUTH_TOKEN?.trim();
79 // An explicit API key can name a different account from the stored login.
80 if (!token && environment.CURSOR_API_KEY?.trim()) {
81 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
82 }
83 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
84 if (!token && credentialStore === "memory") {
85 return makeUnavailableUsageLimits({
86 checkedAt,
87 reason: "unsupported",
88 message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.",
89 });
90 }
91 if (!token && platform === "darwin" && credentialStore !== "file") {
92 if (!allowKeychain) {
93 return makeUnavailableUsageLimits({
94 checkedAt,
95 reason: "unsupported",
96 message: "Enable Cursor account usage in T3 Code to read its Keychain login.",
97 });
98 }
99 if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) {
100 return makeUnavailableUsageLimits({
101 checkedAt,
102 reason: "unsupported",
103 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
104 });
105 }
106 token = (yield* Effect.tryPromise(keychainToken))?.trim();
107 } else if (!token) {
108 const home =
109 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();
110 const directory =
111 platform === "win32"
112 ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor")
113 : platform === "darwin"
114 ? path.join(home, ".cursor")
115 : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor");
116 const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe(
117 Effect.catchTags({
118 PlatformError: (error) =>
119 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
120 }),
121 Effect.flatMap(decodeCredentials),
122 );
123 token = credentials.accessToken?.trim();
124 }
125 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
126 const client = yield* HttpClient.HttpClient;
127 const response = yield* client.execute(
128 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
129 HttpClientRequest.bearerToken(token),
130 HttpClientRequest.setHeaders({
131 "connect-protocol-version": "1",
132 "x-cursor-client-type": "cli",
133 }),
134 HttpClientRequest.bodyJsonUnsafe({}),
135 ),
136 );
137 const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)(
138 yield* HttpClientResponse.filterStatusOk(response),
139 );
140 return cursorUsageResponseToLimits(body, checkedAt);
141 }).pipe(
142 Effect.timeout("10 seconds"),
143 Effect.orElseSucceed(() =>
144 makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Cursor could not read usage limits.",
148 }),
149 ),
150 );
151});
apps/server/src/provider/Layers/grokUsageLimits.ts deleted-175
......@@ -1,175 +0,0 @@
1import * as NodeOS from "node:os";
2import type { ServerProviderUsageWindow } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
10import {
11 clampPercent,
12 makeUnavailableUsageLimits,
13 makeUsageLimits,
14} from "../providerUsageLimits.ts";
15
16const GrokCredentials = Schema.Record(
17 Schema.String,
18 Schema.Struct({
19 key: Schema.optional(Schema.String),
20 auth_mode: Schema.optional(Schema.String),
21 email: Schema.optional(Schema.String),
22 }),
23);
24const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials));
25const GrokUsageResponse = Schema.Struct({
26 config: Schema.optional(
27 Schema.Struct({
28 creditUsagePercent: Schema.optional(Schema.Number),
29 currentPeriod: Schema.optional(
30 Schema.Struct({
31 type: Schema.optional(Schema.String),
32 end: Schema.optional(Schema.String),
33 }),
34 ),
35 }),
36 ),
37});
38
39export function grokUsageResponseToLimits(
40 response: typeof GrokUsageResponse.Type,
41 checkedAt: string,
42) {
43 const usedPercent = response.config?.creditUsagePercent;
44 if (usedPercent === undefined || !Number.isFinite(usedPercent)) {
45 // A billing read that succeeded but carries no percentage is an account
46 // with nothing metered yet, not one that can never report: xAI omits the
47 // field entirely (rather than sending 0) until usage registers, then fills
48 // it in. Calling that `unsupported` would strand the account — the Limits
49 // view drops unsupported entries and deliberately mutes their notice, so a
50 // freshly signed-in Grok account would vanish with no explanation until it
51 // happened to be used, and `applyUsageLimitsUpdate` would refuse the
52 // mid-turn windows that could have recovered it.
53 return makeUsageLimits({ checkedAt, windows: [] });
54 }
55 const period = response.config?.currentPeriod;
56 const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, "");
57 const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other";
58 const reset = period?.end ? DateTime.make(period.end) : Option.none();
59 const window: ServerProviderUsageWindow = {
60 id: "subscription",
61 kind,
62 label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription",
63 usedPercent: clampPercent(usedPercent),
64 ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}),
65 };
66 return makeUsageLimits({ checkedAt, windows: [window] });
67}
68
69/**
70 * The grok.com login the CLI uses by default, or undefined when the CLI is
71 * configured to pick another account, endpoint, or an API key.
72 */
73const readGrokCredential = Effect.fn("readGrokCredential")(function* (
74 environment: NodeJS.ProcessEnv,
75) {
76 // T3's ACP adapter explicitly selects API-key auth when this variable is set.
77 if (environment.XAI_API_KEY?.trim()) return undefined;
78 // Alternate auth deployments can select another scope or account from the same file.
79 if (
80 [
81 "GROK_OIDC_ISSUER",
82 "GROK_OIDC_CLIENT_ID",
83 "GROK_OAUTH2_ISSUER",
84 "GROK_OAUTH2_CLIENT_ID",
85 "GROK_OAUTH2_PRINCIPAL_TYPE",
86 "GROK_OAUTH2_PRINCIPAL_ID",
87 "GROK_AUTH_PROVIDER_COMMAND",
88 "GROK_LOCAL_AUTH",
89 "GROK_CLI_CHAT_PROXY_BASE_URL",
90 "GROK_MODELS_BASE_URL",
91 "GROK_CONFIG",
92 "GROK_CONFIG_PATH",
93 ].some((name) => environment[name]?.trim())
94 ) {
95 return undefined;
96 }
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const home =
100 environment.GROK_HOME?.trim() ||
101 path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok");
102 for (const configPath of [
103 path.join(home, "config.toml"),
104 path.join(home, "managed_config.toml"),
105 path.join(home, "requirements.toml"),
106 "/etc/grok/managed_config.toml",
107 "/etc/grok/requirements.toml",
108 ]) {
109 const config = yield* fs.readFileString(configPath).pipe(
110 Effect.catchTags({
111 PlatformError: (error) =>
112 error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
113 }),
114 );
115 // These sections can change the selected account or endpoint. Leave custom deployments to the CLI.
116 if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) {
117 return undefined;
118 }
119 }
120 const contents =
121 environment.GROK_AUTH?.trim() ||
122 (yield* fs.readFileString(path.join(home, "auth.json")).pipe(
123 Effect.catchTags({
124 PlatformError: (error) =>
125 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
126 }),
127 ));
128 const credentials = yield* decodeCredentials(contents);
129 // Never pick an arbitrary account from other deployments stored in the same file.
130 const credential =
131 credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ??
132 credentials["https://accounts.x.ai/sign-in"];
133 return credential?.auth_mode === "api_key" ? undefined : credential;
134});
135
136/**
137 * Reads the default grok.com login once and reports its usage limits along with
138 * its email, so the email always names the account whose quota was read.
139 */
140export const readGrokAccount = Effect.fn("readGrokAccount")(function* (
141 environment: NodeJS.ProcessEnv = process.env,
142) {
143 const checkedAt = DateTime.formatIso(yield* DateTime.now);
144 const probeFailed = makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Grok could not read usage limits.",
148 });
149 const credential = yield* Effect.option(
150 readGrokCredential(environment).pipe(Effect.timeout("10 seconds")),
151 );
152 if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed };
153 const email = credential.value?.email?.trim() || undefined;
154 const token = credential.value?.key?.trim();
155 if (!token) {
156 return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) };
157 }
158 // A failed quota request still knows which account it asked about.
159 const usageLimits = yield* Effect.gen(function* () {
160 const client = yield* HttpClient.HttpClient;
161 const response = yield* client.execute(
162 HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe(
163 HttpClientRequest.bearerToken(token),
164 ),
165 );
166 const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)(
167 yield* HttpClientResponse.filterStatusOk(response),
168 );
169 return grokUsageResponseToLimits(body, checkedAt);
170 }).pipe(
171 Effect.timeout("10 seconds"),
172 Effect.orElseSucceed(() => probeFailed),
173 );
174 return { email, usageLimits };
175});
apps/server/src/provider/Layers/openCodeUsageLimits.ts deleted-119
......@@ -1,119 +0,0 @@
1import * as NodeOS from "node:os";
2import * as NodeCrypto from "node:crypto";
3
4import type { ServerProviderUsageWindow } from "@t3tools/contracts";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12
13import {
14 clampPercent,
15 makeUnavailableUsageLimits,
16 makeUsageLimits,
17} from "../providerUsageLimits.ts";
18
19const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
20const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
21const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
22const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
23const UsageWindow = Schema.Struct({
24 percent: Schema.Finite,
25 resetsAt: Schema.DateTimeUtcFromString,
26});
27const UsageResponse = Schema.Struct({
28 usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
29});
30
31/** External OpenCode servers own their credentials; never read the host's account for them. */
32export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
33 readonly enabled: boolean;
34 readonly serverUrl: string;
35 readonly environment: NodeJS.ProcessEnv;
36}) {
37 const checkedAt = DateTime.formatIso(yield* DateTime.now);
38 const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
39 if (!input.enabled || input.serverUrl.trim()) return unsupported;
40
41 return yield* Effect.gen(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const env = input.environment;
45 const dataHome =
46 env.XDG_DATA_HOME ||
47 path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share");
48 const authPath = path.join(dataHome, "opencode", "auth.json");
49 const contents =
50 env.OPENCODE_AUTH_CONTENT ||
51 (yield* fs.readFileString(authPath).pipe(
52 Effect.catchTags({
53 PlatformError: (error) =>
54 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
55 }),
56 ));
57 const auth = yield* decodeAuthFile(contents);
58 const apiAuth = decodeApiAuth(auth["opencode-go"]);
59 // OpenCode overlays stored API credentials after environment credentials.
60 const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
61 if (!apiKey) return unsupported;
62
63 const client = yield* HttpClient.HttpClient;
64 const response = yield* client.execute(
65 HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
66 HttpClientRequest.bearerToken(apiKey),
67 ),
68 );
69 // A valid Zen key can exist without a Go subscription.
70 if (response.status === 403) return unsupported;
71 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
72 Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)),
73 );
74 const windows: ServerProviderUsageWindow[] = [
75 {
76 id: "go_rolling",
77 kind: "session",
78 label: "Go · Session",
79 windowDurationMins: 5 * 60,
80 usedPercent: clampPercent(body.usage.rolling.percent),
81 resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt),
82 },
83 {
84 id: "go_weekly",
85 kind: "weekly",
86 label: "Go · Weekly",
87 windowDurationMins: 7 * 24 * 60,
88 usedPercent: clampPercent(body.usage.weekly.percent),
89 resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt),
90 },
91 {
92 id: "go_monthly",
93 kind: "monthly",
94 label: "Go · Monthly",
95 usedPercent: clampPercent(body.usage.monthly.percent),
96 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
97 },
98 ];
99 return {
100 ...makeUsageLimits({ checkedAt, windows }),
101 // Go's usage response has no account ID. An unkeyed hash matches across
102 // environments without a shared secret. It permits offline guesses, but
103 // Go keys are randomly generated.
104 credentialFingerprint: NodeCrypto.createHash("sha256")
105 .update("opencode-go\0")
106 .update(apiKey)
107 .digest("hex"),
108 };
109 }).pipe(
110 Effect.timeout("5 seconds"),
111 Effect.orElseSucceed(() =>
112 makeUnavailableUsageLimits({
113 checkedAt,
114 reason: "probeFailed",
115 message: "OpenCode Go could not read usage.",
116 }),
117 ),
118 );
119});
apps/server/src/provider/Layers/piThinkingCapabilities.test.ts deleted-52
......@@ -1,52 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts";
4
5describe("thinkingCapabilitiesForPiModel", () => {
6 it("gives every choice its real level id and tags Pi's default", () => {
7 const capabilities = thinkingCapabilitiesForPiModel(
8 {
9 reasoning: true,
10 thinkingLevelMap: { off: null, xhigh: "extra_high", max: null },
11 },
12 "xhigh",
13 );
14 const descriptors = capabilities.optionDescriptors ?? [];
15 const thinking = descriptors[0];
16 assert.equal(thinking?.id, "thinking");
17 assert.equal(thinking?.type, "select");
18 if (thinking?.type !== "select") return;
19 assert.deepEqual(
20 thinking.options.map((option) => [option.id, option.label, option.isDefault === true]),
21 [
22 ["minimal", "Minimal", false],
23 ["low", "Low", false],
24 ["medium", "Medium", false],
25 ["high", "High", false],
26 ["xhigh", "Extra High", true],
27 ],
28 );
29 });
30
31 it("clamps Pi's default to each model's supported levels", () => {
32 const capabilities = thinkingCapabilitiesForPiModel(
33 {
34 reasoning: true,
35 thinkingLevelMap: { xhigh: "extra_high", max: null },
36 },
37 "max",
38 );
39 const thinking = capabilities.optionDescriptors?.[0];
40 assert.equal(thinking?.type, "select");
41 if (thinking?.type !== "select") return;
42 assert.deepInclude(thinking.options, {
43 id: "xhigh",
44 label: "Extra High",
45 isDefault: true,
46 });
47 assert.notInclude(
48 thinking.options.map((option) => option.id),
49 "max",
50 );
51 });
52});
apps/server/src/provider/Layers/piThinkingCapabilities.ts deleted-97
......@@ -1,97 +0,0 @@
1import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts";
2import { createModelCapabilities } from "@t3tools/shared/model";
3import * as Predicate from "effect/Predicate";
4
5/**
6 * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per
7 * model via `thinkingLevelMap`; advertising them globally makes
8 * `set_thinking_level` fail on models that lack them.
9 */
10const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
11
12export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number];
13
14const PI_THINKING_LEVEL_LABELS: Record<PiThinkingLevel, string> = {
15 off: "Off",
16 minimal: "Minimal",
17 low: "Low",
18 medium: "Medium",
19 high: "High",
20 xhigh: "Extra High",
21 max: "Max",
22};
23
24export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
25 optionDescriptors: [],
26});
27
28export function thinkingCapabilitiesForPiModel(
29 model: unknown,
30 defaultThinkingLevel: unknown,
31): ModelCapabilities {
32 const levels = supportedPiThinkingLevelsFromModel(model);
33 if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES;
34 const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels);
35 return createModelCapabilities({
36 optionDescriptors: [
37 {
38 id: "thinking",
39 label: "Thinking",
40 type: "select",
41 options: levels.map((level): ProviderOptionChoice => ({
42 id: level,
43 label: PI_THINKING_LEVEL_LABELS[level],
44 ...(level === defaultLevel ? { isDefault: true } : {}),
45 })),
46 },
47 ],
48 });
49}
50
51/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */
52function clampPiThinkingLevel(
53 input: unknown,
54 availableLevels: ReadonlyArray<PiThinkingLevel>,
55): PiThinkingLevel | undefined {
56 if (typeof input !== "string") return undefined;
57 const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input);
58 if (requestedIndex === -1) return undefined;
59 const exact = availableLevels.find((level) => level === input);
60 if (exact !== undefined) return exact;
61 for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) {
62 const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]);
63 if (higher !== undefined) return higher;
64 }
65 for (let index = requestedIndex - 1; index >= 0; index -= 1) {
66 const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]);
67 if (lower !== undefined) return lower;
68 }
69 return availableLevels[0];
70}
71
72/**
73 * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`.
74 *
75 * A reasoning model always exposes off through high unless a map entry is
76 * `null`. Extra High and Max appear only when the map has a non-null entry.
77 */
78function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray<PiThinkingLevel> {
79 if (recordField(model, "reasoning") !== true) return [];
80 const thinkingLevelMap = thinkingLevelMapFromModel(model);
81 return PI_THINKING_LEVELS.filter((level) => {
82 const mapped = thinkingLevelMap?.[level];
83 if (mapped === null) return false;
84 if (level === "xhigh" || level === "max") return mapped !== undefined;
85 return true;
86 });
87}
88
89function thinkingLevelMapFromModel(model: unknown): Record<string, unknown> | undefined {
90 const value = recordField(model, "thinkingLevelMap");
91 if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
92 return value as Record<string, unknown>;
93}
94
95function recordField(input: unknown, key: string): unknown {
96 return Predicate.isObject(input) ? input[key] : undefined;
97}
apps/server/src/provider/Layers/resetCreditCoordinator.test.ts deleted-105
......@@ -1,105 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Ref from "effect/Ref";
6
7import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
8
9describe("ResetCreditCoordinator", () => {
10 it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () =>
11 Effect.gen(function* () {
12 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
13 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
14 const attempts = yield* Ref.make(0);
15 const consume = (key: string) =>
16 Effect.gen(function* () {
17 yield* Ref.update(keys, (seen) => [...seen, key]);
18 const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1);
19 if (attempt === 1) return yield* Effect.fail("timed out" as const);
20 return "reset" as const;
21 });
22
23 const first = yield* redeem("acct", consume).pipe(Effect.result);
24 assert.isTrue(first._tag === "Failure");
25 const second = yield* redeem("acct", consume);
26 assert.strictEqual(second, "reset");
27 // A fresh redemption after success must be a fresh attempt.
28 yield* redeem("acct", consume);
29
30 const seen = yield* Ref.get(keys);
31 assert.strictEqual(seen.length, 3);
32 assert.strictEqual(seen[0], seen[1]);
33 assert.notStrictEqual(seen[1], seen[2]);
34 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
35 );
36
37 it.effect("starts a fresh attempt after a settled failure", () =>
38 Effect.gen(function* () {
39 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
40 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
41 const consume = (key: string) =>
42 Ref.update(keys, (seen) => [...seen, key]).pipe(
43 Effect.andThen(Effect.fail("cooldown" as const)),
44 );
45 const isSettled = (error: "cooldown") => error === "cooldown";
46
47 yield* redeem("acct", consume, isSettled).pipe(Effect.result);
48 yield* redeem("acct", consume, isSettled).pipe(Effect.result);
49
50 const seen = yield* Ref.get(keys);
51 assert.strictEqual(seen.length, 2);
52 assert.notStrictEqual(seen[0], seen[1]);
53 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
54 );
55
56 it.effect("serialises concurrent redemptions on the same account, not per caller", () =>
57 Effect.gen(function* () {
58 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
59 const release = yield* Deferred.make<void>();
60 const inFlight = yield* Ref.make(0);
61 const peak = yield* Ref.make(0);
62 const consume = () =>
63 Effect.gen(function* () {
64 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
65 yield* Ref.update(peak, (p) => Math.max(p, now));
66 yield* Deferred.await(release);
67 yield* Ref.update(inFlight, (n) => n - 1);
68 return "reset" as const;
69 });
70
71 // Two instances of the same account redeem at once.
72 const a = yield* redeem("acct", consume).pipe(Effect.forkChild);
73 const b = yield* redeem("acct", consume).pipe(Effect.forkChild);
74 yield* Effect.yieldNow;
75 yield* Deferred.succeed(release, undefined);
76 yield* Fiber.join(a);
77 yield* Fiber.join(b);
78
79 assert.strictEqual(yield* Ref.get(peak), 1);
80 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
81 );
82
83 it.effect("keeps different accounts independent", () =>
84 Effect.gen(function* () {
85 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
86 const release = yield* Deferred.make<void>();
87 const peak = yield* Ref.make(0);
88 const inFlight = yield* Ref.make(0);
89 const consume = () =>
90 Effect.gen(function* () {
91 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
92 yield* Ref.update(peak, (p) => Math.max(p, now));
93 yield* Deferred.await(release);
94 return "reset" as const;
95 });
96 const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild);
97 const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild);
98 yield* Effect.yieldNow;
99 yield* Deferred.succeed(release, undefined);
100 yield* Fiber.join(a);
101 yield* Fiber.join(b);
102 assert.strictEqual(yield* Ref.get(peak), 2);
103 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
104 );
105});
apps/server/src/provider/Layers/resetCreditCoordinator.ts deleted-111
......@@ -1,111 +0,0 @@
1/**
2 * Redeeming a reset credit is an account-level action: instances that share
3 * the directory holding a provider's login share the credit, so their
4 * redemptions must serialise on that directory, not the instance. This
5 * service keeps one lock and one pending idempotency key per account key so
6 * overlapping confirmations from any instance queue rather than spending two
7 * credits, and a retry after a timeout re-sends the same attempt.
8 *
9 * @module provider/Layers/resetCreditCoordinator
10 */
11import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import type * as PlatformError from "effect/PlatformError";
17import * as Ref from "effect/Ref";
18import * as Semaphore from "effect/Semaphore";
19
20interface AccountRedemptionState {
21 readonly lock: Semaphore.Semaphore;
22 readonly pendingKey: Ref.Ref<string | null>;
23}
24
25export class ResetCreditCoordinator extends Context.Service<
26 ResetCreditCoordinator,
27 {
28 /**
29 * Run `consume` under the account's lock with a stable idempotency key.
30 * The key is cleared when the provider reports an outcome, or when
31 * `isSettled` says a failure was a final answer (such as a cooldown).
32 * Any other failure (timeout included) keeps it so the next attempt is
33 * the same attempt.
34 */
35 readonly redeem: <E, R>(
36 accountKey: string,
37 consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>,
38 isSettled?: (error: E) => boolean,
39 ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>;
40 }
41>()("t3/provider/Layers/resetCreditCoordinator") {}
42
43/** @public Service construction is part of the canonical Effect module API. */
44export const make = Effect.gen(function* () {
45 const crypto = yield* Crypto.Crypto;
46 const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map());
47
48 // Get-or-create through one Ref.modify so two first redemptions for the
49 // same account cannot each install their own lock.
50 const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) {
51 const existing = (yield* Ref.get(statesRef)).get(accountKey);
52 if (existing) return existing;
53 const candidate = {
54 lock: yield* Semaphore.make(1),
55 pendingKey: yield* Ref.make<string | null>(null),
56 };
57 return yield* Ref.modify(statesRef, (states) => {
58 const current = states.get(accountKey);
59 if (current) return [current, states] as const;
60 const next = new Map(states);
61 next.set(accountKey, candidate);
62 return [candidate, next] as const;
63 });
64 });
65
66 const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) =>
67 Effect.gen(function* () {
68 const state = yield* stateFor(accountKey);
69 return yield* state.lock.withPermits(1)(
70 Effect.gen(function* () {
71 const existing = yield* Ref.get(state.pendingKey);
72 const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4);
73 yield* Ref.set(state.pendingKey, idempotencyKey);
74 const outcome = yield* consume(idempotencyKey).pipe(
75 Effect.tapError((error) =>
76 isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void,
77 ),
78 );
79 yield* Ref.set(state.pendingKey, null);
80 return outcome;
81 }),
82 );
83 });
84
85 return { redeem } satisfies ResetCreditCoordinator["Service"];
86});
87
88export const layer = Layer.effect(ResetCreditCoordinator, make);
89
90/**
91 * Self-contained for tests: a counter-backed Crypto so keys are deterministic
92 * and distinct without the platform layer.
93 */
94export const layerTest = Layer.effect(
95 ResetCreditCoordinator,
96 Effect.gen(function* () {
97 let counter = 0;
98 return yield* make.pipe(
99 Effect.provideService(
100 Crypto.Crypto,
101 Crypto.make({
102 randomBytes: (size) => {
103 counter += 1;
104 return new Uint8Array(size).fill(counter);
105 },
106 digest: (_algorithm, data) => Effect.succeed(data),
107 }),
108 ),
109 );
110 }),
111);
apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs deleted-66
......@@ -1,66 +0,0 @@
1#!/usr/bin/env node
2import * as NodeFS from "node:fs";
3import * as NodeReadline from "node:readline";
4const args = process.argv.slice(2);
5const mcpConfigIndex = args.indexOf("--mcp-config");
6const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;
7let mcpConfig;
8if (rawMcpConfig) {
9 const contents = NodeFS.existsSync(rawMcpConfig)
10 ? NodeFS.readFileSync(rawMcpConfig, "utf8")
11 : rawMcpConfig;
12 try {
13 mcpConfig = JSON.parse(contents);
14 } catch {
15 mcpConfig = contents;
16 }
17}
18NodeFS.writeFileSync(
19 process.env.T3_PROBE_INVOCATION_PATH,
20 JSON.stringify({
21 args,
22 cwd: process.cwd(),
23 connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,
24 mcpConfig,
25 }),
26);
27const lines = NodeReadline.createInterface({ input: process.stdin });
28lines.on("line", (line) => {
29 const message = JSON.parse(line);
30 if (message.type !== "control_request") return;
31 const reply = (response) =>
32 process.stdout.write(
33 JSON.stringify({
34 type: "control_response",
35 response: { subtype: "success", request_id: message.request_id, response },
36 }) + "\n",
37 );
38 if (message.request?.subtype === "initialize") {
39 reply({
40 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
41 agents: [],
42 output_style: "default",
43 available_output_styles: ["default"],
44 models: [],
45 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
46 });
47 }
48 // The probe follows initialize with get_usage on the same process.
49 if (message.request?.subtype === "get_usage") {
50 reply({
51 session: {},
52 subscription_type: "pro",
53 rate_limits_available: true,
54 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
55 behaviors: null,
56 });
57 }
58});
59// Stay alive for follow-up control requests, but never outlive the
60// parent: the probe aborts the SDK without awaiting the child, so an
61// unconditional interval would strand this process until reboot.
62const keepAlive = setInterval(() => {}, 1_000);
63lines.on("close", () => {
64 clearInterval(keepAlive);
65 process.exit(0);
66});
apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs deleted-71
......@@ -1,71 +0,0 @@
1#!/usr/bin/env node
2import * as NodeFS from "node:fs";
3import * as NodeReadline from "node:readline";
4if (process.argv.includes("--version")) {
5 process.stdout.write("claude 2.1.219\n");
6 process.exit(0);
7}
8const lines = NodeReadline.createInterface({ input: process.stdin });
9lines.on("line", (line) => {
10 const message = JSON.parse(line);
11 if (message.type !== "control_request") return;
12 if (message.request?.subtype === "get_usage") {
13 const marker = process.env.T3_CLAUDE_RESET_MARKER;
14 if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) {
15 process.stdout.write(
16 JSON.stringify({
17 type: "control_response",
18 response: { subtype: "error", request_id: message.request_id, error: "usage failed" },
19 }) + "\n",
20 );
21 return;
22 }
23 process.stdout.write(
24 JSON.stringify({
25 type: "control_response",
26 response: {
27 subtype: "success",
28 request_id: message.request_id,
29 response: {
30 session: {},
31 subscription_type: "pro",
32 rate_limits_available: true,
33 rate_limits: {
34 five_hour: {
35 utilization: marker && NodeFS.existsSync(marker) ? 0 : 100,
36 resets_at: null,
37 },
38 },
39 },
40 },
41 }) + "\n",
42 );
43 return;
44 }
45 if (message.request?.subtype !== "initialize") return;
46 process.stdout.write(
47 JSON.stringify({
48 type: "control_response",
49 response: {
50 subtype: "success",
51 request_id: message.request_id,
52 response: {
53 commands: [],
54 agents: [],
55 models: [],
56 output_style: "default",
57 available_output_styles: ["default"],
58 account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },
59 },
60 },
61 }) + "\n",
62 );
63});
64// Stay alive for follow-up control requests, but never outlive the
65// parent: the probe aborts the SDK without awaiting the child, so an
66// unconditional interval would strand this process until reboot.
67const keepAlive = setInterval(() => {}, 1_000);
68lines.on("close", () => {
69 clearInterval(keepAlive);
70 process.exit(0);
71});
apps/server/src/provider/OpenCodeProvider.test.ts created+873
......@@ -0,0 +1,873 @@
1import * as NodeAssert from "node:assert/strict";
2import * as NodeCrypto from "node:crypto";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as TestClock from "effect/testing/TestClock";
13import { HttpClient, HttpClientResponse } from "effect/http";
14import { beforeEach } from "vite-plus/test";
15
16import { OpenCodeSettings } from "@t3tools/contracts";
17import * as ServerConfig from "../config.ts";
18import * as OpenCodeRuntime from "./opencodeRuntime.ts";
19import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
20import {
21 checkOpenCodeProviderStatus,
22 loadOpenCode2Workspace,
23 makeOpenCode2ModelLoader,
24 type OpenCode2Model,
25 type OpenCode2Workspace,
26 openCode2CommandsToServerProviderSlashCommands,
27 openCode2SkillsToServerProviderSkills,
28 openCodeCommandsToServerProviderSlashCommands,
29} from "./OpenCodeProvider.ts";
30import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts";
31import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts";
32import {
33 OPENCODE_1_RESPONSES,
34 OPENCODE_2_RESPONSES,
35 replayOpenCodeServer,
36} from "./testFixtures/opencodeProbeResponses.ts";
37const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
38
39const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
40
41it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () =>
42 Effect.gen(function* () {
43 const resetsAt = "2026-09-17T12:00:00.000Z";
44 const limits = yield* readOpenCodeGoUsageLimits({
45 enabled: true,
46 serverUrl: "",
47 environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" },
48 }).pipe(
49 Effect.provideService(
50 FileSystem.FileSystem,
51 FileSystem.makeNoop({
52 readFileString: (path) => {
53 NodeAssert.equal(path, "/instance/data/opencode/auth.json");
54 return Effect.succeed(
55 JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }),
56 );
57 },
58 }),
59 ),
60 Effect.provideService(
61 HttpClient.HttpClient,
62 HttpClient.make((request) => {
63 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
64 NodeAssert.equal(request.headers.authorization, "Bearer instance-key");
65 return Effect.succeed(
66 HttpClientResponse.fromWeb(
67 request,
68 Response.json({
69 usage: {
70 rolling: { percent: 0, resetsAt },
71 weekly: { percent: 10, resetsAt },
72 monthly: { percent: 125, resetsAt },
73 },
74 }),
75 ),
76 );
77 }),
78 ),
79 Effect.provide(NodeServices.layer),
80 );
81 NodeAssert.equal(limits.unavailable, undefined);
82 NodeAssert.equal(
83 limits.credentialFingerprint,
84 NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"),
85 );
86 NodeAssert.deepEqual(
87 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
88 kind,
89 usedPercent,
90 reset,
91 })),
92 [
93 { kind: "session", usedPercent: 0, reset: resetsAt },
94 { kind: "weekly", usedPercent: 10, reset: resetsAt },
95 { kind: "monthly", usedPercent: 100, reset: resetsAt },
96 ],
97 );
98 }),
99);
100
101it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
102 Effect.gen(function* () {
103 for (const settings of [
104 { enabled: true, serverUrl: "https://remote.example" },
105 { enabled: false, serverUrl: "" },
106 ]) {
107 const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe(
108 Effect.provideService(
109 FileSystem.FileSystem,
110 FileSystem.makeNoop({
111 readFileString: () => Effect.die("unexpected credential read"),
112 }),
113 ),
114 Effect.provideService(
115 HttpClient.HttpClient,
116 HttpClient.make(() => Effect.die("unexpected usage request")),
117 ),
118 Effect.provide(NodeServices.layer),
119 );
120 NodeAssert.equal(limits.unavailable?.reason, "unsupported");
121 }
122 }),
123);
124
125it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () =>
126 Effect.gen(function* () {
127 for (const [status, reason] of [
128 [403, "unsupported"],
129 [401, "probeFailed"],
130 [200, "probeFailed"],
131 ] as const) {
132 const limits = yield* readOpenCodeGoUsageLimits({
133 enabled: true,
134 serverUrl: "",
135 environment: {
136 OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}',
137 },
138 }).pipe(
139 Effect.provideService(
140 FileSystem.FileSystem,
141 FileSystem.makeNoop({
142 readFileString: () => Effect.die("inline credentials must bypass disk"),
143 }),
144 ),
145 Effect.provideService(
146 HttpClient.HttpClient,
147 HttpClient.make((request) =>
148 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))),
149 ),
150 ),
151 Effect.provide(NodeServices.layer),
152 );
153 NodeAssert.equal(limits.unavailable?.reason, reason);
154 NodeAssert.equal(limits.credentialFingerprint, undefined);
155 NodeAssert.deepEqual(limits.windows, []);
156 }
157 }),
158);
159
160/**
161 * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag
162 * are deleted. The snapshot-producing logic they wrapped now lives in the
163 * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which
164 * drivers call directly when building their per-instance snapshot
165 * `ServerProviderShape`. Tests mirror that shape: build a settings payload,
166 * invoke the check, assert on the returned snapshot.
167 */
168
169const runtimeMock = {
170 state: {
171 runVersionError: null as Error | null,
172 runVersionPending: false,
173 versionStdout: DEFAULT_VERSION_STDOUT,
174 inventoryError: null as Error | null,
175 connectionError: null as Error | null,
176 inventoryCwd: null as string | null,
177 closeCalls: 0,
178 sdkClientInputs: [] as Array<{
179 baseUrl: string;
180 directory: string;
181 serverPassword?: string;
182 }>,
183 inventory: {
184 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
185 agents: [] as unknown[],
186 skills: [] as unknown[],
187 } as unknown,
188 },
189 reset() {
190 this.state.runVersionError = null;
191 this.state.runVersionPending = false;
192 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
193 this.state.inventoryError = null;
194 this.state.connectionError = null;
195 this.state.inventoryCwd = null;
196 this.state.closeCalls = 0;
197 this.state.sdkClientInputs.length = 0;
198 this.state.inventory = {
199 providerList: { connected: [], all: [] as unknown[], default: {} },
200 agents: [] as unknown[],
201 skills: [] as unknown[],
202 };
203 },
204};
205
206const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
207 startOpenCodeServerProcess: ({ serverPassword, environment }) =>
208 Effect.gen(function* () {
209 yield* Effect.addFinalizer(() =>
210 Effect.sync(() => {
211 runtimeMock.state.closeCalls += 1;
212 }),
213 );
214 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
215 external: false,
216 ...(serverPassword !== undefined ? { serverPassword } : {}),
217 ...(environment !== undefined ? { environment } : {}),
218 });
219 return {
220 url: "http://127.0.0.1:4301",
221 ...(effectiveServerPassword !== undefined
222 ? { serverPassword: effectiveServerPassword }
223 : {}),
224 version: "1.14.19",
225 isRunning: Effect.succeed(true),
226 exitCode: Effect.never,
227 };
228 }),
229 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
230 Effect.gen(function* () {
231 if (runtimeMock.state.connectionError) {
232 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
233 operation: "global.health",
234 detail: runtimeMock.state.connectionError.message,
235 cause: runtimeMock.state.connectionError,
236 });
237 }
238 if (!serverUrl) {
239 yield* Effect.addFinalizer(() =>
240 Effect.sync(() => {
241 runtimeMock.state.closeCalls += 1;
242 }),
243 );
244 }
245 return {
246 url: serverUrl ?? "http://127.0.0.1:4301",
247 ...(serverPassword ? { serverPassword } : {}),
248 version: "1.14.19",
249 exitCode: null,
250 external: Boolean(serverUrl),
251 };
252 }),
253 runOpenCodeCommand: () =>
254 runtimeMock.state.runVersionPending
255 ? Effect.never
256 : runtimeMock.state.runVersionError
257 ? Effect.fail(
258 new OpenCodeRuntime.OpenCodeRuntimeError({
259 operation: "runOpenCodeCommand",
260 detail: runtimeMock.state.runVersionError.message,
261 cause: runtimeMock.state.runVersionError,
262 }),
263 )
264 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
265 createOpenCodeSdkClient: (input) => {
266 runtimeMock.state.sdkClientInputs.push(input);
267 return {} as unknown as ReturnType<
268 OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]
269 >;
270 },
271 loadOpenCodeInventory: () =>
272 runtimeMock.state.inventoryError
273 ? Effect.fail(
274 new OpenCodeRuntime.OpenCodeRuntimeError({
275 operation: "loadOpenCodeInventory",
276 detail: runtimeMock.state.inventoryError.message,
277 cause: runtimeMock.state.inventoryError,
278 }),
279 )
280 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory),
281 loadInventoryFromCli: ({ cwd }) => {
282 runtimeMock.state.inventoryCwd = cwd;
283 return runtimeMock.state.inventoryError
284 ? Effect.fail(
285 new OpenCodeRuntime.OpenCodeRuntimeError({
286 operation: "loadInventoryFromCli",
287 detail: runtimeMock.state.inventoryError.message,
288 cause: runtimeMock.state.inventoryError,
289 }),
290 )
291 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory);
292 },
293 loadOpenCodeSkills: () => Effect.succeed([]),
294 loadSkillsFromCli: () => Effect.succeed([]),
295};
296
297beforeEach(() => {
298 runtimeMock.reset();
299});
300
301it("keeps native and MCP commands while preserving compaction and separate skills", () => {
302 NodeAssert.deepEqual(
303 openCodeCommandsToServerProviderSlashCommands([
304 { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] },
305 { name: "review", source: "command", hints: [] },
306 { name: "compact", source: "command", hints: [] },
307 { name: "skill", source: "skill", hints: [] },
308 { name: "mcp:search", source: "mcp", hints: ["query"] },
309 ]).slice(1),
310 [
311 { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } },
312 { name: "mcp:search", input: { hint: "query" } },
313 ],
314 );
315});
316
317const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
318 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
319 Layer.provideMerge(NodeServices.layer),
320);
321
322const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
323 decodeOpenCodeSettings({
324 enabled: true,
325 binaryPath: "opencode",
326 serverUrl: "",
327 serverPassword: "",
328 customModels: [],
329 ...overrides,
330 });
331
332const checkProvider = Effect.fn("checkProvider")(function* (
333 settings: OpenCodeSettings,
334 cwd = process.cwd(),
335 environment?: NodeJS.ProcessEnv,
336 server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
337 openCode2Models: Effect.Effect<
338 ReadonlyArray<OpenCode2Model>,
339 OpenCodeRuntime.OpenCodeRuntimeError
340 > = Effect.succeed([]),
341) {
342 return yield* Effect.scoped(
343 Effect.gen(function* () {
344 const serverOwner = yield* OpenCodeServerOwner.make({
345 binaryPath: settings.binaryPath,
346 directory: cwd,
347 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
348 ...(environment ? { environment } : {}),
349 });
350 const probe = probeOpenCodeRuntime(settings, environment).pipe(
351 Effect.provideService(HttpClient.HttpClient, server),
352 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
353 );
354 return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe(
355 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
356 );
357 }),
358 );
359});
360
361it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
362 it.effect("shows a codex-style missing binary message", () =>
363 Effect.gen(function* () {
364 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
365 const snapshot = yield* checkProvider(makeOpenCodeSettings());
366
367 NodeAssert.equal(snapshot.status, "error");
368 NodeAssert.equal(snapshot.installed, false);
369 NodeAssert.equal(
370 snapshot.message,
371 "OpenCode CLI (`opencode`) is not installed or not on PATH.",
372 );
373 }),
374 );
375
376 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
377 Effect.gen(function* () {
378 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
379 const snapshot = yield* checkProvider(makeOpenCodeSettings());
380
381 NodeAssert.equal(snapshot.status, "error");
382 NodeAssert.equal(snapshot.installed, true);
383 NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
384 }),
385 );
386
387 it.effect("times out a hanging local CLI version probe", () =>
388 Effect.gen(function* () {
389 runtimeMock.state.runVersionPending = true;
390 const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
391
392 yield* Effect.yieldNow;
393 yield* TestClock.adjust("4 seconds");
394 const snapshot = yield* Fiber.join(probeFiber);
395
396 NodeAssert.equal(snapshot.status, "error");
397 NodeAssert.equal(snapshot.installed, true);
398 NodeAssert.equal(
399 snapshot.message,
400 "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
401 );
402 }).pipe(Effect.provide(TestClock.layer())),
403 );
404
405 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
406 Effect.gen(function* () {
407 runtimeMock.state.inventory = {
408 providerList: {
409 connected: ["openai"],
410 all: [
411 {
412 id: "openai",
413 name: "OpenAI",
414 models: {
415 "gpt-5.4": {
416 id: "gpt-5.4",
417 name: "GPT-5.4",
418 variants: {
419 none: {},
420 low: {},
421 medium: {},
422 high: {},
423 xhigh: {},
424 },
425 },
426 },
427 },
428 ],
429 default: {},
430 },
431 agents: [
432 { name: "build", hidden: false, mode: "primary" },
433 { name: "plan", hidden: false, mode: "primary" },
434 ],
435 };
436
437 const snapshot = yield* checkProvider(makeOpenCodeSettings());
438 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
439
440 NodeAssert.ok(model);
441 const variantDescriptor = model.capabilities?.optionDescriptors?.find(
442 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
443 );
444 NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select");
445 NodeAssert.equal(
446 variantDescriptor.options.find((option) => option.isDefault === true)?.id,
447 "medium",
448 );
449 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
450 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
451 );
452 NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select");
453 NodeAssert.equal(
454 agentDescriptor.options.find((option) => option.isDefault === true)?.id,
455 "build",
456 );
457 }),
458 );
459
460 it.effect("includes OpenCode skills in the provider snapshot", () =>
461 Effect.gen(function* () {
462 runtimeMock.state.inventory = {
463 providerList: {
464 connected: ["openai"],
465 all: [
466 {
467 id: "openai",
468 name: "OpenAI",
469 models: {
470 "gpt-5.4": {
471 id: "gpt-5.4",
472 name: "GPT-5.4",
473 variants: {},
474 },
475 },
476 },
477 ],
478 default: {},
479 },
480 agents: [],
481 skills: [
482 {
483 name: "openclaw-review",
484 description: "Review OpenClaw workflow changes.",
485 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
486 },
487 {
488 name: "openclaw-triage",
489 description: "Triage OpenClaw routing issues.",
490 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
491 },
492 {
493 name: "missing-location",
494 description: "This incomplete SDK row should be skipped.",
495 location: "",
496 },
497 ],
498 };
499
500 const snapshot = yield* checkProvider(makeOpenCodeSettings());
501
502 NodeAssert.deepEqual(
503 snapshot.skills.map((skill) => ({
504 name: skill.name,
505 path: skill.path,
506 enabled: skill.enabled,
507 shortDescription: skill.shortDescription,
508 })),
509 [
510 {
511 name: "openclaw-review",
512 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
513 enabled: true,
514 shortDescription: "Review OpenClaw workflow changes.",
515 },
516 {
517 name: "openclaw-triage",
518 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
519 enabled: true,
520 shortDescription: "Triage OpenClaw routing issues.",
521 },
522 ],
523 );
524 }),
525 );
526
527 it.effect("loads local inventory from a scoped OpenCode server", () =>
528 Effect.gen(function* () {
529 yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" }));
530
531 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
532 {
533 baseUrl: "http://127.0.0.1:4301",
534 directory: process.cwd(),
535 serverPassword: "secret-password",
536 },
537 ]);
538 NodeAssert.equal(runtimeMock.state.closeCalls, 1);
539 NodeAssert.equal(runtimeMock.state.inventoryCwd, null);
540 }),
541 );
542
543 it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
544 Effect.gen(function* () {
545 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
546 const snapshot = yield* checkProvider(
547 makeOpenCodeSettings(),
548 process.cwd(),
549 undefined,
550 undefined,
551 Effect.succeed([
552 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
553 {
554 providerID: "opencode",
555 id: "space-bunny-free",
556 name: "Space Bunny Free",
557 variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
558 },
559 ]),
560 );
561
562 NodeAssert.equal(snapshot.status, "ready");
563 NodeAssert.equal(snapshot.version, "2.0.18");
564 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
565 "approval-required",
566 "auto-accept-edits",
567 "auto",
568 "full-access",
569 ]);
570 // Plan mode runs as OpenCode's plan agent, and every session can compact.
571 NodeAssert.equal(snapshot.showInteractionModeToggle, true);
572 NodeAssert.deepEqual(
573 snapshot.slashCommands.map((command) => command.name),
574 ["compact"],
575 );
576 NodeAssert.deepEqual(
577 snapshot.models.map((model) => model.slug),
578 ["opencode/big-pickle", "opencode/space-bunny-free"],
579 );
580 const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
581 ?.capabilities?.optionDescriptors?.[0];
582 NodeAssert.deepEqual(
583 variant?.type === "select" ? variant.options.map((option) => option.id) : [],
584 ["low", "medium", "high"],
585 );
586 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
587 }),
588 );
589
590 it.effect("reports a failed OpenCode 2 model list without the server's response", () =>
591 Effect.gen(function* () {
592 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
593 const snapshot = yield* checkProvider(
594 makeOpenCodeSettings(),
595 process.cwd(),
596 undefined,
597 undefined,
598 Effect.fail(
599 new OpenCodeRuntime.OpenCodeRuntimeError({
600 operation: "model.list",
601 detail: 'status=500 body={"token":"leaked-response-body"}',
602 }),
603 ),
604 );
605
606 NodeAssert.equal(snapshot.status, "error");
607 NodeAssert.equal(snapshot.message, "OpenCode could not load its model list.");
608 // A failed catalog read still leaves the session able to compact.
609 NodeAssert.deepEqual(
610 snapshot.slashCommands.map((command) => command.name),
611 ["compact"],
612 );
613 }),
614 );
615
616 it.effect("uses an environment-only password for local inventory", () =>
617 Effect.gen(function* () {
618 yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
619 OPENCODE_SERVER_PASSWORD: "environment-password",
620 });
621
622 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
623 {
624 baseUrl: "http://127.0.0.1:4301",
625 directory: process.cwd(),
626 serverPassword: "environment-password",
627 },
628 ]);
629 }),
630 );
631
632 it.effect("uses the settings password when local environment auth differs", () =>
633 Effect.gen(function* () {
634 yield* checkProvider(
635 makeOpenCodeSettings({ serverPassword: "settings-password" }),
636 process.cwd(),
637 { OPENCODE_SERVER_PASSWORD: "environment-password" },
638 );
639
640 NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password");
641 }),
642 );
643
644 it.effect("reports local model inventory failures without treating them as empty", () =>
645 Effect.gen(function* () {
646 runtimeMock.state.inventoryError = new Error("opencode models failed");
647 const snapshot = yield* checkProvider(makeOpenCodeSettings());
648
649 NodeAssert.equal(snapshot.status, "error");
650 NodeAssert.equal(snapshot.installed, true);
651 NodeAssert.equal(snapshot.models.length, 0);
652 NodeAssert.equal(
653 snapshot.message,
654 "Failed to load OpenCode provider inventory: opencode models failed",
655 );
656 }),
657 );
658});
659
660it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
661 it.effect("does not send a local environment password to a configured server", () =>
662 Effect.gen(function* () {
663 const snapshot = yield* checkProvider(
664 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
665 process.cwd(),
666 { OPENCODE_SERVER_PASSWORD: "local-secret" },
667 );
668
669 NodeAssert.equal(snapshot.version, "1.14.19");
670 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
671 {
672 baseUrl: "http://127.0.0.1:9999",
673 directory: process.cwd(),
674 },
675 ]);
676 }),
677 );
678
679 it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () =>
680 Effect.gen(function* () {
681 const settings = makeOpenCodeSettings({
682 serverUrl: "http://127.0.0.1:9999",
683 serverPassword: "secret-password",
684 });
685 const snapshot = yield* checkProvider(
686 settings,
687 process.cwd(),
688 undefined,
689 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
690 );
691
692 NodeAssert.equal(snapshot.status, "warning");
693 NodeAssert.equal(snapshot.version, "2.0.18");
694 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
695 "approval-required",
696 "auto-accept-edits",
697 "auto",
698 "full-access",
699 ]);
700 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
701 }),
702 );
703
704 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
705 Effect.gen(function* () {
706 const snapshot = yield* checkProvider(
707 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }),
708 process.cwd(),
709 undefined,
710 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
711 );
712
713 NodeAssert.equal(snapshot.status, "error");
714 NodeAssert.equal(
715 snapshot.message,
716 "OpenCode server rejected authentication. Check the server URL and password.",
717 );
718 }),
719 );
720
721 it.effect("rejects an unsupported server before loading inventory", () =>
722 Effect.gen(function* () {
723 runtimeMock.state.connectionError = new Error(
724 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
725 );
726 const snapshot = yield* checkProvider(
727 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
728 );
729
730 NodeAssert.equal(snapshot.status, "error");
731 NodeAssert.equal(snapshot.models.length, 0);
732 NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
733 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
734 }),
735 );
736
737 it.effect("surfaces a friendly auth error for configured servers", () =>
738 Effect.gen(function* () {
739 runtimeMock.state.connectionError = new Error("401 Unauthorized");
740 const snapshot = yield* checkProvider(
741 makeOpenCodeSettings({
742 serverUrl: "http://127.0.0.1:9999",
743 serverPassword: "secret-password",
744 }),
745 );
746
747 NodeAssert.equal(snapshot.status, "error");
748 NodeAssert.equal(snapshot.installed, true);
749 NodeAssert.equal(
750 snapshot.message,
751 "OpenCode server rejected authentication. Check the server URL and password.",
752 );
753 }),
754 );
755
756 it.effect("surfaces a friendly connection error for configured servers", () =>
757 Effect.gen(function* () {
758 runtimeMock.state.connectionError = new Error(
759 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
760 );
761 const snapshot = yield* checkProvider(
762 makeOpenCodeSettings({
763 serverUrl: "http://127.0.0.1:9999",
764 serverPassword: "secret-password",
765 }),
766 );
767
768 NodeAssert.equal(snapshot.status, "error");
769 NodeAssert.equal(snapshot.installed, true);
770 NodeAssert.equal(
771 snapshot.message,
772 "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.",
773 );
774 }),
775 );
776});
777
778const bigPickle: OpenCode2Model = {
779 providerID: "opencode",
780 id: "big-pickle",
781 name: "Big Pickle",
782 variants: [],
783};
784
785it.effect("waits for a fresh OpenCode 2 server to list its models", () =>
786 Effect.gen(function* () {
787 // A fresh server lists nothing until its catalog loads.
788 const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]];
789 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle]));
790 const fiber = yield* load.pipe(Effect.forkChild);
791 yield* TestClock.adjust("1 second");
792 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
793 }).pipe(Effect.provide(TestClock.layer())),
794);
795
796it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () =>
797 Effect.gen(function* () {
798 let listed: ReadonlyArray<OpenCode2Model> = [bigPickle];
799 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed));
800 NodeAssert.deepEqual(yield* load, [bigPickle]);
801 listed = [];
802 const fiber = yield* load.pipe(Effect.forkChild);
803 yield* TestClock.adjust("6 seconds");
804 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
805 }).pipe(Effect.provide(TestClock.layer())),
806);
807
808// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29).
809const builtinCommands = [
810 { name: "init", description: "guided AGENTS.md setup" },
811 { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" },
812];
813const builtinSkills = [
814 { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" },
815 { id: "report", name: "Report", path: "/builtin/report.md" },
816];
817const scanned: OpenCode2Workspace = {
818 commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
819 skills: [
820 ...builtinSkills,
821 {
822 id: "greet",
823 name: "greet",
824 path: "/work/.opencode/skills/greet/SKILL.md",
825 description: "Greets the user with the secret word MANGO.",
826 },
827 ],
828};
829
830it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () =>
831 Effect.gen(function* () {
832 // A directory the server has not served yet lists no commands until its scan ends.
833 const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
834 const scanEnded = yield* Deferred.make<void>();
835 const load = yield* loadOpenCode2Workspace(
836 Effect.sync(() => reads.shift()!),
837 Deferred.await(scanEnded),
838 ).pipe(Effect.forkChild);
839 yield* Effect.yieldNow;
840 NodeAssert.equal(reads.length, 1);
841 yield* Deferred.succeed(scanEnded, undefined);
842 NodeAssert.deepEqual(yield* Fiber.join(load), scanned);
843 }),
844);
845
846it.effect("takes a served OpenCode 2 directory's first listing as is", () =>
847 Effect.gen(function* () {
848 const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never);
849 NodeAssert.deepEqual(workspace, scanned);
850 }),
851);
852
853it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => {
854 NodeAssert.deepEqual(
855 openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [
856 skill.name,
857 skill.displayName,
858 skill.path,
859 ]),
860 [
861 ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"],
862 ["opencode", "OpenCode", "/builtin/opencode.md"],
863 ["report", "Report", "/builtin/report.md"],
864 ],
865 );
866 NodeAssert.deepEqual(
867 openCode2CommandsToServerProviderSlashCommands([
868 ...scanned.commands,
869 { name: "compact", description: "a workspace command named like T3's own" },
870 ]).map((command) => command.name),
871 ["compact", "init", "review", "hello"],
872 );
873});
apps/server/src/provider/OpenCodeProvider.ts created+747
......@@ -0,0 +1,747 @@
1import {
2 type ModelCapabilities,
3 type OpenCodeSettings,
4 type ServerProviderModel,
5 type ServerProviderSkill,
6 type ServerProviderSlashCommand,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14import { createModelCapabilities } from "@t3tools/shared/model";
15import { compareSemverVersions } from "@t3tools/shared/semver";
16import {
17 buildServerProvider,
18 COMPACT_SLASH_COMMAND,
19 nonEmptyTrimmed,
20 providerModelsFromSettings,
21 type ServerProviderDraft,
22} from "./providerSnapshot.ts";
23import * as OpenCodeRuntime from "./opencodeRuntime.ts";
24import type { ProbedOpenCode } from "./opencodeVersionProbe.ts";
25import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
26import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
27
28const OPENCODE_PRESENTATION = {
29 displayName: "OpenCode",
30 showInteractionModeToggle: false,
31} as const;
32
33class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
34 readonly cause?: unknown;
35 readonly detail: string;
36}> {}
37
38function normalizeProbeMessage(message: string): string | undefined {
39 const trimmed = message.trim();
40 if (trimmed.length === 0) {
41 return undefined;
42 }
43 if (
44 trimmed === "An error occurred in Effect.tryPromise" ||
45 trimmed === "An error occurred in Effect.try"
46 ) {
47 return undefined;
48 }
49 return trimmed;
50}
51
52function normalizedErrorMessage(cause: unknown): string | undefined {
53 if (cause instanceof OpenCodeProbeError) {
54 return normalizeProbeMessage(cause.detail);
55 }
56
57 if (!(cause instanceof Error)) {
58 return undefined;
59 }
60
61 return normalizeProbeMessage(cause.message);
62}
63
64function formatOpenCodeProbeError(input: {
65 readonly cause: unknown;
66 readonly isExternalServer: boolean;
67 readonly phase: "version" | "inventory";
68 readonly serverUrl: string;
69}): { readonly installed: boolean; readonly message: string } {
70 const detail = normalizedErrorMessage(input.cause);
71 const lower = detail?.toLowerCase() ?? "";
72
73 if (input.isExternalServer) {
74 if (
75 lower.includes("401") ||
76 lower.includes("403") ||
77 lower.includes("unauthorized") ||
78 lower.includes("forbidden")
79 ) {
80 return {
81 installed: true,
82 message: "OpenCode server rejected authentication. Check the server URL and password.",
83 };
84 }
85
86 if (
87 lower.includes("econnrefused") ||
88 lower.includes("enotfound") ||
89 lower.includes("fetch failed") ||
90 lower.includes("networkerror") ||
91 lower.includes("timed out") ||
92 lower.includes("timeout") ||
93 lower.includes("socket hang up")
94 ) {
95 return {
96 installed: true,
97 message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`,
98 };
99 }
100
101 return {
102 installed: true,
103 message: detail ?? "Failed to connect to the configured OpenCode server.",
104 };
105 }
106
107 if (lower.includes("enoent") || lower.includes("notfound")) {
108 return {
109 installed: false,
110 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
111 };
112 }
113
114 if (lower.includes("quarantine")) {
115 return {
116 installed: true,
117 message:
118 "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.",
119 };
120 }
121
122 if (lower.includes("invalid code signature") || lower.includes("corrupted")) {
123 return {
124 installed: true,
125 message:
126 "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.",
127 };
128 }
129
130 const failureLabel =
131 input.phase === "inventory"
132 ? "Failed to load OpenCode provider inventory"
133 : "Failed to execute OpenCode CLI health check";
134 return {
135 installed: true,
136 message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`,
137 };
138}
139
140function titleCaseSlug(value: string): string {
141 const segments: Array<string> = [];
142 for (const segment of value.split(/[-_/]+/)) {
143 if (segment.length > 0) {
144 segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
145 }
146 }
147 return segments.join(" ");
148}
149
150function inferDefaultVariant(
151 providerID: string,
152 variants: ReadonlyArray<string>,
153): string | undefined {
154 if (variants.length === 1) {
155 return variants[0];
156 }
157 if (providerID === "anthropic" || providerID.startsWith("google")) {
158 return variants.includes("high") ? "high" : undefined;
159 }
160 if (providerID === "openai" || providerID === "opencode") {
161 return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
162 }
163 return undefined;
164}
165
166function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
167 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
168}
169
170const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
171 optionDescriptors: [
172 {
173 id: "variant",
174 label: "Reasoning",
175 type: "select",
176 options: [
177 { id: "low", label: "Low" },
178 { id: "medium", label: "Medium", isDefault: true },
179 { id: "high", label: "High" },
180 { id: "xhigh", label: "Extra High" },
181 ],
182 currentValue: "medium",
183 },
184 {
185 id: "agent",
186 label: "Agent",
187 type: "select",
188 options: [
189 { id: "build", label: "Build", isDefault: true },
190 { id: "plan", label: "Plan" },
191 ],
192 currentValue: "build",
193 },
194 ],
195});
196
197function openCodeCapabilitiesForModel(input: {
198 readonly providerID: string;
199 readonly model: ProviderListResponse["all"][number]["models"][string];
200 readonly agents: ReadonlyArray<Agent>;
201}): ModelCapabilities {
202 const rawVariantValues = Object.keys(input.model.variants ?? {});
203 // When a model advertises no variants, synthesize the standard reasoning
204 // levels so the composer still offers a Reasoning selector (mirrors the
205 // Codex/Grok experience where reasoning is always configurable). The set
206 // covers the common OpenCode variant spectrum; `inferDefaultVariant`
207 // picks the provider-appropriate default (e.g. medium for openai/opencode).
208 const variantValues =
209 rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"];
210 const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
211 const variantOptions = variantValues.map((value) =>
212 defaultVariant === value
213 ? { id: value, label: titleCaseSlug(value), isDefault: true as const }
214 : { id: value, label: titleCaseSlug(value) },
215 );
216 const primaryAgents = input.agents.filter(
217 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
218 );
219 const defaultAgent = inferDefaultAgent(primaryAgents);
220 const agentOptions = primaryAgents.map((agent) =>
221 defaultAgent === agent.name
222 ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const }
223 : { id: agent.name, label: titleCaseSlug(agent.name) },
224 );
225 return createModelCapabilities({
226 optionDescriptors: [
227 ...(variantOptions.length > 0
228 ? [
229 {
230 id: "variant",
231 label: "Reasoning",
232 type: "select" as const,
233 options: variantOptions,
234 ...(defaultVariant ? { currentValue: defaultVariant } : {}),
235 },
236 ]
237 : []),
238 ...(agentOptions.length > 0
239 ? [
240 {
241 id: "agent",
242 label: "Agent",
243 type: "select" as const,
244 options: agentOptions,
245 ...(defaultAgent ? { currentValue: defaultAgent } : {}),
246 },
247 ]
248 : []),
249 ],
250 });
251}
252
253function flattenOpenCodeModels(
254 input: OpenCodeRuntime.OpenCodeInventory,
255): ReadonlyArray<ServerProviderModel> {
256 const connected = new Set(input.providerList.connected);
257 const models: Array<ServerProviderModel> = [];
258
259 for (const provider of input.providerList.all) {
260 if (!connected.has(provider.id)) {
261 continue;
262 }
263
264 for (const model of Object.values(provider.models)) {
265 const name = nonEmptyTrimmed(model.name);
266 if (!name) {
267 continue;
268 }
269
270 const subProvider = nonEmptyTrimmed(provider.name);
271 models.push({
272 slug: `${provider.id}/${model.id}`,
273 name,
274 ...(subProvider ? { subProvider } : {}),
275 isCustom: false,
276 capabilities: openCodeCapabilitiesForModel({
277 providerID: provider.id,
278 model,
279 agents: input.agents,
280 }),
281 });
282 }
283 }
284
285 return models.toSorted((left, right) => left.name.localeCompare(right.name));
286}
287
288function trimOptional(value: string | null | undefined): string | undefined {
289 const trimmed = value?.trim();
290 return trimmed && trimmed.length > 0 ? trimmed : undefined;
291}
292
293export function openCodeSkillsToServerProviderSkills(
294 input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined,
295): ReadonlyArray<ServerProviderSkill> {
296 const skills: ServerProviderSkill[] = [];
297 for (const skill of input ?? []) {
298 const name = trimOptional(skill.name);
299 const path = trimOptional(skill.location);
300 if (!name || !path) {
301 continue;
302 }
303
304 const description = trimOptional(skill.description);
305 skills.push({
306 name,
307 path,
308 enabled: true,
309 ...(description ? { description, shortDescription: description } : {}),
310 });
311 }
312
313 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
314}
315
316export function openCodeCommandsToServerProviderSlashCommands(
317 input: OpenCodeRuntime.OpenCodeInventory["commands"],
318): ReadonlyArray<ServerProviderSlashCommand> {
319 const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
320 const names = new Set([COMPACT_SLASH_COMMAND.name]);
321 for (const command of input ?? []) {
322 const name = trimOptional(command.name);
323 if (!name || names.has(name) || command.source === "skill") continue;
324 names.add(name);
325 const description = trimOptional(command.description);
326 const hint = trimOptional(command.hints.join(" "));
327 commands.push({
328 name,
329 ...(description ? { description } : {}),
330 ...(hint ? { input: { hint } } : {}),
331 });
332 }
333 return commands;
334}
335
336export const makePendingOpenCodeProvider = (
337 openCodeSettings: OpenCodeSettings,
338): Effect.Effect<ServerProviderDraft> =>
339 Effect.gen(function* () {
340 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
341 const models = providerModelsFromSettings(
342 [],
343 openCodeSettings.customModels,
344 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
345 );
346
347 if (!openCodeSettings.enabled) {
348 return buildServerProvider({
349 presentation: OPENCODE_PRESENTATION,
350 enabled: false,
351 checkedAt,
352 models,
353 probe: {
354 installed: false,
355 version: null,
356 status: "warning",
357 auth: { status: "unknown" },
358 message:
359 openCodeSettings.serverUrl.trim().length > 0
360 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
361 : "OpenCode is disabled in T3 Code settings.",
362 },
363 });
364 }
365
366 return buildServerProvider({
367 presentation: OPENCODE_PRESENTATION,
368 enabled: true,
369 checkedAt,
370 models,
371 probe: {
372 installed: false,
373 version: null,
374 status: "warning",
375 auth: { status: "unknown" },
376 message: "OpenCode provider status has not been checked in this session yet.",
377 },
378 });
379 });
380
381/** One model an OpenCode 2 server lists, as the status check reads it. */
382export interface OpenCode2Model {
383 readonly providerID: string;
384 readonly id: string;
385 readonly name: string;
386 readonly variants: ReadonlyArray<{ readonly id: string }>;
387}
388
389/**
390 * A server loads its catalog lazily and lists nothing for its first few
391 * hundred milliseconds, and a local instance starts a fresh server for each
392 * status check. So an empty list is read again for a while, and the last
393 * non-empty list stands in if the catalog still has not loaded.
394 */
395export const makeOpenCode2ModelLoader = <E>(
396 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
397) =>
398 Effect.sync(() => {
399 let lastLoaded: ReadonlyArray<OpenCode2Model> = [];
400 return list.pipe(
401 Effect.repeat({
402 until: (models) => models.length > 0,
403 schedule: Schedule.spaced("250 millis"),
404 }),
405 Effect.timeoutOption("5 seconds"),
406 Effect.map((loaded) => {
407 if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value;
408 return lastLoaded;
409 }),
410 );
411 });
412
413/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */
414export interface OpenCode2Workspace {
415 readonly skills: ReadonlyArray<{
416 readonly id: string;
417 readonly name: string;
418 readonly path: string;
419 readonly description?: string | undefined;
420 }>;
421 readonly commands: ReadonlyArray<{
422 readonly name: string;
423 readonly description?: string | undefined;
424 }>;
425}
426
427/**
428 * A server scans a directory it has not served yet in the background: its
429 * first read lists no commands at all (not even the built-ins), and
430 * `command.updated` plus `skill.updated` for the directory end the scan. A
431 * directory it already serves lists everything at once. `scanned` must be
432 * listening before `read` runs, since the read is what starts the scan.
433 */
434export const loadOpenCode2Workspace = <E>(
435 read: Effect.Effect<OpenCode2Workspace, E>,
436 scanned: Effect.Effect<void>,
437) =>
438 Effect.gen(function* () {
439 const first = yield* read;
440 if (first.commands.length > 0) return first;
441 yield* scanned.pipe(Effect.timeoutOption("10 seconds"));
442 return yield* read;
443 });
444
445export function openCode2SkillsToServerProviderSkills(
446 skills: OpenCode2Workspace["skills"],
447): ReadonlyArray<ServerProviderSkill> {
448 return skills
449 .map((skill) => {
450 const description = trimOptional(skill.description);
451 return {
452 name: skill.id,
453 path: skill.path,
454 enabled: true,
455 ...(skill.name === skill.id ? {} : { displayName: skill.name }),
456 ...(description ? { description, shortDescription: description } : {}),
457 };
458 })
459 .toSorted((left, right) => left.name.localeCompare(right.name));
460}
461
462export function openCode2CommandsToServerProviderSlashCommands(
463 commands: OpenCode2Workspace["commands"],
464): ReadonlyArray<ServerProviderSlashCommand> {
465 const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
466 const names = new Set([COMPACT_SLASH_COMMAND.name]);
467 for (const command of commands) {
468 const name = trimOptional(command.name);
469 if (!name || names.has(name)) continue;
470 names.add(name);
471 const description = trimOptional(command.description);
472 slashCommands.push({ name, ...(description ? { description } : {}) });
473 }
474 return slashCommands;
475}
476
477/**
478 * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised:
479 * OpenCode has no reviewer that approves routine actions. Plan mode is its
480 * `plan` agent, so the composer's mode toggle drives it.
481 */
482const OPENCODE_2_PRESENTATION = {
483 ...OPENCODE_PRESENTATION,
484 showInteractionModeToggle: true,
485 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"],
486} as const;
487
488function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities {
489 const variants = model.variants.map((variant) => variant.id);
490 const defaultVariant = inferDefaultVariant(model.providerID, variants);
491 return createModelCapabilities({
492 optionDescriptors:
493 variants.length === 0
494 ? []
495 : [
496 {
497 id: "variant",
498 label: "Reasoning",
499 type: "select",
500 options: variants.map((id) => ({
501 id,
502 label: titleCaseSlug(id),
503 ...(id === defaultVariant ? { isDefault: true } : {}),
504 })),
505 ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }),
506 },
507 ],
508 });
509}
510
511const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* (
512 settings: OpenCodeSettings,
513 version: string,
514 checkedAt: string,
515 loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>,
516) {
517 const result = yield* Effect.exit(loadModels);
518 const probe = (status: "ready" | "warning" | "error", message: string) => ({
519 installed: true,
520 version,
521 status,
522 auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) },
523 message,
524 });
525 if (result._tag === "Failure") {
526 // The detail can carry a response body; it stays in the log, not the status.
527 yield* Effect.logWarning("OpenCode 2 model list failed", result.cause);
528 return buildServerProvider({
529 presentation: OPENCODE_2_PRESENTATION,
530 enabled: true,
531 checkedAt,
532 models: providerModelsFromSettings(
533 [],
534 settings.customModels,
535 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
536 ),
537 // Compaction does not depend on the model catalog.
538 slashCommands: [COMPACT_SLASH_COMMAND],
539 probe: probe("error", "OpenCode could not load its model list."),
540 });
541 }
542 const models = providerModelsFromSettings(
543 result.value
544 .map((model) => ({
545 slug: `${model.providerID}/${model.id}`,
546 name: model.name,
547 isCustom: false,
548 capabilities: openCode2ModelCapabilities(model),
549 }))
550 .toSorted((left, right) => left.name.localeCompare(right.name)),
551 settings.customModels,
552 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
553 );
554 return buildServerProvider({
555 presentation: OPENCODE_2_PRESENTATION,
556 enabled: true,
557 checkedAt,
558 models,
559 // Every session can compact; a workspace snapshot adds that directory's commands.
560 slashCommands: [COMPACT_SLASH_COMMAND],
561 probe:
562 result.value.length > 0
563 ? probe(
564 "ready",
565 `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`,
566 )
567 : probe("warning", "OpenCode 2 is running, but it did not list any models yet."),
568 });
569});
570
571/**
572 * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary,
573 * the version endpoints for a configured server. The status check refreshes it, so an in-place
574 * upgrade re-routes the instance.
575 */
576export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
577 openCodeSettings: OpenCodeSettings,
578 cwd: string,
579 probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>,
580 loadOpenCode2Models: Effect.Effect<
581 ReadonlyArray<OpenCode2Model>,
582 OpenCodeRuntime.OpenCodeRuntimeError
583 >,
584): Effect.fn.Return<
585 ServerProviderDraft,
586 never,
587 OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner
588> {
589 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
590 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
591 const checkedAt = DateTime.formatIso(yield* DateTime.now);
592 const customModels = openCodeSettings.customModels;
593 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
594
595 const fallback = (
596 cause: unknown,
597 version: string | null = null,
598 phase: "version" | "inventory" = "version",
599 ) => {
600 const failure = formatOpenCodeProbeError({
601 cause,
602 isExternalServer,
603 phase,
604 serverUrl: openCodeSettings.serverUrl,
605 });
606 return buildServerProvider({
607 presentation: OPENCODE_PRESENTATION,
608 enabled: openCodeSettings.enabled,
609 checkedAt,
610 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
611 probe: {
612 installed: failure.installed,
613 version,
614 status: "error",
615 auth: { status: "unknown" },
616 message: failure.message,
617 },
618 });
619 };
620
621 if (!openCodeSettings.enabled) {
622 return buildServerProvider({
623 presentation: OPENCODE_PRESENTATION,
624 enabled: false,
625 checkedAt,
626 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
627 probe: {
628 installed: false,
629 version: null,
630 status: "warning",
631 auth: { status: "unknown" },
632 message: isExternalServer
633 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
634 : "OpenCode is disabled in T3 Code settings.",
635 },
636 });
637 }
638
639 const probedExit = yield* Effect.exit(
640 probeRuntime.pipe(
641 Effect.mapError(
642 (cause) =>
643 new OpenCodeProbeError({
644 cause,
645 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
646 }),
647 ),
648 ),
649 );
650 if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause));
651 const probed = probedExit.value;
652 if (probed.generation === "v2") {
653 return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models);
654 }
655 let version: string | null = probed.version;
656 if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) {
657 return buildServerProvider({
658 presentation: OPENCODE_PRESENTATION,
659 enabled: openCodeSettings.enabled,
660 checkedAt,
661 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
662 probe: {
663 installed: true,
664 version: probed.version,
665 status: "error",
666 auth: { status: "unknown" },
667 message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
668 },
669 });
670 }
671
672 const loadInventory = (server: {
673 readonly url: string;
674 readonly serverPassword?: string;
675 readonly version: string;
676 }) =>
677 openCodeRuntime
678 .loadOpenCodeInventory(
679 openCodeRuntime.createOpenCodeSdkClient({
680 baseUrl: server.url,
681 directory: cwd,
682 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
683 }),
684 )
685 .pipe(Effect.map((inventory) => ({ inventory, version: server.version })));
686 const inventoryEffect = isExternalServer
687 ? openCodeRuntime
688 .connectToOpenCodeServer({
689 binaryPath: openCodeSettings.binaryPath,
690 directory: cwd,
691 serverUrl: openCodeSettings.serverUrl,
692 ...(openCodeSettings.serverPassword
693 ? { serverPassword: openCodeSettings.serverPassword }
694 : {}),
695 })
696 .pipe(Effect.flatMap(loadInventory), Effect.scoped)
697 : serverOwner.withServer(loadInventory);
698 const inventoryExit = yield* Effect.exit(
699 inventoryEffect.pipe(
700 Effect.mapError(
701 (cause) =>
702 new OpenCodeProbeError({
703 cause,
704 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
705 }),
706 ),
707 ),
708 );
709 if (inventoryExit._tag === "Failure") {
710 return fallback(Cause.squash(inventoryExit.cause), version, "inventory");
711 }
712
713 version = inventoryExit.value.version;
714
715 const models = providerModelsFromSettings(
716 flattenOpenCodeModels(inventoryExit.value.inventory),
717 customModels,
718 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
719 );
720 const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills);
721 const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
722 return buildServerProvider({
723 presentation: OPENCODE_PRESENTATION,
724 enabled: true,
725 checkedAt,
726 models,
727 skills,
728 slashCommands: openCodeCommandsToServerProviderSlashCommands(
729 inventoryExit.value.inventory.commands,
730 ),
731 probe: {
732 installed: true,
733 version,
734 status: connectedCount > 0 ? "ready" : "warning",
735 auth: {
736 status: connectedCount > 0 ? "authenticated" : "unknown",
737 type: "opencode",
738 },
739 message:
740 connectedCount > 0
741 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
742 : isExternalServer
743 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
744 : "OpenCode is available, but it did not report any connected upstream providers.",
745 },
746 });
747});
apps/server/src/provider/PiProvider.test.ts created+80
......@@ -0,0 +1,80 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcess, ChildProcessSpawner } from "effect/process";
7
8import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts";
9
10const encoder = new TextEncoder();
11
12function processHandle(input: {
13 readonly stdout?: string;
14 readonly stderr?: string;
15 readonly exitCode?: number;
16}) {
17 const bytes = (value: string | undefined) =>
18 value === undefined || value.length === 0
19 ? Stream.empty
20 : Stream.succeed(encoder.encode(value));
21 return ChildProcessSpawner.makeHandle({
22 pid: ChildProcessSpawner.ProcessId(900_000_001),
23 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
24 isRunning: Effect.succeed(false),
25 kill: () => Effect.void,
26 unref: Effect.succeed(Effect.void),
27 stdin: Sink.drain,
28 stdout: bytes(input.stdout),
29 stderr: bytes(input.stderr),
30 all: Stream.empty,
31 getInputFd: () => Sink.drain,
32 getOutputFd: () => Stream.empty,
33 });
34}
35
36function piProbeSpawner(version: string) {
37 return ChildProcessSpawner.make((command) => {
38 const args = ChildProcess.isStandardCommand(command) ? command.args : [];
39 return Effect.succeed(
40 args.includes("--version")
41 ? processHandle({ stdout: `pi ${version}\n` })
42 : processHandle({ stderr: "RPC startup failed", exitCode: 1 }),
43 );
44 });
45}
46
47const settings = {
48 enabled: true,
49 binaryPath: "pi",
50 launchArgs: "",
51 customModels: [],
52} as const;
53
54describe("PiProvider", () => {
55 it.effect("requires the first published Pi version with entries and settlement hooks", () =>
56 Effect.gen(function* () {
57 const snapshot = yield* checkPiProviderStatus(settings).pipe(
58 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")),
59 );
60 assert.equal(snapshot.status, "error");
61 assert.equal(snapshot.version, "0.80.3");
62 assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`);
63 }).pipe(Effect.provide(NodeServices.layer)),
64 );
65
66 it.effect("keeps compatible Pi selectable when optional discovery fails", () =>
67 Effect.gen(function* () {
68 const snapshot = yield* checkPiProviderStatus(settings).pipe(
69 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")),
70 );
71 assert.equal(snapshot.status, "ready");
72 assert.equal(snapshot.auth.status, "unknown");
73 assert.deepEqual(
74 snapshot.models.map((model) => model.slug),
75 ["default"],
76 );
77 assert.include(snapshot.message ?? "", "could not refresh its models and commands");
78 }).pipe(Effect.provide(NodeServices.layer)),
79 );
80});
apps/server/src/provider/PiProvider.ts created+441
......@@ -0,0 +1,441 @@
1/**
2 * PiProvider — snapshot/probe layer for the Pi coding agent.
3 *
4 * Health is probed with `pi --version`. Models, the user's default model, and
5 * the user's commands (extension slash commands, prompt templates, skills)
6 * are discovered through a short-lived ephemeral RPC session
7 * (`pi --mode rpc --no-session`), so everything the user configured in
8 * `~/.pi/agent` — custom providers, models.json entries, extensions, skills —
9 * shows up in T3 without any hardcoded catalog.
10 */
11import {
12 type CustomModelSetting,
13 type PiSettings,
14 type ServerProvider,
15 type ServerProviderModel,
16} from "@t3tools/contracts";
17import { causeErrorTag } from "@t3tools/shared/observability";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
19import { compareSemverVersions } from "@t3tools/shared/semver";
20import * as DateTime from "effect/DateTime";
21import * as Effect from "effect/Effect";
22import * as Stream from "effect/Stream";
23import * as Exit from "effect/Exit";
24import * as Option from "effect/Option";
25import * as Result from "effect/Result";
26import { HttpClient } from "effect/http";
27import { ChildProcess, ChildProcessSpawner } from "effect/process";
28
29import {
30 buildPiRpcLaunch,
31 resolvePiLaunchArgs,
32} from "../orchestration-v2/Adapters/piT3McpInjection.ts";
33import {
34 makePiRpcConnection,
35 piRecordField as recordField,
36 piRecordString as recordString,
37} from "../orchestration-v2/Adapters/PiRpc.ts";
38import {
39 buildServerProvider,
40 isCommandMissingCause,
41 parseGenericCliVersion,
42 providerModelsFromSettings,
43 spawnAndCollect,
44 type ServerProviderDraft,
45} from "./providerSnapshot.ts";
46import {
47 enrichProviderSnapshotWithVersionAdvisory,
48 type ProviderMaintenanceCapabilities,
49} from "./providerMaintenance.ts";
50import {
51 EMPTY_PI_MODEL_CAPABILITIES,
52 thinkingCapabilitiesForPiModel,
53} from "./piThinkingCapabilities.ts";
54import {
55 parsePiDiscoveredCommands,
56 withPiBuiltinSlashCommands,
57 type PiDiscoveredCommands,
58} from "./PiCommands.ts";
59
60const PI_PRESENTATION = {
61 displayName: "Pi",
62 showInteractionModeToggle: false,
63 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"],
64 // The adapter reports context usage from Pi's streaming usage while a
65 // turn runs, so clients can reserve the meter before the first settle.
66 reportsContextWindow: true,
67 requiresNewThreadForModelChange: false,
68} as const;
69
70const VERSION_PROBE_TIMEOUT_MS = 4_000;
71const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000;
72/**
73 * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4.
74 * Version 0.80.5 was the first published package containing both hooks. T3
75 * needs them for rollback boundaries and reliable turn terminalization.
76 */
77export const MINIMUM_PI_VERSION = "0.80.5";
78
79/** Deferring to the user's own settings.json default model. */
80const PI_DEFAULT_MODEL: ServerProviderModel = {
81 slug: "default",
82 name: "Pi default",
83 isCustom: false,
84 capabilities: EMPTY_PI_MODEL_CAPABILITIES,
85};
86
87interface PiDiscovery extends PiDiscoveredCommands {
88 readonly models: ReadonlyArray<ServerProviderModel>;
89 readonly authenticated: boolean;
90}
91
92function piModelsFromSettings(
93 customModels: ReadonlyArray<CustomModelSetting> | undefined,
94 discovered: ReadonlyArray<ServerProviderModel> = [],
95): ReadonlyArray<ServerProviderModel> {
96 return providerModelsFromSettings(
97 [PI_DEFAULT_MODEL, ...discovered],
98 customModels ?? [],
99 EMPTY_PI_MODEL_CAPABILITIES,
100 );
101}
102
103function parseDiscoveredModels(
104 data: unknown,
105 defaultThinkingLevel: unknown,
106): ReadonlyArray<ServerProviderModel> {
107 const models = recordField(data, "models");
108 if (!Array.isArray(models)) return [];
109 const seen = new Set<string>();
110 const parsed: Array<ServerProviderModel> = [];
111 for (const model of models) {
112 const provider = recordString(model, "provider");
113 const id = recordString(model, "id");
114 if (provider === undefined || id === undefined) continue;
115 const slug = `${provider}/${id}`;
116 if (seen.has(slug)) continue;
117 seen.add(slug);
118 parsed.push({
119 slug,
120 name: recordString(model, "name") ?? slug,
121 isCustom: false,
122 capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel),
123 });
124 }
125 return parsed;
126}
127
128const discoverPiViaRpc = (
129 piSettings: PiSettings,
130 environment: NodeJS.ProcessEnv,
131 launchArgs: ReadonlyArray<string>,
132 cwd?: string,
133) =>
134 Effect.gen(function* () {
135 const launch = buildPiRpcLaunch({
136 launchArgs,
137 environment,
138 mcpSession: undefined,
139 extensionPath: undefined,
140 ephemeral: true,
141 });
142 const connection = yield* makePiRpcConnection({
143 command: piSettings.binaryPath || "pi",
144 args: launch.args,
145 cwd,
146 env: launch.env,
147 });
148 yield* Stream.fromQueue(connection.events).pipe(
149 Stream.runDrain,
150 Effect.ignore,
151 Effect.forkScoped,
152 );
153 const stateData = yield* connection.request({ type: "get_state" });
154 const modelsData = yield* connection.request({ type: "get_available_models" });
155 const commandsData = yield* connection
156 .request({ type: "get_commands" })
157 .pipe(Effect.orElseSucceed(() => undefined));
158 const discoveredModels = parseDiscoveredModels(
159 modelsData,
160 recordString(stateData, "thinkingLevel"),
161 );
162 const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData);
163 return {
164 models: discoveredModels,
165 slashCommands: withPiBuiltinSlashCommands(slashCommands),
166 skills,
167 authenticated: discoveredModels.length > 0,
168 } satisfies PiDiscovery;
169 }).pipe(Effect.scoped);
170
171const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) =>
172 Effect.gen(function* () {
173 const command = piSettings.binaryPath || "pi";
174 const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], {
175 env: environment,
176 });
177 return yield* spawnAndCollect(
178 command,
179 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
180 env: environment,
181 shell: spawnCommand.shell,
182 }),
183 );
184 });
185
186export function buildInitialPiProviderSnapshot(
187 piSettings: PiSettings,
188): Effect.Effect<ServerProviderDraft> {
189 return Effect.gen(function* () {
190 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
191 const models = piModelsFromSettings(piSettings.customModels);
192 if (!piSettings.enabled) {
193 return buildServerProvider({
194 presentation: PI_PRESENTATION,
195 enabled: false,
196 checkedAt,
197 models,
198 probe: {
199 installed: false,
200 version: null,
201 status: "warning",
202 auth: { status: "unknown" },
203 message: "Pi is disabled in T3 Code settings.",
204 },
205 });
206 }
207 return buildServerProvider({
208 presentation: PI_PRESENTATION,
209 enabled: true,
210 checkedAt,
211 models,
212 probe: {
213 installed: true,
214 version: null,
215 status: "warning",
216 auth: { status: "unknown" },
217 message: "Checking Pi CLI availability...",
218 },
219 });
220 });
221}
222
223export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* (
224 piSettings: PiSettings,
225 environment: NodeJS.ProcessEnv = process.env,
226 cwd?: string,
227): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> {
228 const checkedAt = DateTime.formatIso(yield* DateTime.now);
229 const fallbackModels = piModelsFromSettings(piSettings.customModels);
230
231 if (!piSettings.enabled) {
232 return buildServerProvider({
233 presentation: PI_PRESENTATION,
234 enabled: false,
235 checkedAt,
236 models: fallbackModels,
237 probe: {
238 installed: false,
239 version: null,
240 status: "warning",
241 auth: { status: "unknown" },
242 message: "Pi is disabled in T3 Code settings.",
243 },
244 });
245 }
246
247 const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe(
248 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
249 Effect.result,
250 );
251
252 if (Result.isFailure(versionResult)) {
253 const error = versionResult.failure;
254 yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag });
255 return buildServerProvider({
256 presentation: PI_PRESENTATION,
257 enabled: piSettings.enabled,
258 checkedAt,
259 models: fallbackModels,
260 probe: {
261 installed: !isCommandMissingCause(error),
262 version: null,
263 status: "error",
264 auth: { status: "unknown" },
265 message: isCommandMissingCause(error)
266 ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`."
267 : "Failed to execute Pi CLI health check.",
268 },
269 });
270 }
271
272 if (Option.isNone(versionResult.success)) {
273 return buildServerProvider({
274 presentation: PI_PRESENTATION,
275 enabled: piSettings.enabled,
276 checkedAt,
277 models: fallbackModels,
278 probe: {
279 installed: true,
280 version: null,
281 status: "error",
282 auth: { status: "unknown" },
283 message: "Pi CLI is installed but timed out while running `pi --version`.",
284 },
285 });
286 }
287
288 const versionOutput = versionResult.success.value;
289 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
290 if (versionOutput.code !== 0) {
291 return buildServerProvider({
292 presentation: PI_PRESENTATION,
293 enabled: piSettings.enabled,
294 checkedAt,
295 models: fallbackModels,
296 probe: {
297 installed: true,
298 version,
299 status: "error",
300 auth: { status: "unknown" },
301 message: "Pi CLI is installed but failed to run.",
302 },
303 });
304 }
305
306 if (version === null) {
307 return buildServerProvider({
308 presentation: PI_PRESENTATION,
309 enabled: piSettings.enabled,
310 checkedAt,
311 models: fallbackModels,
312 probe: {
313 installed: true,
314 version: null,
315 status: "error",
316 auth: { status: "unknown" },
317 message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`,
318 },
319 });
320 }
321
322 if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) {
323 return buildServerProvider({
324 presentation: PI_PRESENTATION,
325 enabled: piSettings.enabled,
326 checkedAt,
327 models: fallbackModels,
328 probe: {
329 installed: true,
330 version,
331 status: "error",
332 auth: { status: "unknown" },
333 message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`,
334 },
335 });
336 }
337
338 const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs);
339 if (!resolvedLaunchArgs.ok) {
340 return buildServerProvider({
341 presentation: PI_PRESENTATION,
342 enabled: piSettings.enabled,
343 checkedAt,
344 models: fallbackModels,
345 probe: {
346 installed: true,
347 version,
348 status: "error",
349 auth: { status: "unknown" },
350 message: resolvedLaunchArgs.message,
351 },
352 });
353 }
354
355 const discoveryExit = yield* discoverPiViaRpc(
356 piSettings,
357 environment,
358 resolvedLaunchArgs.args,
359 cwd,
360 ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit);
361 if (Exit.isFailure(discoveryExit)) {
362 yield* Effect.logWarning("Pi RPC discovery failed.", {
363 errorTag: causeErrorTag(discoveryExit.cause),
364 });
365 return buildServerProvider({
366 presentation: PI_PRESENTATION,
367 enabled: piSettings.enabled,
368 checkedAt,
369 models: fallbackModels,
370 probe: {
371 installed: true,
372 version,
373 status: "ready",
374 auth: { status: "unknown" },
375 message:
376 "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.",
377 },
378 });
379 }
380 if (Option.isNone(discoveryExit.value)) {
381 return buildServerProvider({
382 presentation: PI_PRESENTATION,
383 enabled: piSettings.enabled,
384 checkedAt,
385 models: fallbackModels,
386 probe: {
387 installed: true,
388 version,
389 status: "ready",
390 auth: { status: "unknown" },
391 message:
392 "Pi is available, but model and command discovery needs interactive input. The live session will handle it.",
393 },
394 });
395 }
396
397 const discovery = discoveryExit.value.value;
398 const models = piModelsFromSettings(piSettings.customModels, discovery.models);
399 return buildServerProvider({
400 presentation: PI_PRESENTATION,
401 enabled: piSettings.enabled,
402 checkedAt,
403 models,
404 slashCommands: discovery.slashCommands,
405 skills: discovery.skills,
406 probe: {
407 installed: true,
408 version,
409 status: discovery.authenticated ? "ready" : "warning",
410 auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" },
411 ...(discovery.authenticated
412 ? {}
413 : {
414 message:
415 "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.",
416 }),
417 },
418 });
419});
420
421export const enrichPiSnapshot = (input: {
422 readonly snapshot: ServerProvider;
423 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
424 readonly enableProviderUpdateChecks?: boolean;
425 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
426 readonly httpClient: HttpClient.HttpClient;
427}): Effect.Effect<void> => {
428 const { snapshot, publishSnapshot } = input;
429 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
430 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
431 }).pipe(
432 Effect.provideService(HttpClient.HttpClient, input.httpClient),
433 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
434 Effect.catchCause((cause) =>
435 Effect.logWarning("Pi version advisory enrichment failed", {
436 errorTag: causeErrorTag(cause),
437 }),
438 ),
439 Effect.asVoid,
440 );
441};
apps/server/src/provider/ProviderAuthFlow.ts+1-1
......@@ -21,7 +21,7 @@ import * as Semaphore from "effect/Semaphore";
2121import * as Stream from "effect/Stream";
2222import * as SubscriptionRef from "effect/SubscriptionRef";
2323
24import type * as ProviderAuthService from "./Services/ProviderAuthService.ts";
24import type * as ProviderAuthService from "./ProviderAuthService.ts";
2525
2626export interface ProviderAuthFlowContext {
2727 readonly flowId: string;
apps/server/src/provider/ProviderAuthService.test.ts created+979
......@@ -0,0 +1,979 @@
1import { assert, describe, it } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 ProviderSessionId,
6 ProviderSetupError,
7 ProviderThreadId,
8 ThreadId,
9 type OrchestrationV2ProviderSession,
10 type OrchestrationV2ThreadShell,
11 type ProviderAuthState,
12} from "@t3tools/contracts";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Deferred from "effect/Deferred";
16import * as Fiber from "effect/Fiber";
17import * as Layer from "effect/Layer";
18import * as PubSub from "effect/PubSub";
19import * as Queue from "effect/Queue";
20import * as Stream from "effect/Stream";
21import * as SubscriptionRef from "effect/SubscriptionRef";
22
23import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
24import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
25import { AcpProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AcpAdapterV2.ts";
26import type { ProviderInstance } from "./ProviderDriver.ts";
27import * as ProviderAuthService from "./ProviderAuthService.ts";
28import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
29
30const instanceId = ProviderInstanceId.make("antigravity-personal");
31const otherInstanceId = ProviderInstanceId.make("antigravity-work");
32const unsupportedInstanceId = ProviderInstanceId.make("codex");
33const driverKind = ProviderDriverKind.make("antigravity");
34const owner = "paired-client-owner";
35const otherOwner = "paired-client-other";
36const flowId = "test-sign-in-flow";
37const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code";
38const now = "2026-09-02T00:00:00.000Z";
39const idleAuthState: ProviderAuthState = {
40 instanceId,
41 phase: "idle",
42 flowId: null,
43 authorizationUrl: null,
44 expiresAt: null,
45 message: null,
46};
47const waitingAuthState: ProviderAuthState = {
48 ...idleAuthState,
49 phase: "waiting",
50 flowId,
51 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state",
52 expiresAt: "2026-09-02T00:05:00.000Z",
53};
54
55function makeInstance(input: {
56 instanceId: ProviderInstanceId;
57 enabled: boolean;
58 auth?: ProviderAuthService.ProviderAuthController;
59}): ProviderInstance {
60 return {
61 ...input,
62 driverKind,
63 displayName: undefined,
64 continuationIdentity: { driverKind, continuationKey: input.instanceId },
65 get snapshot(): never {
66 throw new Error("Auth routing must not refresh the provider snapshot.");
67 },
68 get orchestrationAdapter(): never {
69 throw new Error("Auth routing must not start an adapter session.");
70 },
71 get textGeneration(): never {
72 throw new Error("Auth routing must not generate text.");
73 },
74 };
75}
76
77const nowUtc = DateTime.makeUnsafe(now);
78
79/**
80 * A thread bound to a provider instance with (or without) a live native
81 * thread. The auth service reads only the routing fields, so the rest of the
82 * shell row is left out.
83 */
84function makeThread(
85 thread: string,
86 input: { readonly providerInstanceId?: ProviderInstanceId; readonly active?: boolean } = {},
87): OrchestrationV2ThreadShell {
88 const shell: Pick<
89 OrchestrationV2ThreadShell,
90 "id" | "providerInstanceId" | "activeProviderThreadId"
91 > = {
92 id: ThreadId.make(thread),
93 providerInstanceId: input.providerInstanceId ?? instanceId,
94 activeProviderThreadId:
95 input.active === false ? null : ProviderThreadId.make(`${thread}:native`),
96 };
97 return shell as unknown as OrchestrationV2ThreadShell;
98}
99
100function makeSession(
101 session: string,
102 status: OrchestrationV2ProviderSession["status"] = "ready",
103 providerInstanceId = instanceId,
104): OrchestrationV2ProviderSession {
105 return {
106 id: ProviderSessionId.make(session),
107 driver: driverKind,
108 providerInstanceId,
109 status,
110 cwd: "/workspace",
111 model: null,
112 capabilities: AcpProviderCapabilitiesV2,
113 createdAt: nowUtc,
114 updatedAt: nowUtc,
115 lastError: null,
116 };
117}
118
119function sessionThreads(list: ReadonlyArray<OrchestrationV2ProviderSession>) {
120 return {
121 threads: list.map((session) => makeThread(session.id)),
122 sessions: new Map(list.map((session) => [ThreadId.make(session.id), [session]])),
123 };
124}
125
126const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* (
127 input: {
128 enabled?: boolean;
129 threads?: ReadonlyArray<OrchestrationV2ThreadShell>;
130 sessions?: ReadonlyMap<ThreadId, ReadonlyArray<OrchestrationV2ProviderSession>>;
131 shellError?: boolean;
132 stopError?: boolean;
133 logoutError?: ProviderSetupError;
134 sharedCredentials?: boolean;
135 sharedBusy?: boolean;
136 sharedBusyEffect?: Effect.Effect<boolean>;
137 responds?: boolean;
138 beforeLogout?: Effect.Effect<void>;
139 beforeStop?: Effect.Effect<void>;
140 beforeListSessions?: Effect.Effect<void>;
141 onLookup?: Effect.Effect<void>;
142 onListThreads?: (instances: ProviderInstance[]) => void;
143 } = {},
144) {
145 const actions: string[] = [];
146 const registryChanges = yield* PubSub.unbounded<void>();
147 const threads = input.threads ?? [];
148 const sessions = new Map(
149 [...(input.sessions?.entries() ?? [])].map(([threadId, list]) => [threadId, [...list]]),
150 );
151 const released: string[] = [];
152 const idle = idleAuthState;
153 let state = idle;
154 let flowOwner: string | undefined;
155 let gateClosed = false;
156
157 const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* (
158 ownerSessionId: string,
159 requestedFlowId: string,
160 operation: string,
161 ) {
162 if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) {
163 return yield* new ProviderSetupError({
164 instanceId,
165 operation,
166 detail: "This sign-in belongs to another client or has expired.",
167 });
168 }
169 });
170
171 const auth: ProviderAuthService.ProviderAuthController = {
172 ...(input.sharedCredentials
173 ? { credentialBinding: { owner: "provider" as const, key: "shared" } }
174 : {}),
175 ...(input.responds
176 ? {
177 respond: Effect.fn(function* (ownerSessionId, request) {
178 yield* checkOwner(ownerSessionId, request.flowId, "respond");
179 actions.push(`respond:${request.response.type}`);
180 return state;
181 }),
182 }
183 : {}),
184 start: Effect.fn(function* (ownerSessionId, stopSessions) {
185 gateClosed = true;
186 actions.push("close-gate");
187 yield* stopSessions ?? Effect.void;
188 flowOwner = ownerSessionId;
189 state = waitingAuthState;
190 actions.push("start-sign-in");
191 return state;
192 }),
193 complete: Effect.fn(function* (ownerSessionId, request) {
194 yield* checkOwner(ownerSessionId, request.flowId, "complete");
195 if (request.callbackUrl !== callbackUrl) {
196 return yield* new ProviderSetupError({
197 instanceId,
198 operation: "complete",
199 detail: "The redirect URL does not match this sign-in.",
200 });
201 }
202 state = { ...idle, flowId, phase: "succeeded" };
203 return state;
204 }),
205 cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) {
206 yield* checkOwner(ownerSessionId, requestedFlowId, "cancel");
207 state = { ...idle, flowId, phase: "cancelled" };
208 return state;
209 }),
210 logout: Effect.fn(function* (stopSessions) {
211 gateClosed = true;
212 actions.push("close-gate");
213 yield* stopSessions;
214 if (input.logoutError) return yield* input.logoutError;
215 yield* input.beforeLogout ?? Effect.void;
216 actions.push("native-logout");
217 state = idle;
218 flowOwner = undefined;
219 return state;
220 }),
221 subscribe: (ownerSessionId) =>
222 Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))),
223 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
224 };
225 const instances = [
226 makeInstance({ instanceId, enabled: input.enabled ?? true, auth }),
227 makeInstance({ instanceId: unsupportedInstanceId, enabled: true }),
228 ...(input.sharedCredentials
229 ? [
230 makeInstance({
231 instanceId: otherInstanceId,
232 enabled: true,
233 auth: {
234 ...auth,
235 isChangingCredentials:
236 input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false),
237 invalidate: Effect.sync(() => {
238 actions.push("invalidate-shared");
239 }),
240 },
241 }),
242 ]
243 : []),
244 ];
245 const service = yield* ProviderAuthService.makeProviderAuthService.pipe(
246 Effect.provide(
247 Layer.mergeAll(
248 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
249 getInstance: (id) =>
250 Effect.gen(function* () {
251 const found = instances.find((instance) => instance.instanceId === id);
252 yield* input.onLookup ?? Effect.void;
253 return found;
254 }),
255 listInstances: Effect.succeed(instances),
256 subscribeChanges: PubSub.subscribe(registryChanges),
257 }),
258 Layer.mock(ProjectionStore.ProjectionStoreV2)({
259 getRecoveryThreadIds: () =>
260 Effect.gen(function* () {
261 assert.isTrue(gateClosed);
262 actions.push("list-threads");
263 input.onListThreads?.(instances);
264 yield* input.beforeListSessions ?? Effect.void;
265 return yield* input.shellError
266 ? Effect.fail(
267 new ProjectionStore.ProjectionStoreReadError({
268 threadId: ThreadId.make("shell"),
269 cause: new Error("private database diagnostics"),
270 }),
271 )
272 : Effect.succeed(threads.map((thread) => thread.id));
273 }),
274 getThreadRecords: (threadId) =>
275 Effect.sync(() => {
276 actions.push(`sessions:${threadId}`);
277 return {
278 providerSessions: sessions.get(threadId) ?? [],
279 } as never;
280 }),
281 }),
282 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
283 release: ({ providerSessionId, reason }) =>
284 Effect.gen(function* () {
285 assert.isTrue(gateClosed);
286 actions.push(`stop:${providerSessionId}`);
287 yield* input.beforeStop ?? Effect.void;
288 if (input.stopError) {
289 return yield* Effect.fail(
290 new ProviderSessionManager.ProviderSessionReleaseError({
291 providerSessionId,
292 reason,
293 cause: new Error("private process diagnostics"),
294 }),
295 );
296 }
297 released.push(providerSessionId);
298 for (const [threadId, list] of sessions) {
299 const remaining = list.filter((session) => session.id !== providerSessionId);
300 if (remaining.length === 0) sessions.delete(threadId);
301 else sessions.set(threadId, remaining);
302 }
303 }),
304 }),
305 ),
306 ),
307 );
308 return {
309 service,
310 actions,
311 released,
312 sessions,
313 auth,
314 addInstance: (instance: ProviderInstance) => instances.push(instance),
315 replaceInstance: (replacement: ProviderInstance) => {
316 const index = instances.findIndex(
317 (instance) => instance.instanceId === replacement.instanceId,
318 );
319 assert.isAtLeast(index, 0);
320 instances[index] = replacement;
321 },
322 replaceAuth: (next: ProviderAuthService.ProviderAuthController) => {
323 instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next });
324 },
325 };
326});
327
328const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")(
329 function* (flowOwner: string) {
330 const state = yield* SubscriptionRef.make(idleAuthState);
331 const close = yield* Deferred.make<void>();
332 const closedSubscriptions = yield* Queue.unbounded<string>();
333 const unused = () => Effect.die("Unexpected auth operation in a subscription test.");
334 const auth: ProviderAuthService.ProviderAuthController = {
335 start: unused,
336 complete: unused,
337 cancel: unused,
338 logout: unused,
339 subscribe: (ownerSessionId) =>
340 SubscriptionRef.changes(state).pipe(
341 Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)),
342 Stream.interruptWhen(Deferred.await(close)),
343 Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)),
344 ),
345 };
346 return {
347 auth,
348 state,
349 closedSubscriptions,
350 close: Deferred.succeed(close, undefined),
351 };
352 },
353);
354
355const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")(
356 function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) {
357 const changes = yield* PubSub.unbounded<void>();
358 let current: ProviderInstance | undefined = initial;
359 let pendingReplacement = replaceDuringFirstLookup;
360 let subscribed = false;
361 const service = yield* ProviderAuthService.makeProviderAuthService.pipe(
362 Effect.provide(
363 Layer.mergeAll(
364 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
365 subscribeChanges: Effect.gen(function* () {
366 const subscription = yield* PubSub.subscribe(changes);
367 subscribed = true;
368 return subscription;
369 }),
370 getInstance: () =>
371 Effect.gen(function* () {
372 const instance = current;
373 if (pendingReplacement) {
374 assert.isTrue(subscribed, "Registry changes must be subscribed before lookup.");
375 current = pendingReplacement;
376 pendingReplacement = undefined;
377 yield* PubSub.publish(changes, undefined);
378 }
379 return instance;
380 }),
381 }),
382 Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
383 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}),
384 ),
385 ),
386 );
387 return {
388 service,
389 replace: Effect.fn(function* (replacement: ProviderInstance | undefined) {
390 current = replacement;
391 yield* PubSub.publish(changes, undefined);
392 }),
393 };
394 },
395);
396
397const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* (
398 stream: Stream.Stream<ProviderAuthState, ProviderSetupError>,
399) {
400 const states = yield* Queue.unbounded<ProviderAuthState>();
401 const fiber = yield* stream.pipe(
402 Stream.runForEach((state) => Queue.offer(states, state)),
403 Effect.forkScoped,
404 );
405 return { states, fiber };
406});
407
408describe("ProviderAuthService", () => {
409 it.effect(
410 "stops sessions sharing credentials and invalidates their processes before logout",
411 () =>
412 Effect.gen(function* () {
413 const { service, actions, sessions } = yield* makeHarness({
414 sharedCredentials: true,
415 ...sessionThreads([
416 makeSession("target"),
417 makeSession("shared", "ready", otherInstanceId),
418 makeSession("unrelated", "ready", unsupportedInstanceId),
419 ]),
420 });
421 yield* service.logout({ instanceId });
422 assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]);
423 assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared"));
424 assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout"));
425 }),
426 );
427 it.effect("rejects overlapping changes to a shared sign-in", () =>
428 Effect.gen(function* () {
429 const { service, actions } = yield* makeHarness({
430 sharedCredentials: true,
431 sharedBusy: true,
432 });
433 for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) {
434 const error = yield* task.pipe(Effect.flip);
435 assert.include(error.detail, "shared sign-in");
436 }
437 assert.deepStrictEqual(actions, []);
438 }),
439 );
440 it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () =>
441 Effect.gen(function* () {
442 const { service, actions } = yield* makeHarness({ responds: true });
443 yield* service.start({ instanceId }, owner);
444 const request = {
445 instanceId,
446 flowId,
447 interactionId: "consent",
448 response: { type: "browser" as const, action: "accept" as const },
449 };
450 const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip);
451 assert.strictEqual(rejected.operation, "respond");
452 yield* service.respond(request, owner);
453 assert.strictEqual(actions.at(-1), "respond:browser");
454 const unsupported = yield* makeHarness();
455 const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip);
456 assert.include(error.detail, "does not accept");
457 }),
458 );
459 it.effect("stops routed sessions before sign-in, including for a disabled instance", () =>
460 Effect.gen(function* () {
461 const { service, actions, released } = yield* makeHarness({
462 enabled: false,
463 threads: [makeThread("active")],
464 sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]),
465 });
466 const state = yield* service.start({ instanceId }, owner);
467
468 assert.strictEqual(state.instanceId, instanceId);
469 assert.strictEqual(state.phase, "waiting");
470 assert.deepStrictEqual(released, ["active-session"]);
471 assert.deepStrictEqual(actions, [
472 "close-gate",
473 "list-threads",
474 "sessions:active",
475 "stop:active-session",
476 "start-sign-in",
477 ]);
478 }),
479 );
480
481 it.effect("keeps sign-in state private and accepts the owner's redirect URL", () =>
482 Effect.gen(function* () {
483 const { service } = yield* makeHarness();
484 const waiting = yield* service.start({ instanceId }, owner);
485 const ownerStates = yield* service
486 .subscribe({ instanceId }, owner)
487 .pipe(Stream.take(1), Stream.runCollect);
488 const otherStates = yield* service
489 .subscribe({ instanceId }, otherOwner)
490 .pipe(Stream.take(1), Stream.runCollect);
491
492 assert.deepStrictEqual(ownerStates, [waiting]);
493 assert.strictEqual(otherStates[0]?.authorizationUrl, null);
494 assert.strictEqual(otherStates[0]?.flowId, null);
495
496 const otherError = yield* Effect.flip(
497 service.complete({ instanceId, flowId, callbackUrl }, otherOwner),
498 );
499 assert.strictEqual(otherError.operation, "complete");
500 const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner);
501 assert.strictEqual(complete.phase, "succeeded");
502 assert.strictEqual(complete.authorizationUrl, null);
503 }),
504 );
505
506 it.effect("lets only the flow owner cancel sign-in", () =>
507 Effect.gen(function* () {
508 const { service } = yield* makeHarness();
509 yield* service.start({ instanceId }, owner);
510 const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner));
511 assert.strictEqual(error.operation, "cancel");
512
513 const cancelled = yield* service.cancel({ instanceId, flowId }, owner);
514 assert.strictEqual(cancelled.phase, "cancelled");
515 assert.strictEqual(cancelled.authorizationUrl, null);
516 }),
517 );
518
519 it.effect.each([
520 { change: "enable", initialEnabled: false, closeBeforeReplacement: true },
521 { change: "config", initialEnabled: true, closeBeforeReplacement: false },
522 ])(
523 "keeps private auth subscriptions current after an instance $change change",
524 ({ initialEnabled, closeBeforeReplacement }) =>
525 Effect.gen(function* () {
526 const first = yield* makeStreamingController(owner);
527 const replacement = yield* makeStreamingController(otherOwner);
528 yield* SubscriptionRef.set(first.state, waitingAuthState);
529 const { service, replace } = yield* makeSubscriptionHarness(
530 makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }),
531 );
532 const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner));
533 const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner));
534 assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState);
535 assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState);
536
537 if (closeBeforeReplacement) {
538 yield* first.close;
539 const closed = yield* Queue.takeN(first.closedSubscriptions, 2);
540 assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner]));
541 }
542 yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth }));
543 assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState);
544 assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState);
545 if (!closeBeforeReplacement) {
546 const closed = yield* Queue.takeN(first.closedSubscriptions, 2);
547 assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner]));
548 }
549
550 const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" };
551 yield* SubscriptionRef.set(first.state, staleState);
552 const newWaiting: ProviderAuthState = {
553 ...waitingAuthState,
554 flowId: "replacement-flow",
555 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state",
556 };
557 yield* SubscriptionRef.set(replacement.state, newWaiting);
558 assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState);
559 assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting);
560 }),
561 );
562
563 it.effect("does not miss a replacement during the first instance lookup", () =>
564 Effect.gen(function* () {
565 const first = yield* makeStreamingController(owner);
566 const replacement = yield* makeStreamingController(owner);
567 yield* SubscriptionRef.set(replacement.state, waitingAuthState);
568 const { service } = yield* makeSubscriptionHarness(
569 makeInstance({ instanceId, enabled: false, auth: first.auth }),
570 makeInstance({ instanceId, enabled: true, auth: replacement.auth }),
571 );
572
573 const states = yield* service.subscribe({ instanceId }, owner).pipe(
574 Stream.filter((state) => state.phase === "waiting"),
575 Stream.take(1),
576 Stream.runCollect,
577 );
578
579 assert.deepStrictEqual(states, [waitingAuthState]);
580 }),
581 );
582
583 it.effect("ends the subscription with a setup error when the instance is removed", () =>
584 Effect.gen(function* () {
585 const controller = yield* makeStreamingController(owner);
586 const { service, replace } = yield* makeSubscriptionHarness(
587 makeInstance({ instanceId, enabled: true, auth: controller.auth }),
588 );
589 const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner));
590 assert.deepStrictEqual(yield* Queue.take(states), idleAuthState);
591
592 yield* replace(undefined);
593 const error = yield* Effect.flip(Fiber.join(fiber));
594
595 assert.instanceOf(error, ProviderSetupError);
596 assert.strictEqual(error.instanceId, instanceId);
597 assert.strictEqual(error.operation, "subscribe");
598 assert.include(error.detail, "no longer available");
599 assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner);
600 }),
601 );
602
603 it.effect.each([
604 { id: ProviderInstanceId.make("missing"), detail: "no longer available" },
605 { id: unsupportedInstanceId, detail: "does not support sign-in" },
606 ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) =>
607 Effect.gen(function* () {
608 const { service, actions } = yield* makeHarness();
609 const operations: Effect.Effect<
610 ProviderAuthState | ReadonlyArray<ProviderAuthState>,
611 ProviderSetupError
612 >[] = [
613 service.start({ instanceId: id }, owner),
614 service.complete({ instanceId: id, flowId, callbackUrl }, owner),
615 service.cancel({ instanceId: id, flowId }, owner),
616 service.logout({ instanceId: id }),
617 Stream.runCollect(service.subscribe({ instanceId: id }, owner)),
618 ];
619 for (const operation of operations) {
620 const error = yield* Effect.flip(operation);
621 assert.instanceOf(error, ProviderSetupError);
622 assert.strictEqual(error.instanceId, id);
623 assert.include(error.detail, detail);
624 }
625 assert.deepStrictEqual(actions, []);
626 }),
627 );
628
629 it.effect("stops every live session on the instance before native logout", () =>
630 Effect.gen(function* () {
631 const { service, actions, released } = yield* makeHarness({
632 threads: [
633 makeThread("running"),
634 makeThread("starting"),
635 makeThread("idle", { active: false }),
636 makeThread("other-instance", { providerInstanceId: otherInstanceId }),
637 ],
638 sessions: new Map([
639 [
640 ThreadId.make("running"),
641 [
642 makeSession("running-session", "running"),
643 makeSession("stopped-session", "stopped"),
644 makeSession("errored-session", "error"),
645 makeSession("foreign-session", "ready", otherInstanceId),
646 ],
647 ],
648 [ThreadId.make("starting"), [makeSession("starting-session", "starting")]],
649 [ThreadId.make("idle"), [makeSession("idle-session")]],
650 [
651 ThreadId.make("other-instance"),
652 [makeSession("other-session", "ready", otherInstanceId)],
653 ],
654 ]),
655 });
656 const state = yield* service.logout({ instanceId });
657
658 assert.strictEqual(state.phase, "idle");
659 assert.deepStrictEqual(actions, [
660 "close-gate",
661 "list-threads",
662 "sessions:running",
663 "stop:running-session",
664 "sessions:starting",
665 "stop:starting-session",
666 "sessions:idle",
667 "stop:idle-session",
668 "sessions:other-instance",
669 "native-logout",
670 ]);
671 assert.deepStrictEqual(released, ["running-session", "starting-session", "idle-session"]);
672 }),
673 );
674
675 it.effect(
676 "releases a shared native session once after a thread's selected provider changes",
677 () =>
678 Effect.gen(function* () {
679 const shared = makeSession("shared-native-session");
680 const { service, released } = yield* makeHarness({
681 threads: [
682 makeThread("changed-selection", { providerInstanceId: otherInstanceId }),
683 makeThread("same-session"),
684 ],
685 sessions: new Map([
686 [ThreadId.make("changed-selection"), [shared]],
687 [ThreadId.make("same-session"), [shared]],
688 ]),
689 });
690
691 yield* service.logout({ instanceId });
692
693 assert.deepStrictEqual(released, [shared.id]);
694 }),
695 );
696
697 it.effect.each([
698 { text: "/logout", hasAttachments: false, handled: true },
699 { text: " \n/logout\t", hasAttachments: false, handled: true },
700 { text: "/logout", hasAttachments: true, handled: false },
701 { text: "/logout please", hasAttachments: false, handled: false },
702 { text: "Explain /logout", hasAttachments: false, handled: false },
703 { text: "/Logout", hasAttachments: false, handled: false },
704 ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) =>
705 Effect.gen(function* () {
706 const { service, actions } = yield* makeHarness();
707 const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments });
708
709 assert.strictEqual(result, handled);
710 assert.deepStrictEqual(
711 actions,
712 handled ? ["close-gate", "list-threads", "native-logout"] : [],
713 );
714 }),
715 );
716
717 it.effect("does not intercept commands for providers without an auth controller", () =>
718 Effect.gen(function* () {
719 const { service, actions } = yield* makeHarness();
720 for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) {
721 assert.isFalse(
722 yield* service.tryHandlePromptCommand({
723 instanceId: id,
724 text: "/logout",
725 hasAttachments: false,
726 }),
727 );
728 }
729 assert.deepStrictEqual(actions, []);
730 }),
731 );
732
733 it.effect("does not log out when the thread shell cannot be read", () =>
734 Effect.gen(function* () {
735 const { service, actions } = yield* makeHarness({ shellError: true });
736 const error = yield* Effect.flip(service.logout({ instanceId }));
737
738 assert.instanceOf(error, ProviderSetupError);
739 assert.strictEqual(error.instanceId, instanceId);
740 assert.strictEqual(error.operation, "stopSessions");
741 assert.notInclude(error.detail, "private database diagnostics");
742 assert.deepStrictEqual(actions, ["close-gate", "list-threads"]);
743 }),
744 );
745
746 it.effect("does not log out or consume the command when stopping a session fails", () =>
747 Effect.gen(function* () {
748 const { service, actions, released } = yield* makeHarness({
749 threads: [makeThread("active")],
750 sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]),
751 stopError: true,
752 });
753 const error = yield* Effect.flip(
754 service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }),
755 );
756
757 assert.instanceOf(error, ProviderSetupError);
758 assert.strictEqual(error.operation, "stopSessions");
759 assert.notInclude(error.detail, "private process diagnostics");
760 assert.deepStrictEqual(released, []);
761 assert.deepStrictEqual(actions, [
762 "close-gate",
763 "list-threads",
764 "sessions:active",
765 "stop:active-session",
766 ]);
767 }),
768 );
769
770 it.effect("returns native logout failures to the command caller", () =>
771 Effect.gen(function* () {
772 const logoutError = new ProviderSetupError({
773 instanceId,
774 operation: "logout",
775 detail: "Native sign-out failed. Try again.",
776 });
777 const { service } = yield* makeHarness({ logoutError });
778 const error = yield* Effect.flip(
779 service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }),
780 );
781
782 assert.strictEqual(error, logoutError);
783 }),
784 );
785});
786
787it.effect("queued logout prompts resolve the current controller after provider replacement", () =>
788 Effect.gen(function* () {
789 const entered = yield* Deferred.make<void>();
790 const release = yield* Deferred.make<void>();
791 const lookup = yield* Deferred.make<void>();
792 let observeLookup = false;
793 const harness = yield* makeHarness({
794 beforeLogout: Deferred.succeed(entered, undefined).pipe(
795 Effect.andThen(Deferred.await(release)),
796 ),
797 onLookup: Effect.suspend(() =>
798 observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void,
799 ),
800 });
801 const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild);
802 yield* Deferred.await(entered);
803 observeLookup = true;
804 const queued = yield* harness.service
805 .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false })
806 .pipe(Effect.forkChild);
807 yield* Deferred.await(lookup);
808 let replacementLoggedOut = false;
809 harness.replaceAuth({
810 ...harness.auth,
811 logout: (stopSessions) =>
812 stopSessions.pipe(
813 Effect.andThen(
814 Effect.sync(() => {
815 replacementLoggedOut = true;
816 return idleAuthState;
817 }),
818 ),
819 ),
820 });
821 yield* Deferred.succeed(release, undefined);
822 yield* Fiber.join(first);
823 assert.isTrue(yield* Fiber.join(queued));
824 assert.isTrue(replacementLoggedOut);
825 assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1);
826 }).pipe(Effect.scoped),
827);
828
829it.effect.each(["start", "logout", "prompt"] as const)(
830 "%s uses the same credential controller for shared exclusion and mutation during replacement",
831 (action) =>
832 Effect.gen(function* () {
833 const checked = yield* Deferred.make<void>();
834 const continueCheck = yield* Deferred.make<void>();
835 const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer");
836 const harness = yield* makeHarness({
837 sharedCredentials: true,
838 sharedBusyEffect: Deferred.succeed(checked, undefined).pipe(
839 Effect.andThen(Deferred.await(continueCheck)),
840 Effect.as(false),
841 ),
842 ...sessionThreads([
843 makeSession("old-shared", "ready", otherInstanceId),
844 makeSession("replacement-shared", "ready", replacementPeerId),
845 ]),
846 });
847 harness.addInstance(
848 makeInstance({
849 instanceId: replacementPeerId,
850 enabled: true,
851 auth: {
852 ...harness.auth,
853 credentialBinding: { owner: "provider", key: "replacement-binding" },
854 isChangingCredentials: Effect.succeed(true),
855 invalidate: Effect.die(
856 "The unrelated replacement credential binding must stay intact.",
857 ),
858 },
859 }),
860 );
861 const operation =
862 action === "start"
863 ? harness.service.start({ instanceId }, owner)
864 : action === "logout"
865 ? harness.service.logout({ instanceId })
866 : harness.service.tryHandlePromptCommand({
867 instanceId,
868 text: "/logout",
869 hasAttachments: false,
870 });
871 const running = yield* operation.pipe(Effect.forkChild);
872 yield* Deferred.await(checked);
873 let replacementMutations = 0;
874 harness.replaceAuth({
875 ...harness.auth,
876 credentialBinding: { owner: "provider", key: "replacement-binding" },
877 start: () =>
878 Effect.sync(() => {
879 replacementMutations++;
880 return waitingAuthState;
881 }),
882 logout: () =>
883 Effect.sync(() => {
884 replacementMutations++;
885 return idleAuthState;
886 }),
887 });
888 yield* Deferred.succeed(continueCheck, undefined);
889 yield* Fiber.join(running);
890 assert.equal(replacementMutations, 0);
891 assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout");
892 assert.isFalse(harness.sessions.has(ThreadId.make("old-shared")));
893 assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared")));
894 const blocked = yield* Effect.flip(harness.service.logout({ instanceId }));
895 assert.include(blocked.detail, "shared sign-in");
896 assert.equal(replacementMutations, 0);
897 }).pipe(Effect.scoped),
898);
899
900it.effect.each([
901 { owner: "provider" as const, key: "different-binding" },
902 { owner: "t3" as const, key: "shared" },
903])(
904 "does not invalidate a peer that switches credential binding during session draining %#",
905 (binding) =>
906 Effect.gen(function* () {
907 const draining = yield* Deferred.make<void>();
908 const continueDrain = yield* Deferred.make<void>();
909 const harness = yield* makeHarness({
910 sharedCredentials: true,
911 ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]),
912 beforeStop: Deferred.succeed(draining, undefined).pipe(
913 Effect.andThen(Deferred.await(continueDrain)),
914 ),
915 });
916 const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild);
917 yield* Deferred.await(draining);
918 let replacementInvalidated = false;
919 harness.replaceInstance(
920 makeInstance({
921 instanceId: otherInstanceId,
922 enabled: true,
923 auth: {
924 ...harness.auth,
925 credentialBinding: binding,
926 invalidate: Effect.sync(() => {
927 replacementInvalidated = true;
928 }),
929 },
930 }),
931 );
932 yield* Deferred.succeed(continueDrain, undefined);
933 yield* Fiber.join(logout);
934 assert.isFalse(replacementInvalidated);
935 assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining")));
936 assert.include(harness.actions, "native-logout");
937 }).pipe(Effect.scoped),
938);
939
940it.effect.each(["selection", "drain"] as const)(
941 "preserves replacement peer sessions when credentials change during session %s",
942 (phase) =>
943 Effect.gen(function* () {
944 const entered = yield* Deferred.make<void>();
945 const proceed = yield* Deferred.make<void>();
946 const block = Deferred.succeed(entered, undefined).pipe(
947 Effect.andThen(Deferred.await(proceed)),
948 );
949 const harness = yield* makeHarness({
950 sharedCredentials: true,
951 ...sessionThreads([
952 makeSession("target-draining"),
953 makeSession("peer-replacement", "ready", otherInstanceId),
954 makeSession("peer-persisted", "running", otherInstanceId),
955 ]),
956 ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }),
957 });
958 const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild);
959 yield* Deferred.await(entered);
960 harness.replaceInstance(
961 makeInstance({
962 instanceId: otherInstanceId,
963 enabled: true,
964 auth: {
965 ...harness.auth,
966 credentialBinding: { owner: "provider", key: "replacement-credentials" },
967 invalidate: Effect.die("The replacement peer's credentials must not be invalidated."),
968 },
969 }),
970 );
971 yield* Deferred.succeed(proceed, undefined);
972 yield* Fiber.join(logout);
973 assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement")));
974 assert.notInclude(harness.actions, "stop:peer-replacement");
975 assert.notInclude(harness.actions, "stop:peer-persisted");
976 assert.isFalse(harness.sessions.has(ThreadId.make("target-draining")));
977 assert.include(harness.actions, "native-logout");
978 }).pipe(Effect.scoped),
979);
apps/server/src/provider/ProviderAuthService.ts created+348
......@@ -0,0 +1,348 @@
1import {
2 ProviderSetupError,
3 type ChatGptReconnectProfile,
4 type ChatGptTransferredProfile,
5 type ProviderAuthRespondInput,
6 type ProviderAuthStartInput,
7 type ProviderAuthState,
8 type ProviderInstanceId,
9 type ProviderSessionId,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import type * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";
16import * as Semaphore from "effect/Semaphore";
17
18import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
19import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
20import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
21
22export interface ProviderAuthController {
23 /** Equal keys mean these instances share credentials on this environment. */
24 readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string };
25 readonly reconnectProfile?: (
26 methodId: string,
27 ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>;
28 readonly importProfile?: (
29 profile: ChatGptTransferredProfile,
30 stopSessions: Effect.Effect<void, ProviderSetupError>,
31 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
32 readonly adoptCredentials?: (
33 update: Effect.Effect<void, ProviderSetupError>,
34 stopSessions: Effect.Effect<void, ProviderSetupError>,
35 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
36 readonly isChangingCredentials?: Effect.Effect<boolean>;
37 readonly invalidate?: Effect.Effect<void>;
38 readonly refreshMethods?: Effect.Effect<void>;
39 readonly withAccess?: <A, E, R>(
40 task: Effect.Effect<A, E, R>,
41 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
42 readonly start: (
43 ownerSessionId: string,
44 stopSessions?: Effect.Effect<void, ProviderSetupError>,
45 methodId?: string,
46 returnUrl?: string,
47 callbackMode?: "server" | "client",
48 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
49 readonly complete: (
50 ownerSessionId: string,
51 input: { readonly flowId: string; readonly callbackUrl: string },
52 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
53 readonly cancel: (
54 ownerSessionId: string,
55 flowId: string,
56 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
57 readonly respond?: (
58 ownerSessionId: string,
59 input: ProviderAuthRespondInput,
60 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
61 /** The controller closes process admission before it stops routed sessions. */
62 readonly logout: (
63 stopSessions: Effect.Effect<void, ProviderSetupError>,
64 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
65 readonly subscribe: (ownerSessionId: string) => Stream.Stream<ProviderAuthState>;
66 readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean;
67}
68
69interface ProviderAuthTarget {
70 readonly instanceId: ProviderInstanceId;
71}
72
73export class ProviderAuthService extends Context.Service<
74 ProviderAuthService,
75 {
76 readonly reconnectProfile: (
77 input: ProviderAuthTarget & { methodId: string },
78 ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>;
79 readonly importProfile: (
80 input: ProviderAuthTarget & { profile: ChatGptTransferredProfile },
81 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
82 readonly start: (
83 input: ProviderAuthStartInput,
84 ownerSessionId: string,
85 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
86 readonly complete: (
87 input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string },
88 ownerSessionId: string,
89 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
90 readonly respond: (
91 input: ProviderAuthRespondInput,
92 ownerSessionId: string,
93 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
94 readonly cancel: (
95 input: ProviderAuthTarget & { readonly flowId: string },
96 ownerSessionId: string,
97 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
98 readonly logout: (
99 input: ProviderAuthTarget,
100 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
101 readonly subscribe: (
102 input: ProviderAuthTarget,
103 ownerSessionId: string,
104 ) => Stream.Stream<ProviderAuthState, ProviderSetupError>;
105 readonly tryHandlePromptCommand: (
106 input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean },
107 ) => Effect.Effect<boolean, ProviderSetupError>;
108 }
109>()("t3/provider/ProviderAuthService") {}
110
111export const makeProviderAuthService = Effect.gen(function* () {
112 const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
113 const projections = yield* ProjectionStore.ProjectionStoreV2;
114 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
115 const credentialChanges = yield* Semaphore.make(1);
116
117 const getController = Effect.fn("ProviderAuthService.getController")(function* (
118 instanceId: ProviderInstanceId,
119 operation: string,
120 ) {
121 const instance = yield* registry.getInstance(instanceId);
122 if (!instance?.auth) {
123 return yield* new ProviderSetupError({
124 instanceId,
125 operation,
126 detail: instance
127 ? "This provider does not support sign-in in T3 Code."
128 : "This provider instance is no longer available.",
129 });
130 }
131 return instance.auth;
132 });
133
134 // Native sessions may still belong to the previous provider after the
135 // selected model changes. Read session bindings, not the selected model,
136 // when invalidating credentials for sign-in or sign-out.
137 const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* (
138 instanceId: ProviderInstanceId,
139 binding: ProviderAuthController["credentialBinding"],
140 ) {
141 const failure = (detail: string) =>
142 new ProviderSetupError({ instanceId, operation: "stopSessions", detail });
143 const affectedIds = new Set([
144 instanceId,
145 ...(binding === undefined
146 ? []
147 : (yield* registry.listInstances)
148 .filter(
149 (instance) =>
150 instance.auth?.credentialBinding?.key === binding.key &&
151 instance.auth.credentialBinding.owner === binding.owner,
152 )
153 .map((instance) => instance.instanceId)),
154 ]);
155 const threadIds = yield* projections
156 .getRecoveryThreadIds("runtime")
157 .pipe(
158 Effect.mapError(() => failure("Could not read the provider's active sessions. Try again.")),
159 );
160 const released = new Set<ProviderSessionId>();
161 yield* Effect.forEach(
162 threadIds,
163 (threadId) =>
164 projections.getThreadRecords(threadId, ["providerSessions"]).pipe(
165 Effect.flatMap((projection) =>
166 Effect.forEach(
167 projection.providerSessions.filter(
168 (session) =>
169 affectedIds.has(session.providerInstanceId) &&
170 session.status !== "stopped" &&
171 session.status !== "error" &&
172 !released.has(session.id),
173 ),
174 (session) =>
175 Effect.gen(function* () {
176 if (session.providerInstanceId !== instanceId) {
177 const current = yield* registry.getInstance(session.providerInstanceId);
178 if (
179 !binding ||
180 current?.auth?.credentialBinding?.key !== binding.key ||
181 current.auth.credentialBinding.owner !== binding.owner
182 )
183 return;
184 }
185 yield* providerSessions
186 .release({
187 providerSessionId: session.id,
188 reason: "manual_shutdown",
189 detail: "Provider sign-in changed.",
190 })
191 .pipe(Effect.tap(() => Effect.sync(() => released.add(session.id))));
192 }),
193 { discard: true },
194 ),
195 ),
196 Effect.mapError(() =>
197 failure("Could not stop all sessions for this provider. Try again."),
198 ),
199 ),
200 { discard: true },
201 );
202 if (binding) {
203 yield* Effect.forEach(
204 (yield* registry.listInstances).filter(
205 (instance) =>
206 instance.instanceId !== instanceId &&
207 affectedIds.has(instance.instanceId) &&
208 instance.auth?.credentialBinding?.key === binding.key &&
209 instance.auth.credentialBinding.owner === binding.owner,
210 ),
211 (instance) => instance.auth?.invalidate ?? Effect.void,
212 { discard: true },
213 );
214 }
215 });
216
217 const checkSharedBinding = Effect.fnUntraced(function* (
218 instanceId: ProviderInstanceId,
219 operation: "start" | "logout",
220 auth: ProviderAuthController,
221 ) {
222 const binding = auth.credentialBinding;
223 if (!binding) return;
224 const instances = yield* registry.listInstances;
225 for (const instance of instances) {
226 if (
227 instance.instanceId !== instanceId &&
228 instance.auth?.credentialBinding?.key === binding.key &&
229 instance.auth.credentialBinding.owner === binding.owner &&
230 instance.auth.isChangingCredentials &&
231 (yield* instance.auth.isChangingCredentials)
232 ) {
233 return yield* new ProviderSetupError({
234 instanceId,
235 operation,
236 detail:
237 "Another provider instance is changing this shared sign-in. Finish or cancel it first.",
238 });
239 }
240 }
241 });
242
243 return ProviderAuthService.of({
244 reconnectProfile: Effect.fnUntraced(function* (input) {
245 const auth = yield* getController(input.instanceId, "export");
246 if (!auth.reconnectProfile)
247 return yield* new ProviderSetupError({
248 instanceId: input.instanceId,
249 operation: "export",
250 detail: "This provider does not support ChatGPT profile transfer.",
251 });
252 return yield* auth.reconnectProfile(input.methodId);
253 }),
254 importProfile: (input) =>
255 credentialChanges.withPermit(
256 Effect.gen(function* () {
257 const auth = yield* getController(input.instanceId, "import");
258 yield* checkSharedBinding(input.instanceId, "start", auth);
259 if (!auth.importProfile)
260 return yield* new ProviderSetupError({
261 instanceId: input.instanceId,
262 operation: "import",
263 detail: "This provider does not support ChatGPT profile transfer.",
264 });
265 return yield* auth.importProfile(
266 input.profile,
267 stopSessions(input.instanceId, auth.credentialBinding),
268 );
269 }),
270 ),
271 start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) {
272 return yield* credentialChanges.withPermit(
273 Effect.gen(function* () {
274 const auth = yield* getController(input.instanceId, "start");
275 yield* checkSharedBinding(input.instanceId, "start", auth);
276 return yield* auth.start(
277 ownerSessionId,
278 stopSessions(input.instanceId, auth.credentialBinding),
279 input.methodId,
280 input.returnUrl,
281 input.callbackMode,
282 );
283 }),
284 );
285 }),
286 respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) {
287 const auth = yield* getController(input.instanceId, "respond");
288 if (!auth.respond) {
289 return yield* new ProviderSetupError({
290 instanceId: input.instanceId,
291 operation: "respond",
292 detail: "This provider does not accept this sign-in interaction.",
293 });
294 }
295 return yield* auth.respond(ownerSessionId, input);
296 }),
297 complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) {
298 const auth = yield* getController(input.instanceId, "complete");
299 return yield* auth.complete(ownerSessionId, input);
300 }),
301 cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) {
302 const auth = yield* getController(input.instanceId, "cancel");
303 return yield* auth.cancel(ownerSessionId, input.flowId);
304 }),
305 logout: Effect.fn("ProviderAuthService.logout")(function* (input) {
306 return yield* credentialChanges.withPermit(
307 Effect.gen(function* () {
308 const auth = yield* getController(input.instanceId, "logout");
309 yield* checkSharedBinding(input.instanceId, "logout", auth);
310 return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding));
311 }),
312 );
313 }),
314 subscribe: (input, ownerSessionId) =>
315 Effect.gen(function* () {
316 const changes = yield* registry.subscribeChanges;
317 const initial = yield* getController(input.instanceId, "subscribe");
318 return Stream.concat(
319 Stream.succeed(initial),
320 Stream.fromSubscription(changes).pipe(
321 Stream.mapEffect(() => getController(input.instanceId, "subscribe")),
322 ),
323 ).pipe(
324 Stream.changesWith((previous, next) => previous === next),
325 Stream.switchMap((auth) => auth.subscribe(ownerSessionId)),
326 );
327 }).pipe(Stream.unwrap),
328 tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")(
329 function* (input) {
330 const instance = yield* registry.getInstance(input.instanceId);
331 if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) {
332 return false;
333 }
334 return yield* credentialChanges.withPermit(
335 Effect.gen(function* () {
336 const auth = yield* getController(input.instanceId, "logout");
337 if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false;
338 yield* checkSharedBinding(input.instanceId, "logout", auth);
339 yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding));
340 return true;
341 }),
342 );
343 },
344 ),
345 });
346});
347
348export const layer = Layer.effect(ProviderAuthService, makeProviderAuthService);
apps/server/src/provider/ProviderDriver.ts+2-2
......@@ -40,8 +40,8 @@ import type * as Scope from "effect/Scope";
4040import type { TextGeneration } from "../textGeneration/TextGeneration.ts";
4141import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
4242import type { ProviderDriverError } from "./Errors.ts";
43import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";
44import type { ServerProviderShape } from "./Services/ServerProvider.ts";
43import type { ProviderAuthController } from "./ProviderAuthService.ts";
44import type { ServerProviderShape } from "./ServerProvider.ts";
4545
4646/**
4747 * Static metadata advertised by a driver. Used for default presentation
apps/server/src/provider/ProviderEventLoggers.ts created+92
......@@ -0,0 +1,92 @@
1/**
2 * ProviderEventLoggers — single observability service that owns the shared
3 * provider event log store and exposes its two runtime views:
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 * must share one underlying log store — opening N writers against the
17 * same rotating file would race the rotation logic. Owning the loggers on
18 * 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 because observability must not prevent startup.
24 *
25 * @module provider/ProviderEventLoggers
26 */
27import * as Context from "effect/Context";
28import * as Effect from "effect/Effect";
29import * as Layer from "effect/Layer";
30
31import * as ServerConfig from "../config.ts";
32import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
33import * as EventNdjsonLogger from "./EventNdjsonLogger.ts";
34
35/**
36 * Shared logger pair for native + canonical provider event streams.
37 *
38 * Service value is intentionally a struct of two optional loggers rather
39 * than two parallel tags. Construction site is one place
40 * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the
41 * field they need.
42 */
43export class ProviderEventLoggers extends Context.Service<
44 ProviderEventLoggers,
45 {
46 readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined;
47 readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined;
48 }
49>()("t3/provider/ProviderEventLoggers") {}
50
51/**
52 * Constant value used by tests / boot layers that want to opt out of native
53 * + canonical logging entirely. Keeps the tag non-optional in the type
54 * system while letting the runtime treat absence as a no-op.
55 */
56export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = {
57 native: undefined,
58 canonical: undefined,
59};
60
61/**
62 * Builds both stream views over one shared store. Setup failures are logged
63 * and downgraded to the no-op service so diagnostics never block startup.
64 *
65 * @public Service construction is part of the canonical Effect module API.
66 */
67export const make = Effect.gen(function* () {
68 const { providerEventLogPath } = yield* ServerConfig.ServerConfig;
69 const attribution = yield* ResourceAttribution.ResourceAttribution;
70 const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, {
71 attribution,
72 }).pipe(
73 Effect.catch((error) =>
74 Effect.logWarning(error.message, { error }).pipe(
75 Effect.annotateLogs({ scope: "provider-observability" }),
76 Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined),
77 ),
78 ),
79 );
80
81 if (!store) {
82 return ProviderEventLoggers.of(NoOpProviderEventLoggers);
83 }
84
85 yield* Effect.addFinalizer(() => store.close());
86 return ProviderEventLoggers.of({
87 native: store.logger("native"),
88 canonical: store.logger("canonical"),
89 });
90});
91
92export const layer = Layer.effect(ProviderEventLoggers, make);
apps/server/src/provider/ProviderInstanceRegistry.test.ts created+781
......@@ -0,0 +1,781 @@
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "./CodexInstallation.ts";
3import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4/**
5 * Multi-instance validation slices for the live `ProviderInstanceRegistry`.
6 *
7 * Two axes of the driver/registry refactor are exercised here:
8 *
9 * 1. **Same driver, many instances** — the "multi-instance codex slice"
10 * describe block below configures two independent `codex` instances and
11 * asserts each gets its own closures and identity. This is the
12 * multi-codex capability the refactor exists to unlock.
13 *
14 * 2. **Many drivers, one registry** — the "all drivers slice" describe
15 * block below configures one instance of every shipped driver
16 * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single
17 * `ProviderInstanceConfigMap` and asserts the registry boots them all
18 * without cross-contamination. This proves the driver SPI is uniform
19 * across every provider — any driver plugs into the registry through
20 * the same `ProviderDriver` value contract.
21 *
22 * Every instance in these tests is configured with `enabled: false` so the
23 * provider-status checks short-circuit to pending/disabled snapshots
24 * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode`
25 * binaries. That keeps the assertions focused on registry routing
26 * behaviour rather than the runtime details of each provider.
27 */
28import { describe, expect, it } from "@effect/vitest";
29import * as NodeServices from "@effect/platform-node/NodeServices";
30import {
31 EnvironmentId,
32 type ClaudeSettings,
33 type CodexSettings,
34 type CursorSettings,
35 type GrokSettings,
36 type OpenCodeSettings,
37 ProviderDriverKind,
38 type ProviderInstanceConfigMap,
39 ProviderInstanceId,
40} from "@t3tools/contracts";
41import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
42import * as DateTime from "effect/DateTime";
43import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
45import * as Layer from "effect/Layer";
46import * as Path from "effect/Path";
47import * as Stream from "effect/Stream";
48import { HttpClient, HttpClientResponse } from "effect/http";
49
50import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
51import * as AntigravityInstallation from "./AntigravityInstallation.ts";
52import * as ServerConfig from "../config.ts";
53import { expandHomePath } from "../pathExpansion.ts";
54import * as ServerSettings from "../serverSettings.ts";
55import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
56import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
57import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
58import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
59import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
60import * as ModelManifest from "./ModelManifest.ts";
61import * as OpenCodeRuntime from "./opencodeRuntime.ts";
62import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
63import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
64import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
65import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts";
66import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";
67
68const layerTestHttpClient = Layer.succeed(
69 HttpClient.HttpClient,
70 HttpClient.make((request) =>
71 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
72 ),
73);
74
75const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
76
77const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
78 reportClientActivity: () => Effect.void,
79 removeRpcClient: () => Effect.void,
80 reportHostPowerState: () => Effect.void,
81 snapshot: Effect.succeed({
82 hostPower: {
83 source: "unknown",
84 idle: "unknown",
85 idleSeconds: null,
86 locked: "unknown",
87 suspended: false,
88 onBattery: "unknown",
89 lowPowerMode: "unknown",
90 thermalState: "unknown",
91 stale: true,
92 updatedAt: TEST_EPOCH,
93 },
94 leases: [],
95 activeForegroundLeaseCount: 0,
96 activeScopeKeys: [],
97 shouldRunOpportunisticWork: true,
98 updatedAt: TEST_EPOCH,
99 }),
100 streamChanges: Stream.empty,
101 hasDemand: () => Effect.succeed(true),
102 shouldRunScopeWork: () => Effect.succeed(true),
103 shouldRunOpportunisticWork: Effect.succeed(true),
104});
105
106const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
107 enabled: false,
108 binaryPath: "codex",
109 homePath: "",
110 shadowHomePath: "",
111 launchArgs: "",
112 customModels: [],
113 ...overrides,
114});
115
116const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => ({
117 enabled: false,
118 binaryPath: "claude",
119 homePath: "",
120 customModels: [],
121 launchArgs: "",
122 autoCompactWindow: "",
123 ...overrides,
124});
125
126const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => ({
127 enabled: false,
128 customModels: [],
129 ...overrides,
130});
131
132const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({
133 enabled: false,
134 binaryPath: "grok",
135 customModels: [],
136 ...overrides,
137});
138
139const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({
140 enabled: false,
141 binaryPath: "opencode",
142 serverUrl: "",
143 serverPassword: "",
144 customModels: [],
145 ...overrides,
146});
147
148const makeTildeProviderFixtures = Effect.fn(
149 "ProviderInstanceRegistry.test.makeTildeProviderFixtures",
150)(function* () {
151 const fileSystem = yield* FileSystem.FileSystem;
152 const path = yield* Path.Path;
153 const homePath = expandHomePath("~");
154 const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({
155 directory: homePath,
156 prefix: ".t3-provider-path-test-",
157 });
158 const codexPath = path.join(fixtureDir, "codex");
159 const claudePath = path.join(fixtureDir, "claude");
160 const claudeHomePath = path.join(fixtureDir, "claude-home");
161 const codexScriptPath = path.join(fixtureDir, "codex-script.json");
162 const codexFixtureDir = path.join(import.meta.dirname, "testFixtures");
163
164 yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath);
165 yield* fileSystem.copyFile(
166 path.join(codexFixtureDir, "codexCollabMockPeer.mjs"),
167 path.join(fixtureDir, "codexCollabMockPeer.mjs"),
168 );
169 yield* fileSystem.copyFile(
170 path.join(codexFixtureDir, "codexMultiAgentWire.json"),
171 path.join(fixtureDir, "codexMultiAgentWire.json"),
172 );
173 yield* fileSystem.writeFileString(
174 codexScriptPath,
175 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer.
176 JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }),
177 );
178 yield* fileSystem.chmod(codexPath, 0o755);
179
180 yield* fileSystem.copyFile(
181 yield* path.fromFileUrl(
182 new URL("./testing/ProviderInstanceRegistryLive.fixture.mjs", import.meta.url),
183 ),
184 claudePath,
185 );
186 yield* fileSystem.chmod(claudePath, 0o755);
187 yield* fileSystem.makeDirectory(claudeHomePath);
188
189 const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`;
190 return {
191 codexBinaryPath: asTildePath(codexPath),
192 claudeBinaryPath: asTildePath(claudePath),
193 claudeHomePath,
194 codexScriptPath,
195 };
196});
197
198describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
199 // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch
200 // directory. `Layer.merge` just unions requirements, so we have to push
201 // `NodeServices.layer` through `Layer.provideMerge` to satisfy that
202 // dependency while still surfacing NodeServices to the test body (the
203 // codex driver's `create` yields `ChildProcessSpawner` directly).
204 const layerBase = ServerConfig.layerTest(process.cwd(), {
205 prefix: "provider-instance-registry-test",
206 }).pipe(
207 Layer.provideMerge(NodeServices.layer),
208 Layer.provideMerge(
209 Layer.mock(CodexInstallation.CodexInstallation)({
210 managedDirectory: "unused-managed-installation",
211 }),
212 ),
213 Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})),
214 Layer.provideMerge(
215 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
216 getEnvironmentId: Effect.succeed(
217 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
218 ),
219 }),
220 ),
221 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
222 Layer.provideMerge(ServerSettings.layerTest()),
223 Layer.provideMerge(layerTestHttpClient),
224 Layer.provideMerge(ServerSettings.layerTest()),
225 Layer.provideMerge(
226 Layer.succeed(
227 ProviderEventLoggers.ProviderEventLoggers,
228 ProviderEventLoggers.NoOpProviderEventLoggers,
229 ),
230 ),
231 Layer.provideMerge(ModelManifest.layerTest),
232 Layer.provideMerge(ResetCreditCoordinator.layerTest),
233 );
234 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
235 Layer.provideMerge(layerBase),
236 );
237
238 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
239 Effect.gen(function* () {
240 const personalId = ProviderInstanceId.make("codex_personal");
241 const workId = ProviderInstanceId.make("codex_work");
242 const codexDriverKind = ProviderDriverKind.make("codex");
243
244 const configMap: ProviderInstanceConfigMap = {
245 [personalId]: {
246 driver: codexDriverKind,
247 displayName: "Codex (personal)",
248 enabled: false,
249 config: makeCodexConfig({
250 binaryPath: "/opt/codex-personal/bin/codex",
251 homePath: "/home/julius/.codex_personal",
252 customModels: ["personal-preview"],
253 }),
254 },
255 [workId]: {
256 driver: codexDriverKind,
257 displayName: "Codex (work)",
258 enabled: false,
259 config: makeCodexConfig({
260 binaryPath: "/opt/codex-work/bin/codex",
261 homePath: "/home/julius/.codex",
262 customModels: ["work-preview"],
263 }),
264 },
265 };
266
267 const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({
268 drivers: [CodexDriver],
269 configMap,
270 });
271
272 const instances = yield* registry.listInstances;
273 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
274 [personalId, workId].toSorted(),
275 );
276 expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true);
277 expect(instances.map((instance) => instance.displayName).toSorted()).toEqual(
278 ["Codex (personal)", "Codex (work)"].toSorted(),
279 );
280
281 // Each instance must be retrievable by id and carry its *own* closures.
282 const personal = yield* registry.getInstance(personalId);
283 const work = yield* registry.getInstance(workId);
284 expect(personal).toBeDefined();
285 expect(work).toBeDefined();
286 expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter);
287 expect(personal!.textGeneration).not.toBe(work!.textGeneration);
288 expect(personal!.snapshot).not.toBe(work!.snapshot);
289
290 // Snapshots identify themselves by instanceId + driver — this is
291 // what makes per-instance routing distinguishable downstream.
292 const personalSnapshot = yield* personal!.snapshot.getSnapshot;
293 expect(personalSnapshot.instanceId).toBe(personalId);
294 expect(personalSnapshot.driver).toBe(codexDriverKind);
295 expect(personalSnapshot.enabled).toBe(false);
296 // The layout resolves the configured home through the host Path.
297 const path = yield* Path.Path;
298 expect(personalSnapshot.continuation?.groupKey).toBe(
299 `codex:home:${path.resolve("/home/julius/.codex_personal")}`,
300 );
301
302 const workSnapshot = yield* work!.snapshot.getSnapshot;
303 expect(workSnapshot.instanceId).toBe(workId);
304 expect(workSnapshot.driver).toBe(codexDriverKind);
305 expect(workSnapshot.enabled).toBe(false);
306 expect(workSnapshot.continuation?.groupKey).toBe(
307 `codex:home:${path.resolve("/home/julius/.codex")}`,
308 );
309
310 // Nothing goes to the unavailable bucket — both drivers are registered.
311 const unavailable = yield* registry.listUnavailable;
312 expect(unavailable).toEqual([]);
313 }).pipe(Effect.provide(layerTest)),
314 );
315
316 it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () =>
317 Effect.gen(function* () {
318 // Old settings files can carry both flags with conflicting values.
319 // The explicit false must win so a user's disable is never undone.
320 const staleId = ProviderInstanceId.make("codex_stale");
321 const configMap: ProviderInstanceConfigMap = {
322 [staleId]: {
323 driver: ProviderDriverKind.make("codex"),
324 enabled: true,
325 config: makeCodexConfig({ enabled: false }),
326 },
327 };
328
329 const { registry } = yield* makeProviderInstanceRegistry({
330 drivers: [CodexDriver],
331 configMap,
332 });
333
334 const instance = yield* registry.getInstance(staleId);
335 expect(instance).toBeDefined();
336 expect(instance!.enabled).toBe(false);
337 const snapshot = yield* instance!.snapshot.getSnapshot;
338 expect(snapshot.enabled).toBe(false);
339 }).pipe(Effect.provide(layerTest)),
340 );
341
342 it.live("reports Codex's answer when a redemption changed nothing", () =>
343 Effect.gen(function* () {
344 if (yield* isHostWindows) return;
345 const fileSystem = yield* FileSystem.FileSystem;
346 const fixtures = yield* makeTildeProviderFixtures();
347 yield* fileSystem.writeFileString(
348 fixtures.codexScriptPath,
349 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer.
350 JSON.stringify({
351 rootThreadId: "probe-thread",
352 notifications: [],
353 account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
354 failRateLimitsRead: true,
355 resetCreditOutcome: "alreadyRedeemed",
356 }),
357 );
358 const codexId = ProviderInstanceId.make("codex_reset");
359 const { registry } = yield* makeProviderInstanceRegistry({
360 drivers: [CodexDriver],
361 configMap: {
362 [codexId]: {
363 driver: ProviderDriverKind.make("codex"),
364 enabled: true,
365 environment: [
366 { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false },
367 ],
368 config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }),
369 },
370 },
371 });
372 const codex = yield* registry.getInstance(codexId);
373 expect(codex).toBeDefined();
374 // The usage read fails, so the re-probe cannot confirm new limits.
375 yield* codex!.snapshot.refresh;
376 expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed");
377 }).pipe(Effect.provide(layerTest)),
378 );
379
380 it.live("runs Codex and Claude readiness probes from configured tilde paths", () =>
381 Effect.gen(function* () {
382 if (yield* isHostWindows) return;
383
384 const fixtures = yield* makeTildeProviderFixtures();
385
386 const codexId = ProviderInstanceId.make("codex_tilde");
387 const claudeId = ProviderInstanceId.make("claude_tilde");
388 const configMap: ProviderInstanceConfigMap = {
389 [codexId]: {
390 driver: ProviderDriverKind.make("codex"),
391 enabled: true,
392 environment: [
393 {
394 name: "T3_CODEX_COLLAB_SCRIPT",
395 value: fixtures.codexScriptPath,
396 sensitive: false,
397 },
398 ],
399 config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }),
400 },
401 [claudeId]: {
402 driver: ProviderDriverKind.make("claudeAgent"),
403 enabled: true,
404 config: makeClaudeConfig({
405 enabled: true,
406 binaryPath: fixtures.claudeBinaryPath,
407 homePath: fixtures.claudeHomePath,
408 }),
409 },
410 };
411
412 const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv | ClaudeDriverEnv>({
413 drivers: [CodexDriver, ClaudeDriver],
414 configMap,
415 });
416 const codex = yield* registry.getInstance(codexId);
417 const claude = yield* registry.getInstance(claudeId);
418 expect(codex).toBeDefined();
419 expect(claude).toBeDefined();
420
421 const [codexSnapshot, claudeSnapshot] = yield* Effect.all(
422 [codex!.snapshot.refresh, claude!.snapshot.refresh],
423 { concurrency: "unbounded" },
424 );
425 expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" });
426 expect(claudeSnapshot).toMatchObject({
427 status: "ready",
428 installed: true,
429 version: "2.1.219",
430 });
431 }).pipe(Effect.provide(layerTest)),
432 );
433
434 const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>
435 Effect.gen(function* () {
436 const fs = yield* FileSystem.FileSystem;
437 const path = yield* Path.Path;
438 const fixtures = yield* makeTildeProviderFixtures();
439 const marker = path.join(fixtures.claudeHomePath, "redeemed");
440 yield* fs.writeFileString(
441 path.join(fixtures.claudeHomePath, ".credentials.json"),
442 '{"claudeAiOauth":{"accessToken":"fake-token"}}',
443 );
444 yield* fs.writeFileString(
445 path.join(fixtures.claudeHomePath, ".claude.json"),
446 '{"oauthAccount":{"organizationUuid":"fake-org"}}',
447 );
448 const client = HttpClient.make((request) =>
449 Effect.gen(function* () {
450 if (request.url.endsWith("/api/oauth/usage")) {
451 return HttpClientResponse.fromWeb(
452 request,
453 Response.json({
454 cedar_ember: {
455 eligible: true,
456 next_grant_id: "grant_a",
457 grants: [{ id: "grant_a", resets_left: 1, usable_now: true }],
458 },
459 }),
460 );
461 }
462 if (request.url.endsWith("/reset_rate_limits")) {
463 yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie);
464 return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result }));
465 }
466 return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }));
467 }),
468 );
469 const instanceId = ProviderInstanceId.make("claude_reset");
470 const { registry } = yield* makeProviderInstanceRegistry({
471 drivers: [ClaudeDriver],
472 configMap: {
473 [instanceId]: {
474 driver: ProviderDriverKind.make("claudeAgent"),
475 enabled: true,
476 environment: [
477 { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false },
478 ...(claim.usageFailsAfterClaim
479 ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }]
480 : []),
481 ],
482 config: makeClaudeConfig({
483 enabled: true,
484 binaryPath: fixtures.claudeBinaryPath,
485 homePath: fixtures.claudeHomePath,
486 }),
487 },
488 },
489 }).pipe(Effect.provideService(HttpClient.HttpClient, client));
490 const instance = yield* registry.getInstance(instanceId);
491 expect(instance).toBeDefined();
492 const before = yield* instance!.snapshot.refresh;
493 expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100);
494 expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a");
495 const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result);
496 return { outcome, after: yield* instance!.snapshot.getSnapshot };
497 }).pipe(
498 // macOS logins live in the Keychain, where resets are never read.
499 Effect.provideService(HostProcessPlatform, "linux"),
500 Effect.provide(layerTest),
501 );
502
503 it.live("refreshes Claude usage after redeeming a reset", () =>
504 Effect.gen(function* () {
505 const { outcome, after } = yield* redeemClaudeReset({
506 result: "reset",
507 usageFailsAfterClaim: false,
508 });
509 expect(outcome).toMatchObject({ _tag: "Success", success: "reset" });
510 expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0);
511 }),
512 );
513
514 it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () =>
515 Effect.gen(function* () {
516 const { outcome } = yield* redeemClaudeReset({
517 result: "already_used",
518 usageFailsAfterClaim: true,
519 });
520 expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" });
521 }),
522 );
523
524 it.live(
525 "shadows instances whose driver is not registered in this build without failing boot",
526 () =>
527 Effect.gen(function* () {
528 const codexId = ProviderInstanceId.make("codex_main");
529 const ghostId = ProviderInstanceId.make("ghost_main");
530
531 const configMap: ProviderInstanceConfigMap = {
532 [codexId]: {
533 driver: ProviderDriverKind.make("codex"),
534 enabled: false,
535 config: makeCodexConfig({}),
536 },
537 [ghostId]: {
538 driver: ProviderDriverKind.make("ghostDriver"),
539 displayName: "A fork-only driver we don't ship",
540 enabled: false,
541 config: { arbitrary: "payload", preserved: true },
542 },
543 };
544
545 const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({
546 drivers: [CodexDriver],
547 configMap,
548 });
549
550 const instances = yield* registry.listInstances;
551 expect(instances).toHaveLength(1);
552 expect(instances[0]!.instanceId).toBe(codexId);
553
554 const unavailable = yield* registry.listUnavailable;
555 expect(unavailable).toHaveLength(1);
556 const ghost = unavailable[0]!;
557 expect(ghost.instanceId).toBe(ghostId);
558 expect(ghost.driver).toBe("ghostDriver");
559 expect(ghost.availability).toBe("unavailable");
560 expect(ghost.unavailableReason).toMatch(/ghostDriver/);
561 }).pipe(Effect.provide(layerTest)),
562 );
563});
564
565describe("ProviderInstanceRegistry — all drivers slice", () => {
566 // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem +
567 // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime`
568 // at construction time, so we wire `OpenCodeRuntimeLive` into the stack.
569 // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via
570 // `Layer.provide`, so the only external requirement it still exposes is
571 // `ChildProcessSpawner` — resolved here by piping it through
572 // `provideMerge(NodeServices.layer)`.
573 //
574 // The nested `provideMerge`s read bottom-up: `NodeServices.layer`
575 // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs
576 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s
577 // `FileSystem` dep while keeping everything else surfaced to the test.
578 const layerInfra = OpenCodeRuntime.layer.pipe(
579 Layer.provide(OpenCodeServerLedger.layerTest),
580 Layer.provideMerge(NodeServices.layer),
581 Layer.provideMerge(
582 Layer.mock(CodexInstallation.CodexInstallation)({
583 managedDirectory: "unused-managed-installation",
584 }),
585 ),
586 Layer.provideMerge(
587 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
588 getEnvironmentId: Effect.succeed(
589 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
590 ),
591 }),
592 ),
593 );
594 const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe(
595 Layer.provideMerge(ServerSecretStore.layer),
596 Layer.provideMerge(
597 ServerConfig.layerTest(process.cwd(), {
598 prefix: "provider-instance-registry-all-drivers-test",
599 }),
600 ),
601 Layer.provideMerge(layerInfra),
602 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
603 Layer.provideMerge(ServerSettings.layerTest()),
604 Layer.provideMerge(layerTestHttpClient),
605 Layer.provideMerge(ServerSettings.layerTest()),
606 Layer.provideMerge(
607 Layer.succeed(
608 ProviderEventLoggers.ProviderEventLoggers,
609 ProviderEventLoggers.NoOpProviderEventLoggers,
610 ),
611 ),
612 Layer.provideMerge(ModelManifest.layerTest),
613 Layer.provideMerge(ResetCreditCoordinator.layerTest),
614 );
615 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
616 Layer.provideMerge(layerBase),
617 );
618
619 it.live("boots one instance of every shipped driver from a single config map", () =>
620 Effect.gen(function* () {
621 const codexId = ProviderInstanceId.make("codex_default");
622 const claudeId = ProviderInstanceId.make("claude_default");
623 const cursorId = ProviderInstanceId.make("cursor_default");
624 const grokId = ProviderInstanceId.make("grok_default");
625 const openCodeId = ProviderInstanceId.make("opencode_default");
626
627 const codexDriverKind = ProviderDriverKind.make("codex");
628 const claudeDriverKind = ProviderDriverKind.make("claudeAgent");
629 const cursorDriverKind = ProviderDriverKind.make("cursor");
630 const grokDriverKind = ProviderDriverKind.make("grok");
631 const openCodeDriverKind = ProviderDriverKind.make("opencode");
632
633 const configMap: ProviderInstanceConfigMap = {
634 [codexId]: {
635 driver: codexDriverKind,
636 displayName: "Codex",
637 enabled: false,
638 config: makeCodexConfig({ homePath: "/home/julius/.codex" }),
639 },
640 [claudeId]: {
641 driver: claudeDriverKind,
642 displayName: "Claude",
643 enabled: false,
644 config: makeClaudeConfig({
645 homePath: "/home/julius/.claude-work",
646 launchArgs: "--verbose",
647 }),
648 },
649 [cursorId]: {
650 driver: cursorDriverKind,
651 displayName: "Cursor",
652 enabled: false,
653 config: makeCursorConfig({}),
654 },
655 [grokId]: {
656 driver: grokDriverKind,
657 displayName: "Grok",
658 enabled: false,
659 config: makeGrokConfig({}),
660 },
661 [openCodeId]: {
662 driver: openCodeDriverKind,
663 displayName: "OpenCode",
664 enabled: false,
665 config: makeOpenCodeConfig({}),
666 },
667 };
668
669 const { registry } = yield* makeProviderInstanceRegistry<
670 CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv
671 >({
672 drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
673 configMap,
674 });
675
676 // Every configured instance must materialize — none downgraded to a
677 // shadow snapshot, because every driver in the map is registered.
678 const unavailable = yield* registry.listUnavailable;
679 expect(unavailable).toEqual([]);
680
681 const instances = yield* registry.listInstances;
682 expect(instances).toHaveLength(5);
683 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
684 [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(),
685 );
686
687 // Instance lookup by id resolves each instance to its own bundle —
688 // this is how rest-of-server routes turn/session calls in the new
689 // model. Each driver's bundle carries its advertised `driverKind`.
690 const codex = yield* registry.getInstance(codexId);
691 const claude = yield* registry.getInstance(claudeId);
692 const cursor = yield* registry.getInstance(cursorId);
693 const grok = yield* registry.getInstance(grokId);
694 const openCode = yield* registry.getInstance(openCodeId);
695 expect(codex?.driverKind).toBe(codexDriverKind);
696 expect(claude?.driverKind).toBe(claudeDriverKind);
697 expect(cursor?.driverKind).toBe(cursorDriverKind);
698 expect(grok?.driverKind).toBe(grokDriverKind);
699 expect(openCode?.driverKind).toBe(openCodeDriverKind);
700 expect(codex?.displayName).toBe("Codex");
701 expect(claude?.displayName).toBe("Claude");
702 expect(cursor?.displayName).toBe("Cursor");
703 expect(grok?.displayName).toBe("Grok");
704 expect(openCode?.displayName).toBe("OpenCode");
705
706 // Every instance owns its own set of closures — no sharing across
707 // drivers. `orchestrationAdapter` / `textGeneration` / `snapshot` are all
708 // distinct references even when two instances happen to share a
709 // trait (e.g. Cursor + others all use a stub-or-real
710 // `textGeneration`; they must still be different object values).
711 const adapters = [
712 codex!.orchestrationAdapter,
713 claude!.orchestrationAdapter,
714 cursor!.orchestrationAdapter,
715 grok!.orchestrationAdapter,
716 openCode!.orchestrationAdapter,
717 ];
718 expect(new Set(adapters).size).toBe(adapters.length);
719 const textGenerations = [
720 codex!.textGeneration,
721 claude!.textGeneration,
722 cursor!.textGeneration,
723 grok!.textGeneration,
724 openCode!.textGeneration,
725 ];
726 expect(new Set(textGenerations).size).toBe(textGenerations.length);
727 const snapshots = [
728 codex!.snapshot,
729 claude!.snapshot,
730 cursor!.snapshot,
731 grok!.snapshot,
732 openCode!.snapshot,
733 ];
734 expect(new Set(snapshots).size).toBe(snapshots.length);
735
736 // Snapshots identify themselves by `instanceId` + `driver` so
737 // downstream aggregation in `ProviderRegistry` can tell instances
738 // apart even when two share a driver. With `enabled: false`, the
739 // check short-circuits and we get a disabled/pending snapshot back
740 // — that's enough signal to validate the stamping wrapper without
741 // spawning real binaries.
742 const codexSnapshot = yield* codex!.snapshot.getSnapshot;
743 expect(codexSnapshot.instanceId).toBe(codexId);
744 expect(codexSnapshot.driver).toBe(codexDriverKind);
745 expect(codexSnapshot.enabled).toBe(false);
746 expect(codexSnapshot.continuation?.groupKey).toBe(
747 `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`,
748 );
749
750 const claudeSnapshot = yield* claude!.snapshot.getSnapshot;
751 expect(claudeSnapshot.instanceId).toBe(claudeId);
752 expect(claudeSnapshot.driver).toBe(claudeDriverKind);
753 expect(claudeSnapshot.enabled).toBe(false);
754 expect(claudeSnapshot.continuation?.groupKey).toBe(
755 `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`,
756 );
757
758 const cursorSnapshot = yield* cursor!.snapshot.getSnapshot;
759 expect(cursorSnapshot.instanceId).toBe(cursorId);
760 expect(cursorSnapshot.driver).toBe(cursorDriverKind);
761 expect(cursorSnapshot.enabled).toBe(false);
762 expect(cursorSnapshot.continuation?.groupKey).toBe(
763 `${cursorDriverKind}:instance:${cursorId}`,
764 );
765
766 const grokSnapshot = yield* grok!.snapshot.getSnapshot;
767 expect(grokSnapshot.instanceId).toBe(grokId);
768 expect(grokSnapshot.driver).toBe(grokDriverKind);
769 expect(grokSnapshot.enabled).toBe(false);
770 expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`);
771
772 const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot;
773 expect(openCodeSnapshot.instanceId).toBe(openCodeId);
774 expect(openCodeSnapshot.driver).toBe(openCodeDriverKind);
775 expect(openCodeSnapshot.enabled).toBe(false);
776 expect(openCodeSnapshot.continuation?.groupKey).toBe(
777 `${openCodeDriverKind}:instance:${openCodeId}`,
778 );
779 }).pipe(Effect.provide(layerTest)),
780 );
781});
apps/server/src/provider/ProviderInstanceRegistry.ts created+508
......@@ -0,0 +1,508 @@
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 * The live implementation (`makeProviderInstanceRegistry`, `layer`) also
19 * provides its sibling mutator.
20 *
21 * Materializes every entry in a `ProviderInstanceConfigMap`:
22 *
23 * - When the entry's `driver` matches a registered driver, the registry
24 * decodes the opaque `config` envelope through `driver.configSchema`
25 * and calls `driver.create()` inside a fresh child scope. The
26 * resulting `ProviderInstance` is stored keyed by instance id,
27 * alongside its scope so the entry can be torn down independently.
28 * - When the entry's `driver` is unknown to this build (fork, rollback,
29 * in-flight PR branch), the registry emits an `"unavailable"` shadow
30 * `ServerProvider` snapshot instead of failing. This is what makes
31 * downgrades and fork-hopping safe per the
32 * `forward/backward compatibility invariant` in
33 * `packages/contracts/src/providerInstance.ts`.
34 * - When the entry's config fails schema decode, the registry logs and
35 * emits a shadow snapshot with the schema detail — same bucket as an
36 * unknown driver.
37 *
38 * Unlike the pre-Slice-D layer, the registry now holds mutable state
39 * (`Ref`s + `PubSub`) and exposes an internal mutator
40 * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a
41 * fresh config map against the live state, tearing down removed instances
42 * and building new ones without disturbing unaffected instances.
43 *
44 * Every live instance runs inside its own child `Scope`. The registry's
45 * own scope owns all child scopes via finalizers, so closing the registry
46 * tears every instance down in reverse order; closing a single instance
47 * (via `reconcile` removing it) leaves the rest untouched.
48 *
49 * @module provider/ProviderInstanceRegistry
50 */
51import {
52 providerInstanceConfigEnabledFlag,
53 ProviderInstanceId,
54 type ProviderInstanceConfig,
55 type ProviderInstanceConfigMap,
56 type ProviderDriverKind,
57 type ServerProvider,
58} from "@t3tools/contracts";
59import * as Context from "effect/Context";
60import * as Effect from "effect/Effect";
61import * as Equal from "effect/Equal";
62import * as Exit from "effect/Exit";
63import * as Layer from "effect/Layer";
64import * as PubSub from "effect/PubSub";
65import * as Ref from "effect/Ref";
66import * as Schema from "effect/Schema";
67import * as Scope from "effect/Scope";
68import * as Stream from "effect/Stream";
69
70import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts";
71import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts";
72import type { AnyProviderDriver, ProviderInstance } from "./ProviderDriver.ts";
73
74export class ProviderInstanceRegistry extends Context.Service<
75 ProviderInstanceRegistry,
76 {
77 /**
78 * Look up one instance by id. Returns `undefined` (not Option) when the
79 * id is unknown — callers branch on falsy and emit
80 * `ProviderInstanceNotFoundError`.
81 */
82 readonly getInstance: (
83 instanceId: ProviderInstanceId,
84 ) => Effect.Effect<ProviderInstance | undefined>;
85 /**
86 * Every available (driver-registered, successfully created) instance,
87 * in stable settings-author order.
88 */
89 readonly listInstances: Effect.Effect<ReadonlyArray<ProviderInstance>>;
90 /**
91 * Wire-shape shadow snapshots for instances whose driver is unknown to
92 * this build (or whose config failed to decode). Suitable for merging
93 * directly into `ProviderRegistry` output.
94 */
95 readonly listUnavailable: Effect.Effect<ReadonlyArray<ServerProvider>>;
96 /**
97 * Push notification stream emitted whenever the registry's contents
98 * change — instance added, removed, or rebuilt. The payload is `void`
99 * because consumers always want to re-pull `listInstances` /
100 * `listUnavailable` together.
101 *
102 * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the
103 * stream starts running, forking a consumer via
104 * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next
105 * publish — the forked fiber may not have subscribed yet when the
106 * publish lands. Hot-reload consumers that must not miss a publish
107 * should use `subscribeChanges` below instead, which acquires the
108 * subscription synchronously in the caller's fiber before the consumer
109 * loop is forked.
110 */
111 readonly streamChanges: Stream.Stream<void>;
112 /**
113 * Acquire a subscription to the registry's change channel synchronously
114 * in the caller's fiber. Returns a `PubSub.Subscription<void>` whose
115 * lifetime is scoped to the provided `Scope` (the subscription is
116 * released when the scope closes). Consumers typically `yield*` this
117 * in the same fiber that forks their consumer loop, then drain with
118 * `PubSub.take(subscription)` inside `Effect.forever`. Because the
119 * subscription is registered with the PubSub before this `yield*`
120 * returns, no subsequent publish can land in a gap.
121 *
122 * This exists because the `ProviderInstanceRegistry` publishes on a
123 * PubSub and `Stream.fromPubSub` defers subscription until the stream
124 * starts executing — a consumer that `forkScoped`s the stream
125 * consumption can miss a publish that lands in the narrow window
126 * between "fiber scheduled" and "fiber starts running".
127 */
128 readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>;
129 }
130>()("t3/provider/ProviderInstanceRegistry") {}
131
132/**
133 * Live registry entry: the materialized `ProviderInstance` + the fresh
134 * child scope its `create` effect ran in + the original `entry` envelope
135 * so `reconcile` can cheaply detect "no-op" updates.
136 */
137interface LiveEntry {
138 readonly instance: ProviderInstance;
139 readonly scope: Scope.Closeable;
140 readonly entry: ProviderInstanceConfig;
141}
142
143/**
144 * Internal state shared between the public registry service and the
145 * mutator service. Both services are thin shells around these refs.
146 */
147interface RegistryState {
148 readonly entries: Ref.Ref<ReadonlyMap<ProviderInstanceId, LiveEntry>>;
149 readonly unavailable: Ref.Ref<ReadonlyMap<ProviderInstanceId, ServerProvider>>;
150 readonly changes: PubSub.PubSub<void>;
151}
152
153/**
154 * Structural equality on `ProviderInstanceConfig` envelopes. Used by
155 * `reconcile` to skip rebuilds when settings arrive unchanged. Config
156 * payloads are opaque `unknown` at the envelope layer; `Equal.equals`
157 * falls back to structural equality for plain records, which matches how
158 * the schema decode output is constructed.
159 */
160const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean =>
161 Equal.equals(a, b);
162
163/**
164 * Resolve an entry's enabled state. An explicit false on either the
165 * envelope or the raw config blob wins (most restrictive) — old settings
166 * files can carry both flags with conflicting values, and a user's disable
167 * must never be silently undone. Otherwise the envelope flag wins, then the
168 * decoded config's flag (which carries the driver schema's default for
169 * built-ins and forks alike), then enabled by default.
170 */
171const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => {
172 const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config);
173 if (entry.enabled === false || rawConfigEnabled === false) {
174 return false;
175 }
176 return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true;
177};
178
179/**
180 * Build one live entry from a raw config envelope. Returns either a
181 * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and
182 * undefined entry — callers dispatch to the appropriate Ref bucket.
183 */
184const buildEntry = <R>(input: {
185 readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>;
186 readonly parentScope: Scope.Scope;
187 readonly instanceId: ProviderInstanceId;
188 readonly rawInstanceId: string;
189 readonly entry: ProviderInstanceConfig;
190}): Effect.Effect<
191 | { readonly kind: "live"; readonly live: LiveEntry }
192 | { readonly kind: "unavailable"; readonly snapshot: ServerProvider },
193 never,
194 R
195> =>
196 Effect.gen(function* () {
197 const { driversById, parentScope, instanceId, rawInstanceId, entry } = input;
198 const driver = driversById.get(entry.driver);
199 if (!driver) {
200 return {
201 kind: "unavailable" as const,
202 snapshot: yield* buildUnavailableProviderSnapshot({
203 driverKind: entry.driver,
204 instanceId,
205 displayName: entry.displayName,
206 accentColor: entry.accentColor,
207 reason: `Driver '${entry.driver}' is not registered in this build.`,
208 }),
209 };
210 }
211
212 const decoder = Schema.decodeUnknownEffect(driver.configSchema);
213 const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result);
214 if (decodeResult._tag === "Failure") {
215 const issue = decodeResult.failure;
216 const detail = issue.message ?? String(issue);
217 yield* Effect.logError("Failed to decode provider instance config", {
218 instanceId: rawInstanceId,
219 driver: entry.driver,
220 detail,
221 });
222 return {
223 kind: "unavailable" as const,
224 snapshot: yield* buildUnavailableProviderSnapshot({
225 driverKind: entry.driver,
226 instanceId,
227 displayName: entry.displayName,
228 accentColor: entry.accentColor,
229 reason: `Invalid config for instance '${rawInstanceId}': ${detail}`,
230 }),
231 };
232 }
233
234 const typedConfig = decodeResult.success;
235 const childScope = yield* Scope.make();
236 // Attach the child scope to the registry's parent scope: if the
237 // registry scope closes, each surviving instance's child scope is
238 // closed through this finalizer. `reconcile` manually closes the
239 // child scope on remove/replace; subsequent close via the parent's
240 // finalizer is a no-op because `Scope.close` is idempotent.
241 yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore));
242
243 const createResult = yield* driver
244 .create({
245 instanceId,
246 displayName: entry.displayName,
247 accentColor: entry.accentColor,
248 environment: entry.environment ?? [],
249 enabled: resolveEntryEnabled(entry, typedConfig),
250 config: typedConfig,
251 })
252 .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result);
253 if (createResult._tag === "Failure") {
254 yield* Effect.logError("Failed to create provider instance", {
255 instanceId: rawInstanceId,
256 driver: entry.driver,
257 detail: createResult.failure.detail,
258 });
259 yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore);
260 return {
261 kind: "unavailable" as const,
262 snapshot: yield* buildUnavailableProviderSnapshot({
263 driverKind: entry.driver,
264 instanceId,
265 displayName: entry.displayName,
266 accentColor: entry.accentColor,
267 reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`,
268 }),
269 };
270 }
271
272 return {
273 kind: "live" as const,
274 live: {
275 instance: createResult.success,
276 scope: childScope,
277 entry,
278 },
279 };
280 });
281
282/**
283 * Reconcile-only implementation of the mutator. Exposed to the hydration
284 * layer; never called directly by the rest of the server.
285 */
286const makeReconcile = <R>(input: {
287 readonly state: RegistryState;
288 readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>;
289 readonly parentScope: Scope.Scope;
290}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect<void, never, R>) => {
291 const { state, driversById, parentScope } = input;
292 return (configMap: ProviderInstanceConfigMap) =>
293 Effect.gen(function* () {
294 const previousEntries = yield* Ref.get(state.entries);
295 const previousUnavailable = yield* Ref.get(state.unavailable);
296 const nextRaw = Object.entries(configMap);
297 const nextKeys = new Set<ProviderInstanceId>(
298 nextRaw.map(([raw]) => ProviderInstanceId.make(raw)),
299 );
300
301 // 1. Close scopes for instances that disappeared or whose config
302 // changed. Do this BEFORE creating replacements so ids map 1-to-1
303 // to live scopes at all times.
304 const removedIds: Array<ProviderInstanceId> = [];
305 const replacedIds = new Set<ProviderInstanceId>();
306 for (const [instanceId, live] of previousEntries) {
307 if (!nextKeys.has(instanceId)) {
308 removedIds.push(instanceId);
309 continue;
310 }
311 const nextEntry = configMap[instanceId];
312 if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) {
313 replacedIds.add(instanceId);
314 }
315 }
316 for (const id of [...removedIds, ...replacedIds]) {
317 const live = previousEntries.get(id);
318 if (live) {
319 yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore);
320 }
321 }
322
323 // 2. Build additions and replacements. Walk `nextRaw` so the final
324 // entry order follows settings-author order.
325 const builtEntries = new Map<ProviderInstanceId, LiveEntry>();
326 const builtUnavailable = new Map<ProviderInstanceId, ServerProvider>();
327 let orderChanged = false;
328 const previousOrder = [...previousEntries.keys()];
329 const nextOrder: Array<ProviderInstanceId> = [];
330
331 for (const [rawInstanceId, entry] of nextRaw) {
332 const instanceId = ProviderInstanceId.make(rawInstanceId);
333 nextOrder.push(instanceId);
334
335 const existing = previousEntries.get(instanceId);
336 if (existing !== undefined && !replacedIds.has(instanceId)) {
337 // No-op update: keep the existing live entry and scope.
338 builtEntries.set(instanceId, existing);
339 continue;
340 }
341
342 const result = yield* buildEntry({
343 driversById,
344 parentScope,
345 instanceId,
346 rawInstanceId,
347 entry,
348 });
349 if (result.kind === "live") {
350 builtEntries.set(instanceId, result.live);
351 } else {
352 builtUnavailable.set(instanceId, result.snapshot);
353 }
354 }
355
356 if (previousOrder.length === nextOrder.length) {
357 for (let i = 0; i < previousOrder.length; i++) {
358 if (previousOrder[i] !== nextOrder[i]) {
359 orderChanged = true;
360 break;
361 }
362 }
363 } else {
364 orderChanged = true;
365 }
366
367 const entriesChanged =
368 orderChanged ||
369 removedIds.length > 0 ||
370 replacedIds.size > 0 ||
371 builtEntries.size !== previousEntries.size;
372 const unavailableChanged =
373 builtUnavailable.size !== previousUnavailable.size ||
374 [...builtUnavailable].some(([id, snapshot]) => {
375 const prev = previousUnavailable.get(id);
376 return prev === undefined || !Equal.equals(prev, snapshot);
377 }) ||
378 [...previousUnavailable].some(([id]) => !builtUnavailable.has(id));
379
380 yield* Ref.set(state.entries, builtEntries);
381 yield* Ref.set(state.unavailable, builtUnavailable);
382
383 if (entriesChanged || unavailableChanged) {
384 yield* PubSub.publish(state.changes, undefined);
385 }
386 });
387};
388
389/**
390 * Build the registry's runtime state from a concrete configMap. Returns a
391 * record containing:
392 *
393 * - `registry`: the read-only `ProviderInstanceRegistry["Service"]` to expose
394 * under `ProviderInstanceRegistry`.
395 * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose
396 * under `ProviderInstanceRegistryMutator`.
397 * - `reconcile`: the raw reconcile function, provided for convenience so
398 * boot-time layers can hydrate an initial map before publishing the
399 * services.
400 *
401 * The scope that this effect runs in owns every per-instance child scope
402 * created during `reconcile`. Closing that scope closes every live
403 * instance.
404 */
405export const makeProviderInstanceRegistry = <R>(input: {
406 readonly drivers: ReadonlyArray<AnyProviderDriver<R>>;
407 readonly configMap: ProviderInstanceConfigMap;
408}): Effect.Effect<
409 {
410 readonly registry: ProviderInstanceRegistry["Service"];
411 readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape;
412 },
413 never,
414 R | Scope.Scope
415> =>
416 Effect.gen(function* () {
417 const driversById = new Map<ProviderDriverKind, AnyProviderDriver<R>>(
418 input.drivers.map((driver) => [driver.driverKind, driver]),
419 );
420
421 // Capture the enclosing scope so per-instance child scopes can be
422 // attached to it at `reconcile` time. Without this, `reconcile`
423 // called later (e.g. from the hydration layer) would attach child
424 // scopes to the *caller's* scope instead of the registry's.
425 const parentScope = yield* Scope.Scope;
426
427 // Capture the driver R context at construction time so `reconcile`
428 // can be invoked later without re-providing driver dependencies.
429 // The service tag's declared `reconcile: Effect<void>` hides R from
430 // consumers — we materialize that here.
431 const driverContext = yield* Effect.context<R>();
432
433 const entries = yield* Ref.make<ReadonlyMap<ProviderInstanceId, LiveEntry>>(new Map());
434 const unavailable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ServerProvider>>(new Map());
435 const changes = yield* PubSub.unbounded<void>();
436 yield* Effect.addFinalizer(() => PubSub.shutdown(changes));
437
438 const state: RegistryState = { entries, unavailable, changes };
439 const reconcileWithR = makeReconcile({ state, driversById, parentScope });
440 const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] =
441 (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext));
442
443 // Hydrate the initial configMap synchronously so callers can read
444 // `listInstances` immediately after this effect completes.
445 yield* reconcile(input.configMap);
446
447 const registry: ProviderInstanceRegistry["Service"] = {
448 getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)),
449 listInstances: Ref.get(entries).pipe(
450 Effect.map(
451 (map) =>
452 Array.from(map.values(), (live) => live.instance) as ReadonlyArray<ProviderInstance>,
453 ),
454 ),
455 listUnavailable: Ref.get(unavailable).pipe(
456 Effect.map((map) => Array.from(map.values()) as ReadonlyArray<ServerProvider>),
457 ),
458 // Getters: each read constructs a fresh Stream / Effect descriptor
459 // so multiple consumers don't share a single already-started
460 // Channel or subscription. Matches the pattern `ProviderRegistry`
461 // uses for its own `streamChanges`.
462 get streamChanges() {
463 return Stream.fromPubSub(changes);
464 },
465 // Synchronous subscribe — callers that need to consume changes
466 // from a forked fibre must acquire the subscription in their own
467 // fibre first (via `yield* registry.subscribeChanges`) and only
468 // then fork a consumer loop on `Stream.fromSubscription(...)` /
469 // `PubSub.take(...)`. See the shape docs for the race this avoids.
470 get subscribeChanges() {
471 return PubSub.subscribe(changes);
472 },
473 };
474
475 const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = {
476 reconcile,
477 };
478
479 return { registry, mutator };
480 });
481
482/**
483 * Layer variant that also exposes the mutator tag. Consumed by
484 * `ProviderInstanceRegistryHydrationLive` to reconcile on settings
485 * changes. Tests that exercise the mutator directly can pair this Layer
486 * with a test-local `ServerSettingsService`.
487 */
488export const layer = <R>(input: {
489 readonly drivers: ReadonlyArray<AnyProviderDriver<R>>;
490 readonly configMap: ProviderInstanceConfigMap;
491}): Layer.Layer<
492 ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
493 never,
494 R
495> =>
496 Layer.effectContext(
497 makeProviderInstanceRegistry(input).pipe(
498 Effect.map(({ registry, mutator }) =>
499 Context.make(ProviderInstanceRegistry, registry).pipe(
500 Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator),
501 ),
502 ),
503 ),
504 ) as Layer.Layer<
505 ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
506 never,
507 R
508 >;
apps/server/src/provider/ProviderInstanceRegistryHydration.ts created+192
......@@ -0,0 +1,192 @@
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 `ProviderInstanceRegistry.layer`.
33 * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that
34 * acquires `ServerSettingsService.subscribeChanges` 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/ProviderInstanceRegistryHydration
43 */
44import {
45 defaultInstanceIdForDriver,
46 type ProviderInstanceConfig,
47 type ProviderInstanceConfigMap,
48 ServerSettings,
49} from "@t3tools/contracts";
50import * as Effect from "effect/Effect";
51import * as Layer from "effect/Layer";
52import * as Stream from "effect/Stream";
53
54import * as Settings from "../serverSettings.ts";
55import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts";
56import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
57import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts";
58import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";
59import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts";
60import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts";
61
62type ProviderInstanceRegistryHydrationEnv =
63 | Exclude<
64 BuiltInDriversEnv,
65 | ProviderOrchestrationAdapterInfrastructure.ProviderOrchestrationAdapterInfrastructure
66 | AcpRegistrySupport.AcpRegistryCatalog
67 >
68 | Settings.ServerSettingsService;
69
70/**
71 * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot.
72 *
73 * Strategy:
74 * 1. Copy all explicit `settings.providerInstances` entries verbatim.
75 * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key
76 * is *not* already in the explicit map, synthesize an entry from the
77 * matching legacy `settings.providers.<kind>` blob.
78 *
79 * The returned map is the input the registry consumes; pure & exported
80 * separately so the hydration logic can be exercised by unit tests
81 * without layering.
82 */
83export const deriveProviderInstanceConfigMap = (
84 settings: ServerSettings,
85): ProviderInstanceConfigMap => {
86 const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances };
87
88 for (const driver of BUILT_IN_DRIVERS) {
89 const instanceId = defaultInstanceIdForDriver(driver.driverKind);
90 if (instanceId in merged) {
91 // Explicit `providerInstances` entry for this slot — user-authored
92 // config always wins over the legacy mirror.
93 continue;
94 }
95
96 // Only built-in drivers have a legacy mirror; the registry's
97 // `providers` struct is keyed on the same literal slug as
98 // `driverKind`. Access is dynamic (the driver kind is a branded string),
99 // but it's constrained to `keyof settings.providers` by the union of
100 // built-in driver kinds.
101 const legacyKey = driver.driverKind as keyof ServerSettings["providers"];
102 const legacyConfig = settings.providers[legacyKey];
103 if (legacyConfig === undefined) {
104 continue;
105 }
106
107 merged[instanceId] = {
108 driver: driver.driverKind,
109 config: legacyConfig,
110 };
111 }
112
113 return merged as ProviderInstanceConfigMap;
114};
115
116/**
117 * Layer that consumes `ProviderInstanceRegistryMutator` and forks a
118 * settings-watcher fiber. The fiber's lifetime is tied to the enclosing
119 * layer scope (process lifetime in production), so it is interrupted on
120 * shutdown without leaking.
121 *
122 * Errors inside the watcher are logged and swallowed — the registry's own
123 * "unavailable" bucket already absorbs unknown drivers and invalid
124 * configs, so the only way the watcher could fail is a settings stream
125 * tear-down, which logs and exits cleanly.
126 */
127const layerSettingsWatcher = Layer.effectDiscard(
128 Effect.gen(function* () {
129 const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator;
130 const serverSettings = yield* Settings.ServerSettingsService;
131 const settingsChanges = yield* serverSettings.subscribeChanges;
132 yield* settingsChanges.pipe(
133 Stream.runForEach((next) =>
134 mutator
135 .reconcile(deriveProviderInstanceConfigMap(next))
136 .pipe(
137 Effect.catchCause((cause) =>
138 Effect.logError("ProviderInstanceRegistry reconcile failed", cause),
139 ),
140 ),
141 ),
142 Effect.forkScoped,
143 );
144 }),
145);
146
147/**
148 * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in
149 * sync with subsequent `streamChanges` emissions.
150 *
151 * The Layer's two halves:
152 * - `ProviderInstanceRegistry.layer` produces the registry +
153 * mutator from the initial config map. Its scope owns every
154 * per-instance child scope created during reconcile.
155 * - `SettingsWatcherLive` consumes the mutator, acquires its settings
156 * subscription before forking, and runs a daemon fiber in the same scope.
157 *
158 * Composing via `Layer.provideMerge` makes the watcher's deps available
159 * from the mutable layer while still surfacing the registry as an output.
160 * The mutator tag is technically also exposed; only this module imports
161 * it, so the visibility leak is harmless in practice.
162 */
163export const layer: Layer.Layer<
164 ProviderInstanceRegistry.ProviderInstanceRegistry,
165 never,
166 ProviderInstanceRegistryHydrationEnv
167> = Layer.unwrap(
168 Effect.gen(function* () {
169 const serverSettings = yield* Settings.ServerSettingsService;
170 const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe(
171 Effect.orElseSucceed(() => undefined),
172 );
173 const initialConfigMap =
174 initialSettings === undefined
175 ? ({} as ProviderInstanceConfigMap)
176 : deriveProviderInstanceConfigMap(initialSettings);
177
178 const layerMutable = ProviderInstanceRegistry.layer({
179 drivers: BUILT_IN_DRIVERS,
180 configMap: initialConfigMap,
181 }).pipe(
182 Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer),
183 Layer.provide(AcpRegistryCatalog.layer),
184 );
185
186 return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable));
187 }),
188) as Layer.Layer<
189 ProviderInstanceRegistry.ProviderInstanceRegistry,
190 never,
191 ProviderInstanceRegistryHydrationEnv
192>;
apps/server/src/provider/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/ProviderInstanceRegistryMutator
30 */
31import type { ProviderInstanceConfigMap } from "@t3tools/contracts";
32import * as Context from "effect/Context";
33import type * as Effect from "effect/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/ProviderInstanceRegistryMutator") {}
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts created+27
......@@ -0,0 +1,27 @@
1import * as Layer from "effect/Layer";
2
3import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
5import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts";
6import * as IdAllocator from "../orchestration-v2/IdAllocator.ts";
7import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts";
8
9export type ProviderOrchestrationAdapterInfrastructure =
10 | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner
11 | CodexAdapterV2.CodexAppServerClientFactory
12 | CursorAgentSdk.CursorAgentSdkRunner
13 | IdAllocator.IdAllocatorV2;
14
15/**
16 * Infrastructure shared by the V2 adapters materialized inside provider
17 * instances. `providerContinuationRequestsLayer` must be the same layer
18 * reference the orchestration runtime provides to its continuation worker so
19 * Effect layer memoization yields one shared queue.
20 */
21export const layer = Layer.mergeAll(
22 ClaudeAdapterV2.layerQueryRunner,
23 CodexAdapterV2.layerAppServerClientFactory,
24 CursorAgentSdk.layer,
25 IdAllocator.layer,
26 ProviderContinuationRequests.layer,
27);
apps/server/src/provider/ProviderRegistry.test.ts created+3376
......@@ -0,0 +1,3376 @@
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "./CodexInstallation.ts";
3import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, it, assert } from "@effect/vitest";
6import * as DateTime from "effect/DateTime";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Path from "effect/Path";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as PubSub from "effect/PubSub";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Sink from "effect/Sink";
18import * as Stream from "effect/Stream";
19import * as TestClock from "effect/testing/TestClock";
20import * as CodexErrors from "effect-codex-app-server/errors";
21import {
22 EnvironmentId,
23 ClaudeSettings,
24 CodexSettings,
25 DEFAULT_SERVER_SETTINGS,
26 ProviderDriverKind,
27 ProviderInstanceId,
28 ServerSettings,
29 type ServerProvider,
30 type ServerProviderSlashCommand,
31 type ServerSettings as ContractServerSettings,
32} from "@t3tools/contracts";
33import * as PlatformError from "effect/PlatformError";
34import { HttpClient, HttpClientResponse } from "effect/http";
35import { ChildProcessSpawner } from "effect/process";
36import { deepMerge } from "@t3tools/shared/Struct";
37import { createModelCapabilities } from "@t3tools/shared/model";
38import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
39
40import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
41import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
42import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
43import * as AntigravityInstallation from "./AntigravityInstallation.ts";
44import * as ModelManifest from "./ModelManifest.ts";
45import { applyProviderCompatibility } from "./providerCompatibility.ts";
46import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
47import * as OpenCodeRuntime from "./opencodeRuntime.ts";
48import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
49import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
50import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts";
51import * as ServerConfig from "../config.ts";
52import * as ServerSettingsModule from "../serverSettings.ts";
53import {
54 readProviderStatusCache,
55 resolveProviderStatusCachePath,
56 writeProviderStatusCache,
57} from "./providerStatusCache.ts";
58import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts";
59import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts";
60import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
61import * as ProviderRegistry from "./ProviderRegistry.ts";
62import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
63const decodeServerSettings = Schema.decodeSync(ServerSettings);
64const encodeServerSettings = Schema.encodeSync(ServerSettings);
65const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
66
67const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
68const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
69const decodeCodexSettings = Schema.decodeSync(CodexSettings);
70const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({
71 enabled: false,
72});
73
74process.env.T3CODE_CURSOR_ENABLED = "1";
75
76// ── Test helpers ────────────────────────────────────────────────────
77
78const encoder = new TextEncoder();
79const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
80const withBundledCompatibility = (snapshot: ServerProvider) =>
81 applyProviderCompatibility(
82 snapshot,
83 undefined,
84 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
85 );
86
87// Provider metadata checks use a bundled manifest and stubbed HTTP.
88const layerTestHttpClient = Layer.succeed(
89 HttpClient.HttpClient,
90 HttpClient.make((request) =>
91 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
92 ),
93).pipe(Layer.provideMerge(ModelManifest.layerTest));
94
95const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
96 reportClientActivity: () => Effect.void,
97 removeRpcClient: () => Effect.void,
98 reportHostPowerState: () => Effect.void,
99 snapshot: Effect.succeed({
100 hostPower: {
101 source: "unknown",
102 idle: "unknown",
103 idleSeconds: null,
104 locked: "unknown",
105 suspended: false,
106 onBattery: "unknown",
107 lowPowerMode: "unknown",
108 thermalState: "unknown",
109 stale: true,
110 updatedAt: TEST_EPOCH,
111 },
112 leases: [],
113 activeForegroundLeaseCount: 0,
114 activeScopeKeys: [],
115 shouldRunOpportunisticWork: true,
116 updatedAt: TEST_EPOCH,
117 }),
118 streamChanges: Stream.empty,
119 hasDemand: () => Effect.succeed(true),
120 shouldRunScopeWork: () => Effect.succeed(true),
121 shouldRunOpportunisticWork: Effect.succeed(true),
122});
123
124function selectDescriptor(
125 id: string,
126 label: string,
127 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
128) {
129 return {
130 id,
131 label,
132 type: "select" as const,
133 options: [...options],
134 ...(options.find((option) => option.isDefault)?.id
135 ? { currentValue: options.find((option) => option.isDefault)?.id }
136 : {}),
137 };
138}
139
140function booleanDescriptor(id: string, label: string) {
141 return {
142 id,
143 label,
144 type: "boolean" as const,
145 };
146}
147
148type TestClaudeCapabilities = {
149 readonly email: string | undefined;
150 readonly subscriptionType: string | undefined;
151 readonly tokenSource: string | undefined;
152 readonly apiProvider: string | undefined;
153 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
154};
155
156function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
157 return () =>
158 Effect.succeed({
159 email: undefined,
160 subscriptionType: undefined,
161 tokenSource: undefined,
162 apiProvider: undefined,
163 slashCommands: [],
164 ...overrides,
165 });
166}
167
168const noClaudeCapabilities = () =>
169 Effect.sync(() => undefined as TestClaudeCapabilities | undefined);
170
171function mockHandle(result: { stdout: string; stderr: string; code: number }) {
172 return ChildProcessSpawner.makeHandle({
173 pid: ChildProcessSpawner.ProcessId(1),
174 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)),
175 isRunning: Effect.succeed(false),
176 kill: () => Effect.void,
177 unref: Effect.succeed(Effect.void),
178 stdin: Sink.drain,
179 stdout: Stream.make(encoder.encode(result.stdout)),
180 stderr: Stream.make(encoder.encode(result.stderr)),
181 all: Stream.empty,
182 getInputFd: () => Sink.drain,
183 getOutputFd: () => Stream.empty,
184 });
185}
186
187function layerMockSpawner(
188 handler: (args: ReadonlyArray<string>) => {
189 stdout: string;
190 stderr: string;
191 code: number;
192 },
193) {
194 return Layer.succeed(
195 ChildProcessSpawner.ChildProcessSpawner,
196 ChildProcessSpawner.make((command) => {
197 const cmd = command as unknown as { args: ReadonlyArray<string> };
198 return Effect.succeed(mockHandle(handler(cmd.args)));
199 }),
200 );
201}
202
203function recordingMockSpawnerLayer(
204 handler: (args: ReadonlyArray<string>) => {
205 stdout: string;
206 stderr: string;
207 code: number;
208 },
209) {
210 const commands: Array<{
211 readonly args: ReadonlyArray<string>;
212 readonly env: NodeJS.ProcessEnv | undefined;
213 }> = [];
214 const layer = Layer.succeed(
215 ChildProcessSpawner.ChildProcessSpawner,
216 ChildProcessSpawner.make((command) => {
217 const cmd = command as unknown as {
218 args: ReadonlyArray<string>;
219 options?: {
220 readonly env?: NodeJS.ProcessEnv;
221 };
222 };
223 commands.push({ args: cmd.args, env: cmd.options?.env });
224 return Effect.succeed(mockHandle(handler(cmd.args)));
225 }),
226 );
227 return { layer, commands };
228}
229
230function layerMockCommandSpawner(
231 handler: (
232 command: string,
233 args: ReadonlyArray<string>,
234 ) => { stdout: string; stderr: string; code: number },
235) {
236 return Layer.succeed(
237 ChildProcessSpawner.ChildProcessSpawner,
238 ChildProcessSpawner.make((command) => {
239 const cmd = command as unknown as {
240 command: string;
241 args: ReadonlyArray<string>;
242 };
243 return Effect.succeed(mockHandle(handler(cmd.command, cmd.args)));
244 }),
245 );
246}
247
248function layerFailingSpawner(description: string) {
249 return Layer.succeed(
250 ChildProcessSpawner.ChildProcessSpawner,
251 ChildProcessSpawner.make(() =>
252 Effect.fail(
253 PlatformError.systemError({
254 _tag: "NotFound",
255 module: "ChildProcess",
256 method: "spawn",
257 description,
258 }),
259 ),
260 ),
261 );
262}
263
264function layerHangingScopedSpawner(killCalls: Ref.Ref<number>) {
265 return Layer.succeed(
266 ChildProcessSpawner.ChildProcessSpawner,
267 ChildProcessSpawner.make(() =>
268 Effect.gen(function* () {
269 const handle = ChildProcessSpawner.makeHandle({
270 pid: ChildProcessSpawner.ProcessId(1),
271 exitCode: Effect.never,
272 isRunning: Effect.succeed(true),
273 kill: () => Ref.update(killCalls, (current) => current + 1),
274 unref: Effect.succeed(Effect.void),
275 stdin: Sink.drain,
276 stdout: Stream.never,
277 stderr: Stream.never,
278 all: Stream.never,
279 getInputFd: () => Sink.drain,
280 getOutputFd: () => Stream.empty,
281 });
282 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
283 return handle;
284 }),
285 ),
286 );
287}
288
289const codexModelCapabilities = createModelCapabilities({
290 optionDescriptors: [
291 selectDescriptor("reasoningEffort", "Reasoning", [
292 { id: "high", label: "High", isDefault: true },
293 { id: "low", label: "Low" },
294 ]),
295 booleanDescriptor("fastMode", "Fast Mode"),
296 ],
297}) satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>;
298
299function makeCodexProbeSnapshot(
300 input: Partial<CodexAppServerProviderSnapshot> = {},
301): CodexAppServerProviderSnapshot {
302 return {
303 version: "1.0.0",
304 account: {
305 account: {
306 type: "chatgpt",
307 email: "test@example.com",
308 planType: "pro",
309 },
310 requiresOpenaiAuth: false,
311 },
312 models: [
313 {
314 slug: "gpt-live-codex",
315 name: "GPT Live Codex",
316 isCustom: false,
317 capabilities: codexModelCapabilities,
318 },
319 ],
320 skills: [],
321 ...input,
322 };
323}
324
325function makeMutableServerSettingsService(
326 initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS,
327) {
328 return Effect.gen(function* () {
329 const settingsRef = yield* Ref.make(initial);
330 const changes = yield* PubSub.unbounded<ContractServerSettings>();
331
332 return {
333 start: Effect.void,
334 ready: Effect.void,
335 getSettings: Ref.get(settingsRef),
336 updateSettings: (patch) =>
337 Effect.gen(function* () {
338 const current = yield* Ref.get(settingsRef);
339 const next = applyServerSettingsPatch(current, patch);
340 encodeServerSettings(next);
341 yield* Ref.set(settingsRef, next);
342 yield* PubSub.publish(changes, next);
343 return next;
344 }),
345 updateProviderInstance: (mutation, patch = {}) =>
346 Effect.gen(function* () {
347 const current = yield* Ref.get(settingsRef);
348 const next = ServerSettingsModule.applyProviderInstanceMutation(
349 applyServerSettingsPatch(current, patch),
350 mutation,
351 );
352 encodeServerSettings(next);
353 yield* Ref.set(settingsRef, next);
354 yield* PubSub.publish(changes, next);
355 return next;
356 }),
357 withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)),
358 get streamChanges() {
359 return Stream.fromPubSub(changes);
360 },
361 get subscribeChanges() {
362 return PubSub.subscribe(changes).pipe(
363 Effect.map((subscription) => Stream.fromSubscription(subscription)),
364 );
365 },
366 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
367 });
368}
369
370// The registry writes the status cache and only then publishes the change, so
371// a subscriber that sees `checkedAt` on the stream knows the file is on disk.
372// Subscribed before the publish that triggers it; a spin on the file would
373// race the write and lose on a slow host.
374const awaitPersistedProvider = (
375 registry: ProviderRegistry.ProviderRegistry["Service"],
376 checkedAt: string,
377) =>
378 registry.streamChanges.pipe(
379 Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)),
380 Stream.take(1),
381 Stream.runDrain,
382 Effect.forkScoped,
383 );
384
385const layerTestNodeServices = Layer.mergeAll(
386 NodeServices.layer,
387 Layer.mock(CodexInstallation.CodexInstallation)({
388 managedDirectory: "unused-managed-installation",
389 }),
390 Layer.mock(ServerSecretStore.ServerSecretStore)({}),
391 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
392 getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")),
393 }),
394);
395
396it.layer(
397 Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient),
398)("ProviderRegistry", (it) => {
399 describe("checkCodexProviderStatus", () => {
400 it.effect("uses the app-server account and model list for provider status", () =>
401 Effect.gen(function* () {
402 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
403 Effect.succeed(
404 makeCodexProbeSnapshot({
405 skills: [
406 {
407 name: "github:gh-fix-ci",
408 path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md",
409 enabled: true,
410 displayName: "CI Debug",
411 shortDescription: "Debug failing GitHub Actions checks",
412 },
413 ],
414 }),
415 ),
416 );
417 assert.strictEqual(status.status, "ready");
418 assert.strictEqual(status.installed, true);
419 assert.strictEqual(status.version, "1.0.0");
420 assert.strictEqual(status.auth.status, "authenticated");
421 assert.strictEqual(status.auth.type, "chatgpt");
422 assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription");
423 assert.strictEqual(status.auth.email, "test@example.com");
424 assert.deepStrictEqual(status.models, [
425 {
426 slug: "gpt-live-codex",
427 name: "GPT Live Codex",
428 isCustom: false,
429 capabilities: codexModelCapabilities,
430 },
431 ]);
432 assert.deepStrictEqual(status.skills, [
433 {
434 name: "github:gh-fix-ci",
435 path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md",
436 enabled: true,
437 displayName: "CI Debug",
438 shortDescription: "Debug failing GitHub Actions checks",
439 },
440 ]);
441 assert.deepStrictEqual(status.slashCommands.slice(1), [
442 {
443 name: "goal",
444 description: "Set a goal Codex keeps working toward until it is done",
445 input: { hint: "Objective, or pause, resume, clear" },
446 },
447 {
448 name: "feedback",
449 description: "Send this thread and Codex logs to OpenAI",
450 input: { hint: "Describe the issue (optional)" },
451 },
452 ]);
453 }),
454 );
455
456 it.effect("passes configured launch args to the Codex provider probe", () =>
457 Effect.gen(function* () {
458 let observedLaunchArgs: string | undefined;
459 const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" });
460
461 const status = yield* checkCodexProviderStatus(settings, (input) => {
462 observedLaunchArgs = input.launchArgs;
463 return Effect.succeed(makeCodexProbeSnapshot());
464 });
465
466 assert.strictEqual(status.status, "ready");
467 assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo");
468 }),
469 );
470
471 it.effect("returns unauthenticated when app-server requires OpenAI auth", () =>
472 Effect.gen(function* () {
473 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
474 Effect.succeed(
475 makeCodexProbeSnapshot({
476 account: {
477 account: null,
478 requiresOpenaiAuth: true,
479 },
480 }),
481 ),
482 );
483
484 assert.strictEqual(status.status, "error");
485 assert.strictEqual(status.auth.status, "unauthenticated");
486 assert.strictEqual(
487 status.message,
488 "Codex CLI is not authenticated. Run `codex login` and try again.",
489 );
490 }),
491 );
492
493 it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () =>
494 Effect.gen(function* () {
495 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
496 Effect.succeed(
497 makeCodexProbeSnapshot({
498 account: {
499 account: null,
500 requiresOpenaiAuth: false,
501 },
502 }),
503 ),
504 );
505
506 assert.strictEqual(status.status, "ready");
507 assert.strictEqual(status.auth.status, "unknown");
508 }),
509 );
510
511 it.effect("returns an api key label for codex api key auth", () =>
512 Effect.gen(function* () {
513 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
514 Effect.succeed(
515 makeCodexProbeSnapshot({
516 account: {
517 account: { type: "apiKey" },
518 requiresOpenaiAuth: false,
519 },
520 }),
521 ),
522 );
523
524 assert.strictEqual(status.status, "ready");
525 assert.strictEqual(status.auth.status, "authenticated");
526 assert.strictEqual(status.auth.type, "apiKey");
527 assert.strictEqual(status.auth.label, "OpenAI API Key");
528 }),
529 );
530
531 it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () =>
532 Effect.gen(function* () {
533 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
534 Effect.succeed(
535 makeCodexProbeSnapshot({
536 account: {
537 account: { type: "amazonBedrock" },
538 requiresOpenaiAuth: false,
539 },
540 }),
541 ),
542 );
543
544 assert.strictEqual(status.status, "ready");
545 assert.strictEqual(status.auth.status, "authenticated");
546 assert.strictEqual(status.auth.type, "amazonBedrock");
547 assert.strictEqual(status.auth.label, "Amazon Bedrock");
548 }),
549 );
550
551 it.effect.each([
552 "codex",
553 "/Applications/Custom App.app/Contents/Resources/codex",
554 "C:\\Tools\\codex.exe",
555 ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) =>
556 Effect.gen(function* () {
557 const settings = { ...defaultCodexSettings, binaryPath };
558 const status = yield* checkCodexProviderStatus(settings, (input) => {
559 assert.strictEqual(input.binaryPath, binaryPath);
560 return Effect.fail(
561 new CodexErrors.CodexAppServerSpawnError({
562 command: `${binaryPath} app-server`,
563 cause: new Error("spawn ENOENT"),
564 }),
565 );
566 });
567 assert.strictEqual(status.status, "error");
568 assert.strictEqual(status.installed, false);
569 assert.strictEqual(status.auth.status, "unknown");
570 assert.include(status.message, binaryPath);
571 assert.include(status.message, "Settings → Providers → Codex → Binary path on the server");
572 assert.strictEqual(
573 status.message?.includes("Installing ChatGPT or Codex desktop"),
574 binaryPath === "codex",
575 );
576 assert.strictEqual(settings.binaryPath, binaryPath);
577 }),
578 );
579
580 it.effect("closes the app-server probe scope when provider status times out", () =>
581 Effect.gen(function* () {
582 const killCalls = yield* Ref.make(0);
583 const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe(
584 Effect.provide(layerHangingScopedSpawner(killCalls)),
585 Effect.forkChild,
586 );
587
588 yield* Effect.yieldNow;
589 yield* TestClock.adjust("11 seconds");
590 yield* Effect.yieldNow;
591
592 const status = yield* Fiber.join(statusFiber);
593 assert.strictEqual(status.status, "error");
594 assert.strictEqual(
595 status.message,
596 "Timed out while checking Codex app-server provider status.",
597 );
598 assert.strictEqual(yield* Ref.get(killCalls), 1);
599 }),
600 );
601 });
602
603 describe("ProviderRegistry.layer", () => {
604 it("stores workspace skills and commands without changing machine metadata", () => {
605 const provider = {
606 instanceId: ProviderInstanceId.make("codex"),
607 driver: ProviderDriverKind.make("codex"),
608 status: "ready",
609 enabled: true,
610 installed: true,
611 auth: { status: "authenticated" },
612 checkedAt: "2026-03-25T00:00:00.000Z",
613 version: "1.0.0",
614 models: [],
615 slashCommands: [{ name: "global" }],
616 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
617 } satisfies ServerProvider;
618 const scopedSnapshot = {
619 ...provider,
620 checkedAt: "2026-03-25T00:01:00.000Z",
621 slashCommands: [{ name: "project" }],
622 skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }],
623 } satisfies ServerProvider;
624
625 const result = ProviderRegistry.upsertProviderWorkspaceSnapshot(
626 provider,
627 "/project",
628 scopedSnapshot,
629 );
630
631 assert.deepStrictEqual(result.slashCommands, provider.slashCommands);
632 assert.deepStrictEqual(result.skills, provider.skills);
633 assert.deepStrictEqual(result.workspaceSnapshots, [
634 {
635 cwd: "/project",
636 checkedAt: scopedSnapshot.checkedAt,
637 slashCommands: scopedSnapshot.slashCommands,
638 skills: scopedSnapshot.skills,
639 },
640 ]);
641
642 const pendingSnapshot = {
643 ...scopedSnapshot,
644 slashCommands: [COMPACT_SLASH_COMMAND],
645 slashCommandsPending: true,
646 } satisfies ProviderWorkspaceSnapshot;
647 const partial = ProviderRegistry.upsertProviderWorkspaceSnapshot(
648 result,
649 "/project",
650 pendingSnapshot,
651 );
652 assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [{ name: "project" }]);
653 assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true);
654 const otherProject = ProviderRegistry.upsertProviderWorkspaceSnapshot(
655 partial,
656 "/other-project",
657 pendingSnapshot,
658 );
659 assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [
660 COMPACT_SLASH_COMMAND,
661 ]);
662 const recovered = ProviderRegistry.upsertProviderWorkspaceSnapshot(partial, "/project", {
663 ...scopedSnapshot,
664 slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }],
665 });
666 assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [
667 COMPACT_SLASH_COMMAND,
668 { name: "replacement" },
669 ]);
670 assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined);
671 });
672
673 it("preserves previously discovered provider models when a refresh returns none", () => {
674 const previousProvider = {
675 instanceId: ProviderInstanceId.make("cursor"),
676 driver: ProviderDriverKind.make("cursor"),
677 status: "ready",
678 enabled: true,
679 installed: true,
680 auth: { status: "authenticated" },
681 checkedAt: "2026-04-14T00:00:00.000Z",
682 version: "2026.04.09-f2b0fcd",
683 models: [
684 {
685 slug: "claude-opus-4-6",
686 name: "Opus 4.6",
687 isCustom: false,
688 capabilities: createModelCapabilities({
689 optionDescriptors: [
690 selectDescriptor("reasoning", "Reasoning", [
691 { id: "high", label: "High", isDefault: true },
692 ]),
693 booleanDescriptor("fastMode", "Fast Mode"),
694 booleanDescriptor("thinking", "Thinking"),
695 ],
696 }),
697 },
698 ],
699 slashCommands: [{ name: "review", description: "Review changes" }],
700 skills: [
701 {
702 name: "typescript",
703 description: "TypeScript help",
704 path: "/skills/typescript/SKILL.md",
705 enabled: true,
706 },
707 ],
708 } as const satisfies ServerProvider;
709 const refreshedProvider = {
710 ...previousProvider,
711 checkedAt: "2026-04-14T00:01:00.000Z",
712 models: [],
713 slashCommands: [],
714 skills: [],
715 } satisfies ServerProvider;
716
717 assert.deepStrictEqual(
718 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
719 [...previousProvider.models],
720 );
721 assert.deepStrictEqual(
722 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands,
723 [],
724 );
725 assert.deepStrictEqual(
726 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills,
727 [],
728 );
729 });
730
731 it("drops custom models the refreshed snapshot no longer carries", () => {
732 const previousProvider = {
733 instanceId: ProviderInstanceId.make("claudeAgent"),
734 driver: ProviderDriverKind.make("claudeAgent"),
735 status: "ready",
736 enabled: true,
737 installed: true,
738 auth: { status: "authenticated" },
739 checkedAt: "2026-04-14T00:00:00.000Z",
740 version: "2.1.0",
741 models: [
742 {
743 slug: "claude-sonnet-4-6",
744 name: "Sonnet 4.6",
745 isCustom: false,
746 capabilities: null,
747 },
748 {
749 slug: "removed-custom",
750 name: "removed-custom",
751 isCustom: true,
752 capabilities: null,
753 },
754 ],
755 slashCommands: [],
756 skills: [],
757 } as const satisfies ServerProvider;
758 const refreshedProvider = {
759 ...previousProvider,
760 checkedAt: "2026-04-14T00:01:00.000Z",
761 models: [previousProvider.models[0]],
762 } satisfies ServerProvider;
763
764 assert.deepStrictEqual(
765 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
766 [...refreshedProvider.models],
767 );
768 });
769
770 it("drops stale ACP Registry models missing from a completed discovery probe", () => {
771 const previousProvider = {
772 instanceId: ProviderInstanceId.make("acpRegistry_codex"),
773 driver: ProviderDriverKind.make("acpRegistry"),
774 status: "ready",
775 enabled: true,
776 installed: true,
777 auth: { status: "authenticated" },
778 checkedAt: "2026-08-13T00:00:00.000Z",
779 version: "1.2.0",
780 models: [
781 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
782 {
783 slug: "gpt-5.6-sol[low]",
784 name: "GPT-5.6-Sol (low)",
785 isCustom: false,
786 capabilities: null,
787 },
788 { slug: "default", name: "Default", isCustom: false, capabilities: null },
789 ],
790 slashCommands: [],
791 skills: [],
792 } as const satisfies ServerProvider;
793 const refreshedProvider = {
794 ...previousProvider,
795 checkedAt: "2026-08-13T00:01:00.000Z",
796 models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }],
797 } satisfies ServerProvider;
798
799 assert.deepStrictEqual(
800 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
801 [...refreshedProvider.models],
802 );
803 });
804
805 it("retains ACP Registry models while discovery has not completed", () => {
806 const previousProvider = {
807 instanceId: ProviderInstanceId.make("acpRegistry_codex"),
808 driver: ProviderDriverKind.make("acpRegistry"),
809 status: "ready",
810 enabled: true,
811 installed: true,
812 auth: { status: "authenticated" },
813 checkedAt: "2026-08-13T00:00:00.000Z",
814 version: "1.2.0",
815 models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }],
816 slashCommands: [],
817 skills: [],
818 } as const satisfies ServerProvider;
819 const checkingProvider = {
820 ...previousProvider,
821 checkedAt: "2026-08-13T00:01:00.000Z",
822 auth: { status: "unknown" },
823 models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }],
824 } satisfies ServerProvider;
825 const failedProbeProvider = {
826 ...checkingProvider,
827 status: "warning",
828 } satisfies ServerProvider;
829
830 assert.deepStrictEqual(
831 ProviderRegistry.mergeProviderSnapshot(previousProvider, checkingProvider).models,
832 [
833 { slug: "default", name: "Default", isCustom: false, capabilities: null },
834 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
835 ],
836 );
837 assert.deepStrictEqual(
838 ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProbeProvider).models,
839 [
840 { slug: "default", name: "Default", isCustom: false, capabilities: null },
841 { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
842 ],
843 );
844 });
845
846 it("drops stale OpenCode models missing from a successful refresh", () => {
847 const previousProvider = {
848 instanceId: ProviderInstanceId.make("opencode"),
849 driver: ProviderDriverKind.make("opencode"),
850 status: "ready",
851 enabled: true,
852 installed: true,
853 auth: { status: "authenticated" },
854 checkedAt: "2026-07-17T00:00:00.000Z",
855 version: "1.0.0",
856 models: [
857 {
858 slug: "github/gpt-5",
859 name: "GPT-5",
860 subProvider: "GitHub",
861 isCustom: false,
862 capabilities: null,
863 },
864 {
865 slug: "removed-plugin/model",
866 name: "Removed Plugin Model",
867 subProvider: "Removed Plugin",
868 isCustom: false,
869 capabilities: null,
870 },
871 ],
872 slashCommands: [],
873 skills: [],
874 } as const satisfies ServerProvider;
875 const refreshedProvider = {
876 ...previousProvider,
877 checkedAt: "2026-07-17T00:01:00.000Z",
878 models: [
879 {
880 slug: "github/gpt-5",
881 name: "GPT-5",
882 subProvider: "GitHub",
883 isCustom: false,
884 capabilities: null,
885 },
886 ],
887 } satisfies ServerProvider;
888
889 assert.deepStrictEqual(
890 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
891 [...refreshedProvider.models],
892 );
893 });
894
895 it("retains stale OpenCode models when a refresh fails", () => {
896 const previousProvider = {
897 instanceId: ProviderInstanceId.make("opencode"),
898 driver: ProviderDriverKind.make("opencode"),
899 status: "ready",
900 enabled: true,
901 installed: true,
902 auth: { status: "authenticated" },
903 checkedAt: "2026-07-17T00:00:00.000Z",
904 version: "1.0.0",
905 models: [
906 {
907 slug: "github/gpt-5",
908 name: "GPT-5",
909 subProvider: "GitHub",
910 isCustom: false,
911 capabilities: null,
912 },
913 ],
914 slashCommands: [{ name: "review", description: "Review changes" }],
915 skills: [
916 {
917 name: "typescript",
918 description: "TypeScript help",
919 path: "/skills/typescript/SKILL.md",
920 enabled: true,
921 },
922 ],
923 } as const satisfies ServerProvider;
924 const refreshedProvider = {
925 ...previousProvider,
926 status: "error",
927 auth: { status: "unknown" },
928 checkedAt: "2026-07-17T00:01:00.000Z",
929 models: [],
930 message: "Failed to refresh OpenCode models.",
931 } satisfies ServerProvider;
932
933 assert.deepStrictEqual(
934 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
935 [...previousProvider.models],
936 );
937 assert.deepStrictEqual(
938 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands,
939 previousProvider.slashCommands,
940 );
941 assert.deepStrictEqual(
942 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills,
943 previousProvider.skills,
944 );
945 });
946
947 it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => {
948 const previousProvider = {
949 instanceId: ProviderInstanceId.make("opencode"),
950 driver: ProviderDriverKind.make("opencode"),
951 status: "ready",
952 enabled: true,
953 installed: true,
954 auth: { status: "authenticated" },
955 checkedAt: "2026-07-17T00:00:00.000Z",
956 version: "1.0.0",
957 models: [
958 {
959 slug: "github/gpt-5",
960 name: "GPT-5",
961 subProvider: "GitHub",
962 isCustom: false,
963 capabilities: null,
964 },
965 {
966 slug: "removed-plugin/model",
967 name: "Removed Plugin Model",
968 subProvider: "Removed Plugin",
969 isCustom: false,
970 capabilities: null,
971 },
972 ],
973 slashCommands: [{ name: "review", description: "Review changes" }],
974 skills: [
975 {
976 name: "typescript",
977 description: "TypeScript help",
978 path: "/skills/typescript/SKILL.md",
979 enabled: true,
980 },
981 ],
982 } as const satisfies ServerProvider;
983 const pendingProvider = {
984 ...previousProvider,
985 status: "warning",
986 installed: false,
987 auth: { status: "unknown" },
988 checkedAt: "2026-07-17T00:01:00.000Z",
989 version: null,
990 models: [],
991 message: "OpenCode provider status has not been checked in this session yet.",
992 } satisfies ServerProvider;
993 const loggedOutProvider = {
994 ...previousProvider,
995 status: "warning",
996 auth: { status: "unknown" },
997 checkedAt: "2026-07-17T00:02:00.000Z",
998 models: [],
999 slashCommands: [],
1000 skills: [],
1001 message: "OpenCode is available, but it did not report any connected upstream providers.",
1002 } satisfies ServerProvider;
1003 const missingProvider = {
1004 ...previousProvider,
1005 status: "error",
1006 installed: false,
1007 auth: { status: "unknown" },
1008 checkedAt: "2026-07-17T00:03:00.000Z",
1009 version: null,
1010 models: [],
1011 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
1012 } satisfies ServerProvider;
1013 const authoritativeProvider = {
1014 ...previousProvider,
1015 checkedAt: "2026-07-17T00:04:00.000Z",
1016 models: [previousProvider.models[0]!],
1017 } satisfies ServerProvider;
1018 const failedProvider = {
1019 ...authoritativeProvider,
1020 status: "error",
1021 auth: { status: "unknown" },
1022 checkedAt: "2026-07-17T00:05:00.000Z",
1023 models: [],
1024 message: "Failed to refresh OpenCode models.",
1025 } satisfies ServerProvider;
1026
1027 assert.deepStrictEqual(
1028 ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models,
1029 [...previousProvider.models],
1030 );
1031 assert.deepStrictEqual(
1032 ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).models,
1033 [],
1034 );
1035 assert.deepStrictEqual(
1036 ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands,
1037 [],
1038 );
1039 assert.deepStrictEqual(
1040 ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).skills,
1041 [],
1042 );
1043 assert.deepStrictEqual(
1044 ProviderRegistry.mergeProviderSnapshot(previousProvider, missingProvider).models,
1045 [],
1046 );
1047
1048 const afterRemoval = ProviderRegistry.mergeProviderSnapshot(
1049 previousProvider,
1050 authoritativeProvider,
1051 );
1052 const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, failedProvider);
1053
1054 assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]);
1055 });
1056
1057 describe("Codex model inventories", () => {
1058 const cachedProvider = {
1059 instanceId: ProviderInstanceId.make("codex-personal"),
1060 driver: ProviderDriverKind.make("codex"),
1061 status: "ready",
1062 enabled: true,
1063 installed: true,
1064 auth: { status: "authenticated" },
1065 checkedAt: "2026-09-04T19:00:00.000Z",
1066 version: "0.153.3",
1067 models: ["vega-alpha", "joule-alpha", "kindle-alpha", "ultima-alpha", "solstice-alpha"].map(
1068 (slug) => ({ slug, name: slug, isCustom: false, capabilities: null }),
1069 ),
1070 slashCommands: [],
1071 skills: [],
1072 } satisfies ServerProvider;
1073 const customModel = {
1074 slug: "custom-model",
1075 name: "Custom model",
1076 isCustom: true,
1077 capabilities: null,
1078 } as const;
1079 const refreshedProvider = {
1080 ...cachedProvider,
1081 checkedAt: "2026-09-04T19:01:00.000Z",
1082 models: [
1083 { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null },
1084 cachedProvider.models[0]!,
1085 customModel,
1086 ],
1087 } satisfies ServerProvider;
1088 const pendingProvider = {
1089 ...cachedProvider,
1090 status: "warning",
1091 installed: false,
1092 auth: { status: "unknown" },
1093 models: [customModel],
1094 } satisfies ServerProvider;
1095 const failedProvider = {
1096 ...pendingProvider,
1097 checkedAt: "2026-09-04T19:02:00.000Z",
1098 status: "error",
1099 installed: true,
1100 } satisfies ServerProvider;
1101
1102 it("drops retired alpha models after discovery, including without OpenAI authentication", () => {
1103 for (const authStatus of ["authenticated", "unknown"] as const) {
1104 assert.deepStrictEqual(
1105 ProviderRegistry.mergeProviderSnapshot(cachedProvider, {
1106 ...refreshedProvider,
1107 auth: { status: authStatus },
1108 }).models,
1109 refreshedProvider.models,
1110 );
1111 }
1112 });
1113
1114 it("keeps discovered models during startup and failed probes without restoring removed custom models", () => {
1115 for (const provider of [pendingProvider, failedProvider]) {
1116 assert.deepStrictEqual(
1117 ProviderRegistry.mergeProviderSnapshot(
1118 {
1119 ...cachedProvider,
1120 models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }],
1121 },
1122 provider,
1123 ).models,
1124 [customModel, ...cachedProvider.models],
1125 );
1126 }
1127 });
1128
1129 it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => {
1130 const emptyProvider = { ...refreshedProvider, models: [customModel] };
1131 const clearedProviders = [
1132 { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } },
1133 { ...emptyProvider, status: "disabled", enabled: false },
1134 { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } },
1135 emptyProvider,
1136 { ...emptyProvider, models: [] },
1137 ] satisfies ReadonlyArray<ServerProvider>;
1138
1139 for (const provider of clearedProviders) {
1140 assert.deepStrictEqual(
1141 ProviderRegistry.mergeProviderSnapshot(cachedProvider, provider).models,
1142 provider.models,
1143 );
1144 }
1145 });
1146
1147 it.effect("persists removals across failed refreshes and registry restarts", () =>
1148 Effect.gen(function* () {
1149 const config = yield* ServerConfig.ServerConfig;
1150 const filePath = yield* resolveProviderStatusCachePath({
1151 cacheDir: config.providerStatusCacheDir,
1152 instanceId: cachedProvider.instanceId,
1153 });
1154 yield* writeProviderStatusCache({ filePath, provider: cachedProvider });
1155 const nextProvider = yield* Ref.make<ServerProvider>(refreshedProvider);
1156 const instance = {
1157 instanceId: cachedProvider.instanceId,
1158 driverKind: cachedProvider.driver,
1159 continuationIdentity: {
1160 driverKind: cachedProvider.driver,
1161 continuationKey: "codex:instance:codex-personal",
1162 },
1163 displayName: undefined,
1164 enabled: true,
1165 snapshot: {
1166 resolveMaintenance: () =>
1167 Effect.succeed(
1168 makeManualOnlyProviderMaintenanceCapabilities({
1169 provider: cachedProvider.driver,
1170 packageName: null,
1171 }),
1172 ),
1173 getSnapshot: Effect.succeed(pendingProvider),
1174 refresh: Ref.get(nextProvider),
1175 streamChanges: Stream.empty,
1176 applyUsageLimits: () => Effect.void,
1177 },
1178 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1179 textGeneration: {} as ProviderInstance["textGeneration"],
1180 } satisfies ProviderInstance;
1181 const layerInstanceRegistry = Layer.succeed(
1182 ProviderInstanceRegistry.ProviderInstanceRegistry,
1183 {
1184 getInstance: (id) =>
1185 Effect.succeed(id === instance.instanceId ? instance : undefined),
1186 listInstances: Effect.succeed([instance]),
1187 listUnavailable: Effect.succeed([]),
1188 streamChanges: Stream.empty,
1189 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1190 },
1191 );
1192 const retainedModels = [
1193 customModel,
1194 ...refreshedProvider.models.filter((model) => !model.isCustom),
1195 ];
1196
1197 for (const restarted of [false, true]) {
1198 yield* Effect.gen(function* () {
1199 const registry = yield* ProviderRegistry.ProviderRegistry;
1200 const expectedModels = restarted
1201 ? retainedModels
1202 : [customModel, ...cachedProvider.models];
1203 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels);
1204
1205 yield* registry.refreshInstance(instance.instanceId);
1206 assert.deepStrictEqual(
1207 (yield* readProviderStatusCache(filePath))?.models,
1208 restarted ? retainedModels : refreshedProvider.models,
1209 );
1210
1211 yield* Ref.set(nextProvider, failedProvider);
1212 const afterFailure = yield* registry.refreshInstance(instance.instanceId);
1213 assert.deepStrictEqual(afterFailure[0]?.models, retainedModels);
1214 assert.deepStrictEqual(
1215 (yield* readProviderStatusCache(filePath))?.models,
1216 retainedModels,
1217 );
1218 }).pipe(
1219 Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))),
1220 Effect.scoped,
1221 );
1222 }
1223 }).pipe(
1224 Effect.provide(
1225 ServerConfig.layerTest(process.cwd(), {
1226 prefix: "t3-codex-retired-model-cache-",
1227 }).pipe(Layer.provideMerge(NodeServices.layer)),
1228 ),
1229 ),
1230 );
1231 });
1232
1233 describe("Antigravity model inventories", () => {
1234 const previousProvider = {
1235 instanceId: ProviderInstanceId.make("antigravity-personal"),
1236 driver: ProviderDriverKind.make("antigravity"),
1237 status: "ready",
1238 enabled: true,
1239 installed: true,
1240 auth: { status: "authenticated" },
1241 checkedAt: "2026-09-02T00:00:00.000Z",
1242 version: "0.1.3",
1243 models: [
1244 {
1245 slug: "gemini-3.1-pro-high",
1246 name: "Gemini 3.1 Pro High",
1247 isCustom: false,
1248 capabilities: null,
1249 },
1250 {
1251 slug: "gemini-3-flash",
1252 name: "Gemini 3 Flash",
1253 isCustom: false,
1254 capabilities: null,
1255 },
1256 ],
1257 slashCommands: [],
1258 skills: [],
1259 } as const satisfies ServerProvider;
1260
1261 it("removes unavailable models after a successful refresh", () => {
1262 for (const status of ["ready", "warning"] as const) {
1263 const refreshedProvider = {
1264 ...previousProvider,
1265 status,
1266 checkedAt: "2026-09-02T00:01:00.000Z",
1267 models: [previousProvider.models[1]],
1268 } satisfies ServerProvider;
1269 const afterRefresh = ProviderRegistry.mergeProviderSnapshot(
1270 previousProvider,
1271 refreshedProvider,
1272 );
1273
1274 assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models);
1275
1276 const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRefresh, {
1277 ...refreshedProvider,
1278 status: "error",
1279 auth: { status: "unknown" },
1280 models: [],
1281 });
1282 assert.deepStrictEqual(afterFailure.models, refreshedProvider.models);
1283 }
1284 });
1285
1286 it("keeps cached models during health checks and temporary failures", () => {
1287 for (const installed of [false, true]) {
1288 const pendingProvider = {
1289 ...previousProvider,
1290 status: "warning",
1291 installed,
1292 auth: { status: "unknown" },
1293 checkedAt: "2026-09-02T00:01:00.000Z",
1294 version: installed ? previousProvider.version : null,
1295 models: [],
1296 } satisfies ServerProvider;
1297
1298 assert.deepStrictEqual(
1299 ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models,
1300 previousProvider.models,
1301 );
1302 }
1303
1304 for (const authStatus of ["unknown", "authenticated"] as const) {
1305 const failedProvider = {
1306 ...previousProvider,
1307 status: "error",
1308 auth: { status: authStatus },
1309 checkedAt: "2026-09-02T00:02:00.000Z",
1310 models: [],
1311 } satisfies ServerProvider;
1312
1313 assert.deepStrictEqual(
1314 ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models,
1315 previousProvider.models,
1316 );
1317 }
1318 });
1319
1320 it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => {
1321 const emptyProvider = {
1322 ...previousProvider,
1323 checkedAt: "2026-09-02T00:01:00.000Z",
1324 models: [],
1325 } satisfies ServerProvider;
1326 const clearedProviders = [
1327 { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } },
1328 { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } },
1329 { ...emptyProvider, status: "disabled", enabled: false },
1330 { ...emptyProvider, status: "error", enabled: false },
1331 { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } },
1332 emptyProvider,
1333 ] satisfies ReadonlyArray<ServerProvider>;
1334
1335 for (const provider of clearedProviders) {
1336 const afterRemoval = ProviderRegistry.mergeProviderSnapshot(previousProvider, provider);
1337 assert.deepStrictEqual(afterRemoval.models, []);
1338
1339 const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, {
1340 ...emptyProvider,
1341 status: "error",
1342 auth: { status: "unknown" },
1343 });
1344 assert.deepStrictEqual(afterFailure.models, []);
1345 }
1346 });
1347 });
1348
1349 describe("Antigravity saved account", () => {
1350 const signedIn = {
1351 instanceId: ProviderInstanceId.make("antigravity-personal"),
1352 driver: ProviderDriverKind.make("antigravity"),
1353 status: "ready",
1354 enabled: true,
1355 installed: true,
1356 auth: { status: "authenticated", type: "oauth-personal", label: "Google account" },
1357 checkedAt: "2026-09-05T00:00:00.000Z",
1358 version: "agy_acp_server_1.1.1",
1359 models: [
1360 {
1361 slug: "gemini-3.7-flash-high",
1362 name: "Gemini 3.7 Flash",
1363 isCustom: false,
1364 capabilities: null,
1365 },
1366 ],
1367 slashCommands: [{ name: "plan" }],
1368 skills: [],
1369 } as const satisfies ServerProvider;
1370 const uncheckedMessage =
1371 "Antigravity is installed. Google account access is not checked yet.";
1372 const restartProbe = {
1373 ...signedIn,
1374 status: "warning",
1375 auth: { status: "unknown" },
1376 checkedAt: "2026-09-05T00:01:00.000Z",
1377 message: uncheckedMessage,
1378 models: [],
1379 } as const satisfies ServerProvider;
1380
1381 it("keeps the saved Google account through restart health checks", () => {
1382 const merged = ProviderRegistry.mergeProviderSnapshot(signedIn, restartProbe);
1383 const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe;
1384 assert.deepStrictEqual(merged, {
1385 ...probeWithoutMessage,
1386 status: "ready",
1387 auth: signedIn.auth,
1388 models: signedIn.models,
1389 });
1390 assert.equal("message" in merged, false);
1391 // The next periodic probe reads the merged snapshot as its previous state.
1392 assert.deepStrictEqual(
1393 ProviderRegistry.mergeProviderSnapshot(merged, restartProbe),
1394 merged,
1395 );
1396 });
1397
1398 it("carries the account through the boot probe and a failed probe without hiding them", () => {
1399 const booting = {
1400 ...restartProbe,
1401 installed: false,
1402 version: null,
1403 message: "Checking Antigravity availability.",
1404 } satisfies ServerProvider;
1405 assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, booting), {
1406 ...booting,
1407 auth: signedIn.auth,
1408 models: signedIn.models,
1409 });
1410
1411 const failed = {
1412 ...restartProbe,
1413 status: "error",
1414 message: "Antigravity did not respond to its local health check within 90 seconds.",
1415 } satisfies ServerProvider;
1416 assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, failed), {
1417 ...failed,
1418 auth: signedIn.auth,
1419 models: signedIn.models,
1420 });
1421 });
1422
1423 it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => {
1424 const untouched = [
1425 { ...restartProbe, auth: { status: "unauthenticated" } },
1426 { ...restartProbe, status: "disabled", enabled: false },
1427 { ...restartProbe, status: "error", installed: false },
1428 { ...restartProbe, driver: ProviderDriverKind.make("codex") },
1429 // The instance was rebuilt with another sign-in method.
1430 { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } },
1431 ] satisfies ReadonlyArray<ServerProvider>;
1432 for (const next of untouched) {
1433 const merged = ProviderRegistry.mergeProviderSnapshot(signedIn, next);
1434 assert.deepStrictEqual(merged.auth, next.auth);
1435 assert.equal(merged.status, next.status);
1436 assert.equal(merged.message, next.message);
1437 }
1438 assert.deepStrictEqual(
1439 ProviderRegistry.mergeProviderSnapshot(
1440 { ...signedIn, auth: { status: "unknown" } },
1441 restartProbe,
1442 ).auth,
1443 { status: "unknown" },
1444 );
1445 assert.equal(
1446 ProviderRegistry.mergeProviderSnapshot(
1447 { ...signedIn, driver: ProviderDriverKind.make("codex") },
1448 restartProbe,
1449 ).auth.status,
1450 "unknown",
1451 );
1452 assert.deepStrictEqual(
1453 ProviderRegistry.mergeProviderSnapshot(signedIn, {
1454 ...restartProbe,
1455 auth: { status: "unknown", type: "oauth-personal" },
1456 }).auth,
1457 signedIn.auth,
1458 );
1459 });
1460 });
1461
1462 it("fills missing capabilities from the previous provider snapshot", () => {
1463 const previousProvider = {
1464 instanceId: ProviderInstanceId.make("cursor"),
1465 driver: ProviderDriverKind.make("cursor"),
1466 status: "ready",
1467 enabled: true,
1468 installed: true,
1469 auth: { status: "authenticated" },
1470 checkedAt: "2026-04-14T00:00:00.000Z",
1471 version: "2026.04.09-f2b0fcd",
1472 models: [
1473 {
1474 slug: "claude-opus-4-6",
1475 name: "Opus 4.6",
1476 isCustom: false,
1477 capabilities: createModelCapabilities({
1478 optionDescriptors: [
1479 selectDescriptor("reasoning", "Reasoning", [
1480 { id: "high", label: "High", isDefault: true },
1481 ]),
1482 booleanDescriptor("fastMode", "Fast Mode"),
1483 booleanDescriptor("thinking", "Thinking"),
1484 ],
1485 }),
1486 },
1487 ],
1488 slashCommands: [],
1489 skills: [],
1490 } as const satisfies ServerProvider;
1491 const refreshedProvider = {
1492 ...previousProvider,
1493 checkedAt: "2026-04-14T00:01:00.000Z",
1494 models: [
1495 {
1496 slug: "claude-opus-4-6",
1497 name: "Opus 4.6",
1498 isCustom: false,
1499 capabilities: createModelCapabilities({
1500 optionDescriptors: [],
1501 }),
1502 },
1503 ],
1504 } satisfies ServerProvider;
1505
1506 assert.deepStrictEqual(
1507 ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
1508 [...previousProvider.models],
1509 );
1510 });
1511
1512 it.effect("does not run provider probes during layer construction", () =>
1513 Effect.gen(function* () {
1514 const codexDriver = ProviderDriverKind.make("codex");
1515 const codexInstanceId = ProviderInstanceId.make("codex");
1516 const initialProvider = {
1517 instanceId: codexInstanceId,
1518 driver: codexDriver,
1519 status: "warning",
1520 enabled: true,
1521 installed: false,
1522 auth: { status: "unknown" },
1523 checkedAt: "2026-06-10T00:00:00.000Z",
1524 version: null,
1525 message: "Checking Codex provider status.",
1526 models: [],
1527 slashCommands: [],
1528 skills: [],
1529 } as const satisfies ServerProvider;
1530 const refreshCalls = yield* Ref.make(0);
1531 const instance = {
1532 instanceId: codexInstanceId,
1533 driverKind: codexDriver,
1534 continuationIdentity: {
1535 driverKind: codexDriver,
1536 continuationKey: "codex:instance:codex",
1537 },
1538 displayName: undefined,
1539 enabled: true,
1540 snapshot: {
1541 resolveMaintenance: () =>
1542 Effect.succeed(
1543 makeManualOnlyProviderMaintenanceCapabilities({
1544 provider: codexDriver,
1545 packageName: null,
1546 }),
1547 ),
1548 getSnapshot: Effect.succeed(initialProvider),
1549 refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(
1550 Effect.andThen(Effect.never),
1551 ),
1552 streamChanges: Stream.empty,
1553 applyUsageLimits: () => Effect.void,
1554 },
1555 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1556 textGeneration: {} as ProviderInstance["textGeneration"],
1557 } satisfies ProviderInstance;
1558 const layerInstanceRegistry = Layer.succeed(
1559 ProviderInstanceRegistry.ProviderInstanceRegistry,
1560 {
1561 getInstance: (instanceId) =>
1562 Effect.succeed(instanceId === codexInstanceId ? instance : undefined),
1563 listInstances: Effect.succeed([instance]),
1564 listUnavailable: Effect.succeed([]),
1565 streamChanges: Stream.empty,
1566 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1567 },
1568 );
1569 const scope = yield* Scope.make();
1570 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1571 const runtimeServices = yield* Layer.build(
1572 ProviderRegistry.layer.pipe(
1573 Layer.provideMerge(layerInstanceRegistry),
1574 Layer.provideMerge(
1575 ServerConfig.layerTest(process.cwd(), {
1576 prefix: "t3-provider-registry-background-refresh-",
1577 }),
1578 ),
1579 Layer.provideMerge(NodeServices.layer),
1580 ),
1581 ).pipe(Scope.provide(scope));
1582 yield* Effect.gen(function* () {
1583 const registry = yield* ProviderRegistry.ProviderRegistry;
1584 assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]);
1585 assert.strictEqual(yield* Ref.get(refreshCalls), 0);
1586 }).pipe(Effect.provide(runtimeServices));
1587 }),
1588 );
1589
1590 it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () =>
1591 Effect.gen(function* () {
1592 const driver = ProviderDriverKind.make("codex");
1593 const instanceId = ProviderInstanceId.make("codex");
1594 const machineProvider = {
1595 instanceId,
1596 driver,
1597 status: "ready",
1598 enabled: true,
1599 installed: true,
1600 auth: { status: "authenticated" },
1601 checkedAt: "2026-06-10T00:00:00.000Z",
1602 version: "1.0.0",
1603 models: [],
1604 slashCommands: [{ name: "global" }],
1605 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
1606 } as const satisfies ServerProvider;
1607 const scopedProvider = {
1608 ...machineProvider,
1609 checkedAt: "2026-06-10T00:01:00.000Z",
1610 slashCommands: [{ name: "project" }],
1611 skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }],
1612 } as const satisfies ServerProvider;
1613 const pendingScopedProvider = {
1614 ...scopedProvider,
1615 status: "error",
1616 installed: false,
1617 slashCommands: [],
1618 } as const satisfies ServerProvider;
1619 const snapshotCalls = yield* Ref.make(0);
1620 const scopedResult = yield* Ref.make<ProviderWorkspaceSnapshot>({
1621 ...scopedProvider,
1622 status: "error",
1623 slashCommands: [COMPACT_SLASH_COMMAND],
1624 slashCommandsPending: true,
1625 });
1626 const cacheInvalidations = yield* Ref.make(0);
1627 const scanGate = yield* Ref.make<{
1628 readonly started: Deferred.Deferred<void>;
1629 readonly release: Deferred.Deferred<void>;
1630 } | null>(null);
1631 const returnPendingSnapshot = yield* Ref.make(true);
1632 const probeStarted = yield* Deferred.make<void>();
1633 const releaseProbe = yield* Deferred.make<void>();
1634 const makeInstance = (
1635 provider: ServerProvider,
1636 snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>,
1637 ): ProviderInstance => ({
1638 instanceId,
1639 driverKind: driver,
1640 continuationIdentity: {
1641 driverKind: driver,
1642 continuationKey: "codex:instance:codex",
1643 },
1644 displayName: undefined,
1645 enabled: true,
1646 snapshot: {
1647 resolveMaintenance: () =>
1648 Effect.succeed(
1649 makeManualOnlyProviderMaintenanceCapabilities({
1650 provider: driver,
1651 packageName: null,
1652 }),
1653 ),
1654 getSnapshot: Effect.succeed(provider),
1655 refresh: Effect.succeed(provider),
1656 streamChanges: Stream.empty,
1657 applyUsageLimits: () => Effect.void,
1658 },
1659 snapshotForCwd,
1660 invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1),
1661 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1662 textGeneration: {} as ProviderInstance["textGeneration"],
1663 });
1664 const firstInstance = makeInstance(machineProvider, () =>
1665 Effect.gen(function* () {
1666 yield* Ref.update(snapshotCalls, (count) => count + 1);
1667 if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider;
1668 yield* Deferred.succeed(probeStarted, undefined);
1669 yield* Deferred.await(releaseProbe);
1670 const result = yield* Ref.get(scopedResult);
1671 const gate = yield* Ref.getAndSet(scanGate, null);
1672 if (gate) {
1673 yield* Deferred.succeed(gate.started, undefined);
1674 yield* Deferred.await(gate.release);
1675 }
1676 return result;
1677 }),
1678 );
1679 const rebuiltProvider = {
1680 ...machineProvider,
1681 checkedAt: "2026-06-10T00:02:00.000Z",
1682 status: "warning",
1683 installed: false,
1684 auth: { status: "unknown" },
1685 } satisfies ServerProvider;
1686 const rebuiltInstance = makeInstance(rebuiltProvider, () =>
1687 Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)),
1688 );
1689 const registryChanges = yield* PubSub.unbounded<void>();
1690 const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]);
1691 const layerInstanceRegistry = Layer.succeed(
1692 ProviderInstanceRegistry.ProviderInstanceRegistry,
1693 {
1694 getInstance: (requestedId) =>
1695 Ref.get(instancesRef).pipe(
1696 Effect.map((instances) =>
1697 instances.find((instance) => instance.instanceId === requestedId),
1698 ),
1699 ),
1700 listInstances: Ref.get(instancesRef),
1701 listUnavailable: Effect.succeed([]),
1702 streamChanges: Stream.fromPubSub(registryChanges),
1703 subscribeChanges: PubSub.subscribe(registryChanges),
1704 },
1705 );
1706 const scope = yield* Scope.make();
1707 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1708 const runtimeServices = yield* Layer.build(
1709 ProviderRegistry.layer.pipe(
1710 Layer.provideMerge(layerInstanceRegistry),
1711 Layer.provideMerge(
1712 ServerConfig.layerTest(process.cwd(), {
1713 prefix: "t3-provider-registry-workspace-snapshot-",
1714 }),
1715 ),
1716 Layer.provideMerge(NodeServices.layer),
1717 ),
1718 ).pipe(Scope.provide(scope));
1719
1720 yield* Effect.gen(function* () {
1721 const registry = yield* ProviderRegistry.ProviderRegistry;
1722 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1723 assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined);
1724 yield* Ref.set(returnPendingSnapshot, false);
1725 const workspaceUpdate = yield* registry.streamChanges.pipe(
1726 Stream.runHead,
1727 Effect.forkChild,
1728 );
1729 yield* Effect.yieldNow;
1730 const firstRefresh = yield* registry
1731 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1732 .pipe(Effect.forkChild);
1733 yield* Deferred.await(probeStarted);
1734 const duplicateRefresh = yield* registry
1735 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1736 .pipe(Effect.forkChild);
1737 yield* Effect.yieldNow;
1738 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1739 yield* Deferred.succeed(releaseProbe, undefined);
1740 yield* Fiber.join(firstRefresh);
1741 yield* Fiber.join(duplicateRefresh);
1742 const published = yield* Fiber.join(workspaceUpdate);
1743 assert.strictEqual(published._tag, "Some");
1744 const providers = yield* registry.getProviders;
1745 assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills);
1746 assert.deepStrictEqual(
1747 providers[0]?.workspaceSnapshots?.[0]?.skills,
1748 scopedProvider.skills,
1749 );
1750 assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [
1751 COMPACT_SLASH_COMMAND,
1752 ]);
1753 assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true);
1754 yield* Ref.set(scopedResult, {
1755 ...scopedProvider,
1756 status: "error",
1757 slashCommandsPending: false,
1758 });
1759 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1760 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1761 assert.deepStrictEqual(
1762 (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands,
1763 scopedProvider.slashCommands,
1764 );
1765 assert.strictEqual(
1766 (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending,
1767 undefined,
1768 );
1769 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1770 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1771 const newSkills = [
1772 ...scopedProvider.skills,
1773 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
1774 ];
1775 yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills });
1776 yield* registry.refreshWorkspaceSnapshot({
1777 instanceId,
1778 cwd: "/workspace",
1779 fresh: true,
1780 });
1781 assert.strictEqual(yield* Ref.get(snapshotCalls), 4);
1782 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
1783 assert.deepStrictEqual(
1784 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
1785 [newSkills],
1786 );
1787
1788 // A slow fresh scan that read older files must not overwrite a
1789 // newer scan that finished first.
1790 const slowStarted = yield* Deferred.make<void>();
1791 const releaseSlow = yield* Deferred.make<void>();
1792 yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow });
1793 yield* Ref.set(scopedResult, scopedProvider);
1794 const slowScan = yield* registry
1795 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true })
1796 .pipe(Effect.forkChild);
1797 yield* Deferred.await(slowStarted);
1798 const latestSkills = [
1799 ...newSkills,
1800 { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true },
1801 ];
1802 yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills });
1803 yield* registry.refreshWorkspaceSnapshot({
1804 instanceId,
1805 cwd: "/workspace",
1806 fresh: true,
1807 });
1808 yield* Deferred.succeed(releaseSlow, undefined);
1809 yield* Fiber.join(slowScan);
1810 assert.deepStrictEqual(
1811 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
1812 [latestSkills],
1813 );
1814
1815 yield* Ref.set(instancesRef, [rebuiltInstance]);
1816 yield* PubSub.publish(registryChanges, undefined);
1817 let rebuilt = yield* registry.getProviders;
1818 for (
1819 let attempt = 0;
1820 attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt;
1821 attempt += 1
1822 ) {
1823 yield* Effect.yieldNow;
1824 rebuilt = yield* registry.getProviders;
1825 }
1826 assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt);
1827 assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined);
1828 }).pipe(Effect.provide(runtimeServices));
1829 }),
1830 );
1831
1832 it.effect("refreshes OpenCode catalogs and preserves other providers", () =>
1833 Effect.gen(function* () {
1834 const codexDriver = ProviderDriverKind.make("codex");
1835 const openCodeDriver = ProviderDriverKind.make("opencode");
1836 const codexInstanceId = ProviderInstanceId.make("codex");
1837 const openCodeInstanceId = ProviderInstanceId.make("opencode");
1838 const codexRefreshCalls = yield* Ref.make(0);
1839 const openCodeRefreshCalls = yield* Ref.make(0);
1840 const codexProvider = {
1841 instanceId: codexInstanceId,
1842 driver: codexDriver,
1843 status: "ready",
1844 enabled: true,
1845 installed: true,
1846 auth: { status: "authenticated" },
1847 checkedAt: "2026-06-10T00:00:00.000Z",
1848 version: "1.0.0",
1849 models: [],
1850 slashCommands: [],
1851 skills: [],
1852 } as const satisfies ServerProvider;
1853 const failedOpenCodeProvider = {
1854 instanceId: openCodeInstanceId,
1855 driver: openCodeDriver,
1856 status: "error",
1857 enabled: true,
1858 installed: true,
1859 auth: { status: "unknown" },
1860 checkedAt: "2026-06-10T00:00:00.000Z",
1861 version: "1.0.0",
1862 message: "Failed to refresh OpenCode models.",
1863 models: [],
1864 slashCommands: [],
1865 skills: [],
1866 } as const satisfies ServerProvider;
1867 const recoveredOpenCodeProvider = {
1868 ...failedOpenCodeProvider,
1869 status: "ready",
1870 auth: { status: "authenticated" },
1871 checkedAt: "2026-06-10T00:01:00.000Z",
1872 message: "One upstream provider connected through OpenCode.",
1873 models: [
1874 {
1875 slug: "github/gpt-5",
1876 name: "GPT-5",
1877 subProvider: "GitHub",
1878 isCustom: false,
1879 capabilities: null,
1880 },
1881 ],
1882 } as const satisfies ServerProvider;
1883 const changedCatalogProvider = {
1884 ...recoveredOpenCodeProvider,
1885 checkedAt: "2026-06-10T00:02:00.000Z",
1886 models: [
1887 {
1888 slug: "anthropic/claude-sonnet-4",
1889 name: "Claude Sonnet 4",
1890 subProvider: "Anthropic",
1891 isCustom: false,
1892 capabilities: null,
1893 },
1894 ],
1895 } as const satisfies ServerProvider;
1896 const catalogSnapshot = yield* Ref.make<ServerProvider>(recoveredOpenCodeProvider);
1897 const instances = [
1898 {
1899 instanceId: codexInstanceId,
1900 driverKind: codexDriver,
1901 continuationIdentity: {
1902 driverKind: codexDriver,
1903 continuationKey: "codex:instance:codex",
1904 },
1905 displayName: undefined,
1906 enabled: true,
1907 snapshot: {
1908 resolveMaintenance: () =>
1909 Effect.succeed(
1910 makeManualOnlyProviderMaintenanceCapabilities({
1911 provider: codexDriver,
1912 packageName: null,
1913 }),
1914 ),
1915 getSnapshot: Effect.succeed(codexProvider),
1916 refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe(
1917 Effect.as(codexProvider),
1918 ),
1919 streamChanges: Stream.empty,
1920 applyUsageLimits: () => Effect.void,
1921 },
1922 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1923 textGeneration: {} as ProviderInstance["textGeneration"],
1924 },
1925 {
1926 instanceId: openCodeInstanceId,
1927 driverKind: openCodeDriver,
1928 continuationIdentity: {
1929 driverKind: openCodeDriver,
1930 continuationKey: "opencode:instance:opencode",
1931 },
1932 displayName: undefined,
1933 enabled: true,
1934 snapshot: {
1935 resolveMaintenance: () =>
1936 Effect.succeed(
1937 makeManualOnlyProviderMaintenanceCapabilities({
1938 provider: openCodeDriver,
1939 packageName: null,
1940 }),
1941 ),
1942 getSnapshot: Effect.succeed(failedOpenCodeProvider),
1943 refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe(
1944 Effect.andThen(Ref.get(catalogSnapshot)),
1945 ),
1946 streamChanges: Stream.empty,
1947 applyUsageLimits: () => Effect.void,
1948 },
1949 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1950 textGeneration: {} as ProviderInstance["textGeneration"],
1951 },
1952 ] satisfies ReadonlyArray<ProviderInstance>;
1953 const layerInstanceRegistry = Layer.succeed(
1954 ProviderInstanceRegistry.ProviderInstanceRegistry,
1955 {
1956 getInstance: (instanceId) =>
1957 Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)),
1958 listInstances: Effect.succeed(instances),
1959 listUnavailable: Effect.succeed([]),
1960 streamChanges: Stream.empty,
1961 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
1962 },
1963 );
1964 const scope = yield* Scope.make();
1965 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1966 const runtimeServices = yield* Layer.build(
1967 ProviderRegistry.layer.pipe(
1968 Layer.provideMerge(layerInstanceRegistry),
1969 Layer.provideMerge(
1970 ServerConfig.layerTest(process.cwd(), {
1971 prefix: "t3-provider-registry-reconnect-refresh-",
1972 }),
1973 ),
1974 Layer.provideMerge(NodeServices.layer),
1975 ),
1976 ).pipe(Scope.provide(scope));
1977
1978 yield* Effect.gen(function* () {
1979 const registry = yield* ProviderRegistry.ProviderRegistry;
1980 const initialProviders = yield* registry.getProviders;
1981 assert.strictEqual(
1982 initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status,
1983 "error",
1984 );
1985
1986 const recoveredProviders = yield* registry.refresh();
1987 assert.deepStrictEqual(
1988 recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId)
1989 ?.models,
1990 recoveredOpenCodeProvider.models,
1991 );
1992 assert.deepStrictEqual(
1993 recoveredProviders.find((provider) => provider.instanceId === codexInstanceId),
1994 withBundledCompatibility(codexProvider),
1995 );
1996
1997 yield* Ref.set(catalogSnapshot, changedCatalogProvider);
1998 const changedProviders = yield* registry.refresh();
1999 assert.deepStrictEqual(
2000 changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models,
2001 changedCatalogProvider.models,
2002 );
2003 assert.deepStrictEqual(
2004 changedProviders.find((provider) => provider.instanceId === codexInstanceId),
2005 withBundledCompatibility(codexProvider),
2006 );
2007 }).pipe(Effect.provide(runtimeServices));
2008
2009 assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2);
2010 assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2);
2011 }),
2012 );
2013
2014 it("persists merged provider snapshots for the providers that were refreshed", () => {
2015 const previousProviders = [
2016 {
2017 instanceId: ProviderInstanceId.make("cursor"),
2018 driver: ProviderDriverKind.make("cursor"),
2019 status: "ready",
2020 enabled: true,
2021 installed: true,
2022 auth: { status: "authenticated" },
2023 checkedAt: "2026-04-14T00:00:00.000Z",
2024 version: "2026.04.09-f2b0fcd",
2025 models: [
2026 {
2027 slug: "claude-opus-4-6",
2028 name: "Opus 4.6",
2029 isCustom: false,
2030 capabilities: createModelCapabilities({
2031 optionDescriptors: [
2032 selectDescriptor("reasoning", "Reasoning", [
2033 { id: "high", label: "High", isDefault: true },
2034 ]),
2035 booleanDescriptor("fastMode", "Fast Mode"),
2036 booleanDescriptor("thinking", "Thinking"),
2037 ],
2038 }),
2039 },
2040 ],
2041 slashCommands: [],
2042 skills: [],
2043 },
2044 {
2045 instanceId: ProviderInstanceId.make("codex"),
2046 driver: ProviderDriverKind.make("codex"),
2047 status: "ready",
2048 enabled: true,
2049 installed: true,
2050 auth: { status: "authenticated" },
2051 checkedAt: "2026-04-14T00:00:00.000Z",
2052 version: "1.0.0",
2053 models: [],
2054 slashCommands: [],
2055 skills: [],
2056 },
2057 ] as const satisfies ReadonlyArray<ServerProvider>;
2058 const refreshedCursor = {
2059 ...previousProviders[0],
2060 checkedAt: "2026-04-14T00:01:00.000Z",
2061 models: [],
2062 } satisfies ServerProvider;
2063
2064 const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [
2065 refreshedCursor,
2066 ]);
2067 const persistedProviders = ProviderRegistry.selectProvidersByKind(
2068 mergedProviders,
2069 new Set([ProviderDriverKind.make("cursor")]),
2070 );
2071
2072 assert.deepStrictEqual(persistedProviders, [
2073 {
2074 ...refreshedCursor,
2075 models: [...previousProviders[0].models],
2076 },
2077 ]);
2078 });
2079
2080 it.effect("persists the merged snapshot when a live update has empty models", () =>
2081 Effect.gen(function* () {
2082 const cursorDriver = ProviderDriverKind.make("cursor");
2083 const cursorInstanceId = ProviderInstanceId.make("cursor");
2084 const initialProvider = {
2085 instanceId: cursorInstanceId,
2086 driver: cursorDriver,
2087 status: "ready",
2088 enabled: true,
2089 installed: true,
2090 auth: { status: "authenticated" },
2091 checkedAt: "2026-04-14T00:00:00.000Z",
2092 version: "2026.04.09-f2b0fcd",
2093 models: [
2094 {
2095 slug: "claude-opus-4-6",
2096 name: "Opus 4.6",
2097 isCustom: false,
2098 capabilities: createModelCapabilities({
2099 optionDescriptors: [
2100 selectDescriptor("reasoning", "Reasoning", [
2101 { id: "high", label: "High", isDefault: true },
2102 ]),
2103 ],
2104 }),
2105 },
2106 ],
2107 slashCommands: [],
2108 skills: [],
2109 } as const satisfies ServerProvider;
2110 const refreshedProvider = {
2111 ...initialProvider,
2112 checkedAt: "2026-04-14T00:01:00.000Z",
2113 models: [],
2114 } satisfies ServerProvider;
2115 const changes = yield* PubSub.unbounded<ServerProvider>();
2116 const instance = {
2117 instanceId: cursorInstanceId,
2118 driverKind: cursorDriver,
2119 continuationIdentity: {
2120 driverKind: cursorDriver,
2121 continuationKey: "cursor:instance:cursor",
2122 },
2123 displayName: undefined,
2124 enabled: true,
2125 snapshot: {
2126 resolveMaintenance: () =>
2127 Effect.succeed(
2128 makeManualOnlyProviderMaintenanceCapabilities({
2129 provider: cursorDriver,
2130 packageName: null,
2131 }),
2132 ),
2133 getSnapshot: Effect.succeed(initialProvider),
2134 refresh: Effect.succeed(refreshedProvider),
2135 streamChanges: Stream.fromPubSub(changes),
2136 applyUsageLimits: () => Effect.void,
2137 },
2138 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2139 textGeneration: {} as ProviderInstance["textGeneration"],
2140 } satisfies ProviderInstance;
2141 const layerInstanceRegistry = Layer.succeed(
2142 ProviderInstanceRegistry.ProviderInstanceRegistry,
2143 {
2144 getInstance: (instanceId) =>
2145 Effect.succeed(instanceId === cursorInstanceId ? instance : undefined),
2146 listInstances: Effect.succeed([instance]),
2147 listUnavailable: Effect.succeed([]),
2148 streamChanges: Stream.empty,
2149 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
2150 PubSub.subscribe(pubsub),
2151 ),
2152 },
2153 );
2154 const scope = yield* Scope.make();
2155 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2156 const runtimeServices = yield* Layer.build(
2157 ProviderRegistry.layer.pipe(
2158 Layer.provideMerge(layerInstanceRegistry),
2159 Layer.provideMerge(
2160 ServerConfig.layerTest(process.cwd(), {
2161 prefix: "t3-provider-registry-merged-persist-",
2162 }),
2163 ),
2164 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2165 Layer.provideMerge(NodeServices.layer),
2166 ),
2167 ).pipe(Scope.provide(scope));
2168
2169 yield* Effect.gen(function* () {
2170 const registry = yield* ProviderRegistry.ProviderRegistry;
2171 const config = yield* ServerConfig.ServerConfig;
2172 const filePath = yield* resolveProviderStatusCachePath({
2173 cacheDir: config.providerStatusCacheDir,
2174 instanceId: cursorInstanceId,
2175 });
2176
2177 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
2178 ...initialProvider.models,
2179 ]);
2180 const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt);
2181 yield* PubSub.publish(changes, refreshedProvider);
2182 yield* Fiber.join(persisted);
2183 const cachedProvider = yield* readProviderStatusCache(filePath);
2184
2185 assert.deepStrictEqual(
2186 cachedProvider,
2187 withBundledCompatibility({
2188 ...refreshedProvider,
2189 models: [...initialProvider.models],
2190 }),
2191 );
2192 }).pipe(Effect.provide(runtimeServices));
2193 }),
2194 );
2195
2196 it.effect(
2197 "persists authoritative OpenCode removals without resurrecting them on a failed live refresh",
2198 () =>
2199 Effect.gen(function* () {
2200 const openCodeDriver = ProviderDriverKind.make("opencode");
2201 const openCodeInstanceId = ProviderInstanceId.make("opencode");
2202 const initialProvider = {
2203 instanceId: openCodeInstanceId,
2204 driver: openCodeDriver,
2205 status: "ready",
2206 enabled: true,
2207 installed: true,
2208 auth: { status: "authenticated" },
2209 checkedAt: "2026-07-17T00:00:00.000Z",
2210 version: "1.0.0",
2211 models: [
2212 {
2213 slug: "github/gpt-5",
2214 name: "GPT-5",
2215 subProvider: "GitHub",
2216 isCustom: false,
2217 capabilities: null,
2218 },
2219 {
2220 slug: "removed-plugin/model",
2221 name: "Removed Plugin Model",
2222 subProvider: "Removed Plugin",
2223 isCustom: false,
2224 capabilities: null,
2225 },
2226 ],
2227 slashCommands: [],
2228 skills: [],
2229 } as const satisfies ServerProvider;
2230 const authoritativeProvider = {
2231 ...initialProvider,
2232 checkedAt: "2026-07-17T00:01:00.000Z",
2233 models: [initialProvider.models[0]!],
2234 } satisfies ServerProvider;
2235 const failedProvider = {
2236 ...authoritativeProvider,
2237 status: "error",
2238 auth: { status: "unknown" },
2239 checkedAt: "2026-07-17T00:02:00.000Z",
2240 models: [],
2241 message: "Failed to refresh OpenCode models.",
2242 } satisfies ServerProvider;
2243 const changes = yield* PubSub.unbounded<ServerProvider>();
2244 const instance = {
2245 instanceId: openCodeInstanceId,
2246 driverKind: openCodeDriver,
2247 continuationIdentity: {
2248 driverKind: openCodeDriver,
2249 continuationKey: "opencode:instance:opencode",
2250 },
2251 displayName: undefined,
2252 enabled: true,
2253 snapshot: {
2254 resolveMaintenance: () =>
2255 Effect.succeed(
2256 makeManualOnlyProviderMaintenanceCapabilities({
2257 provider: openCodeDriver,
2258 packageName: null,
2259 }),
2260 ),
2261 getSnapshot: Effect.succeed(initialProvider),
2262 refresh: Effect.succeed(authoritativeProvider),
2263 streamChanges: Stream.fromPubSub(changes),
2264 applyUsageLimits: () => Effect.void,
2265 },
2266 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2267 textGeneration: {} as ProviderInstance["textGeneration"],
2268 } satisfies ProviderInstance;
2269 const layerInstanceRegistry = Layer.succeed(
2270 ProviderInstanceRegistry.ProviderInstanceRegistry,
2271 {
2272 getInstance: (instanceId) =>
2273 Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined),
2274 listInstances: Effect.succeed([instance]),
2275 listUnavailable: Effect.succeed([]),
2276 streamChanges: Stream.empty,
2277 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
2278 PubSub.subscribe(pubsub),
2279 ),
2280 },
2281 );
2282 const scope = yield* Scope.make();
2283 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2284 const runtimeServices = yield* Layer.build(
2285 ProviderRegistry.layer.pipe(
2286 Layer.provideMerge(layerInstanceRegistry),
2287 Layer.provideMerge(
2288 ServerConfig.layerTest(process.cwd(), {
2289 prefix: "t3-provider-registry-opencode-authoritative-persist-",
2290 }),
2291 ),
2292 Layer.provideMerge(NodeServices.layer),
2293 ),
2294 ).pipe(Scope.provide(scope));
2295
2296 yield* Effect.gen(function* () {
2297 const registry = yield* ProviderRegistry.ProviderRegistry;
2298 const config = yield* ServerConfig.ServerConfig;
2299 const filePath = yield* resolveProviderStatusCachePath({
2300 cacheDir: config.providerStatusCacheDir,
2301 instanceId: openCodeInstanceId,
2302 });
2303
2304 const authoritativePersisted = yield* awaitPersistedProvider(
2305 registry,
2306 authoritativeProvider.checkedAt,
2307 );
2308 yield* PubSub.publish(changes, authoritativeProvider);
2309 yield* Fiber.join(authoritativePersisted);
2310 let cachedProvider = yield* readProviderStatusCache(filePath);
2311
2312 assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]);
2313
2314 const failedPersisted = yield* awaitPersistedProvider(
2315 registry,
2316 failedProvider.checkedAt,
2317 );
2318 yield* PubSub.publish(changes, failedProvider);
2319 yield* Fiber.join(failedPersisted);
2320 cachedProvider = yield* readProviderStatusCache(filePath);
2321
2322 assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]);
2323 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
2324 authoritativeProvider.models[0]!,
2325 ]);
2326 }).pipe(Effect.provide(runtimeServices));
2327 }),
2328 );
2329
2330 it.effect("returns the cached provider list when a manual refresh fails", () =>
2331 Effect.gen(function* () {
2332 const codexDriver = ProviderDriverKind.make("codex");
2333 const codexInstanceId = ProviderInstanceId.make("codex");
2334 const cachedProvider = {
2335 instanceId: codexInstanceId,
2336 driver: codexDriver,
2337 status: "ready",
2338 enabled: true,
2339 installed: true,
2340 auth: { status: "authenticated" },
2341 checkedAt: "2026-04-29T10:00:00.000Z",
2342 version: "1.0.0",
2343 models: [],
2344 slashCommands: [],
2345 skills: [],
2346 } as const satisfies ServerProvider;
2347 const instance = {
2348 instanceId: codexInstanceId,
2349 driverKind: codexDriver,
2350 continuationIdentity: {
2351 driverKind: codexDriver,
2352 continuationKey: "codex:instance:codex",
2353 },
2354 displayName: undefined,
2355 enabled: true,
2356 snapshot: {
2357 resolveMaintenance: () =>
2358 Effect.succeed(
2359 makeManualOnlyProviderMaintenanceCapabilities({
2360 provider: codexDriver,
2361 packageName: null,
2362 }),
2363 ),
2364 getSnapshot: Effect.succeed(cachedProvider),
2365 refresh: Effect.die(new Error("simulated refresh failure")),
2366 streamChanges: Stream.empty,
2367 applyUsageLimits: () => Effect.void,
2368 },
2369 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2370 textGeneration: {} as ProviderInstance["textGeneration"],
2371 } satisfies ProviderInstance;
2372 const layerInstanceRegistry = Layer.succeed(
2373 ProviderInstanceRegistry.ProviderInstanceRegistry,
2374 {
2375 getInstance: (instanceId) =>
2376 Effect.succeed(instanceId === codexInstanceId ? instance : undefined),
2377 listInstances: Effect.succeed([instance]),
2378 listUnavailable: Effect.succeed([]),
2379 streamChanges: Stream.empty,
2380 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
2381 PubSub.subscribe(pubsub),
2382 ),
2383 },
2384 );
2385 const scope = yield* Scope.make();
2386 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2387 const runtimeServices = yield* Layer.build(
2388 ProviderRegistry.layer.pipe(
2389 Layer.provideMerge(layerInstanceRegistry),
2390 Layer.provideMerge(
2391 ServerConfig.layerTest(process.cwd(), {
2392 prefix: "t3-provider-registry-refresh-failure-",
2393 }),
2394 ),
2395 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2396 Layer.provideMerge(NodeServices.layer),
2397 ),
2398 ).pipe(Scope.provide(scope));
2399
2400 yield* Effect.gen(function* () {
2401 const registry = yield* ProviderRegistry.ProviderRegistry;
2402
2403 assert.deepStrictEqual(yield* registry.getProviders, [
2404 withBundledCompatibility(cachedProvider),
2405 ]);
2406 assert.deepStrictEqual(yield* registry.refresh(codexDriver), [
2407 withBundledCompatibility(cachedProvider),
2408 ]);
2409 assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [
2410 withBundledCompatibility(cachedProvider),
2411 ]);
2412 }).pipe(Effect.provide(runtimeServices));
2413 }),
2414 );
2415
2416 it.effect("keeps consuming registry changes after one sync fails", () =>
2417 Effect.gen(function* () {
2418 const codexDriver = ProviderDriverKind.make("codex");
2419 const codexInstanceId = ProviderInstanceId.make("codex");
2420 const claudeDriver = ProviderDriverKind.make("claudeAgent");
2421 const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
2422 const codexProvider = {
2423 instanceId: codexInstanceId,
2424 driver: codexDriver,
2425 status: "ready",
2426 enabled: true,
2427 installed: true,
2428 auth: { status: "authenticated" },
2429 checkedAt: "2026-04-29T10:00:00.000Z",
2430 version: "1.0.0",
2431 models: [],
2432 slashCommands: [],
2433 skills: [],
2434 } as const satisfies ServerProvider;
2435 const claudeProvider = {
2436 instanceId: claudeInstanceId,
2437 driver: claudeDriver,
2438 status: "ready",
2439 enabled: true,
2440 installed: true,
2441 auth: { status: "authenticated" },
2442 checkedAt: "2026-04-29T10:01:00.000Z",
2443 version: "1.0.0",
2444 models: [],
2445 slashCommands: [],
2446 skills: [],
2447 } as const satisfies ServerProvider;
2448 const makeInstance = (provider: ServerProvider): ProviderInstance => ({
2449 instanceId: provider.instanceId,
2450 driverKind: provider.driver,
2451 continuationIdentity: {
2452 driverKind: provider.driver,
2453 continuationKey: `${provider.driver}:instance:${provider.instanceId}`,
2454 },
2455 displayName: undefined,
2456 enabled: true,
2457 snapshot: {
2458 resolveMaintenance: () =>
2459 Effect.succeed(
2460 makeManualOnlyProviderMaintenanceCapabilities({
2461 provider: provider.driver,
2462 packageName: null,
2463 }),
2464 ),
2465 getSnapshot: Effect.succeed(provider),
2466 refresh: Effect.succeed(provider),
2467 streamChanges: Stream.empty,
2468 applyUsageLimits: () => Effect.void,
2469 },
2470 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2471 textGeneration: {} as ProviderInstance["textGeneration"],
2472 });
2473 const codexInstance = makeInstance(codexProvider);
2474 const claudeInstance = makeInstance(claudeProvider);
2475 const changes = yield* PubSub.unbounded<void>();
2476 const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([codexInstance]);
2477 const failNextList = yield* Ref.make(false);
2478 const wait = () => Effect.yieldNow;
2479 const layerInstanceRegistry = Layer.succeed(
2480 ProviderInstanceRegistry.ProviderInstanceRegistry,
2481 {
2482 getInstance: (instanceId) =>
2483 Ref.get(instancesRef).pipe(
2484 Effect.map((instances) =>
2485 instances.find((instance) => instance.instanceId === instanceId),
2486 ),
2487 ),
2488 listInstances: Effect.gen(function* () {
2489 const shouldFail = yield* Ref.get(failNextList);
2490 if (shouldFail) {
2491 yield* Ref.set(failNextList, false);
2492 return yield* Effect.die(new Error("simulated registry list failure"));
2493 }
2494 return yield* Ref.get(instancesRef);
2495 }),
2496 listUnavailable: Effect.succeed([]),
2497 streamChanges: Stream.fromPubSub(changes),
2498 subscribeChanges: PubSub.subscribe(changes),
2499 },
2500 );
2501 const scope = yield* Scope.make();
2502 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2503 const runtimeServices = yield* Layer.build(
2504 ProviderRegistry.layer.pipe(
2505 Layer.provideMerge(layerInstanceRegistry),
2506 Layer.provideMerge(
2507 ServerConfig.layerTest(process.cwd(), {
2508 prefix: "t3-provider-registry-sync-failure-",
2509 }),
2510 ),
2511 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2512 Layer.provideMerge(NodeServices.layer),
2513 ),
2514 ).pipe(Scope.provide(scope));
2515
2516 yield* Effect.gen(function* () {
2517 const registry = yield* ProviderRegistry.ProviderRegistry;
2518 assert.deepStrictEqual(yield* registry.getProviders, [
2519 withBundledCompatibility(codexProvider),
2520 ]);
2521
2522 yield* Ref.set(failNextList, true);
2523 yield* PubSub.publish(changes, undefined);
2524
2525 yield* Ref.set(instancesRef, [codexInstance, claudeInstance]);
2526 yield* PubSub.publish(changes, undefined);
2527
2528 let providers = yield* registry.getProviders;
2529 for (
2530 let attempt = 0;
2531 attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId);
2532 attempt += 1
2533 ) {
2534 yield* wait();
2535 providers = yield* registry.getProviders;
2536 }
2537
2538 assert.deepStrictEqual(
2539 providers.map((provider) => provider.instanceId).toSorted(),
2540 [codexInstanceId, claudeInstanceId].toSorted(),
2541 );
2542 }).pipe(Effect.provide(runtimeServices));
2543 }),
2544 );
2545
2546 // This test intentionally avoids `mockCommandSpawnerLayer` so the real
2547 // `probeCodexAppServerProvider` path runs — including the full
2548 // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`.
2549 // We point `binaryPath` at a name that cannot exist on any machine so
2550 // the real `ChildProcessSpawner` deterministically returns ENOENT; the
2551 // probe wraps that as `CodexAppServerSpawnError` and
2552 // `checkCodexProviderStatus` turns it into the user-visible "not
2553 // installed" error snapshot. If the aggregator's `syncLiveSources`
2554 // breaks — the `codex_personal`-never-probes bug we are guarding
2555 // against — that snapshot never lands in `getProviders` and the
2556 // assertions below fail.
2557 it.effect("propagates real Codex probe failures to the aggregator at boot", () =>
2558 Effect.gen(function* () {
2559 const missingBinary = `t3code_codex_missing_`;
2560 const serverSettings = yield* makeMutableServerSettingsService(
2561 decodeServerSettings(
2562 deepMerge(encodedDefaultServerSettings, {
2563 providers: {
2564 // Disable every built-in probe that would otherwise spawn
2565 // on the CI host. `enabled: false` short-circuits each
2566 // driver's probe *before* it touches the spawner, so the
2567 // test environment stays isolated from the dev
2568 // machine's PATH.
2569 codex: { enabled: false },
2570 claudeAgent: { enabled: false },
2571 cursor: { enabled: false },
2572 grok: { enabled: false },
2573 opencode: { enabled: false },
2574 },
2575 // `providerInstances` keys are branded `ProviderInstanceId`;
2576 // the branded index signature rejects plain string literals
2577 // at the TS level even though the runtime schema happily
2578 // accepts + decodes them. Cast the patch to `unknown` so
2579 // the `Schema.decodeSync` below does the real validation.
2580 providerInstances: {
2581 // Matches the shape the user had in `.t3/dev/settings.json`
2582 // when the bug was reported: a custom enabled Codex instance
2583 // pointing at a binary the server has to actually spawn.
2584 codex_personal: {
2585 driver: "codex",
2586 displayName: "Codex Personal",
2587 enabled: true,
2588 config: {
2589 binaryPath: missingBinary,
2590 homePath: `/tmp/${missingBinary}_home`,
2591 },
2592 },
2593 } as unknown as ContractServerSettings["providerInstances"],
2594 }),
2595 ),
2596 );
2597 const scope = yield* Scope.make();
2598 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2599 const layerProviderRegistry = ProviderRegistry.layer.pipe(
2600 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2601 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2602 Layer.provideMerge(
2603 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2604 ),
2605 Layer.provideMerge(ServerSecretStore.layer),
2606 Layer.provideMerge(
2607 ServerConfig.layerTest(process.cwd(), {
2608 prefix: "t3-provider-registry-",
2609 }),
2610 ),
2611 Layer.provideMerge(layerTestHttpClient),
2612 Layer.provideMerge(
2613 Layer.succeed(
2614 ProviderEventLoggers.ProviderEventLoggers,
2615 ProviderEventLoggers.NoOpProviderEventLoggers,
2616 ),
2617 ),
2618 Layer.provideMerge(ModelManifest.layerTest),
2619 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2620 Layer.provideMerge(
2621 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2622 ),
2623 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2624 // NO spawner mock — `ChildProcessSpawner` is supplied by the
2625 // outer `NodeServices.layer` on `it.layer(...)` and will
2626 // genuinely spawn a subprocess. The missing-binary ENOENT is
2627 // what exercises the same failure mode as a misconfigured
2628 // production `binaryPath`.
2629 );
2630 const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe(
2631 Scope.provide(scope),
2632 );
2633
2634 yield* Effect.gen(function* () {
2635 const registry = yield* ProviderRegistry.ProviderRegistry;
2636 let providers = yield* registry.getProviders;
2637 for (
2638 let attempts = 0;
2639 attempts < 50 &&
2640 providers.find((provider) => provider.instanceId === "codex_personal")?.status !==
2641 "error";
2642 attempts += 1
2643 ) {
2644 yield* Effect.yieldNow;
2645 providers = yield* registry.getProviders;
2646 }
2647 const codexPersonal = providers.find(
2648 (provider) => provider.instanceId === "codex_personal",
2649 );
2650 assert.notStrictEqual(
2651 codexPersonal,
2652 undefined,
2653 `Expected the aggregator to know about codex_personal; instead saw: ${providers
2654 .map((provider) => provider.instanceId)
2655 .join(", ")}`,
2656 );
2657 assert.strictEqual(
2658 codexPersonal?.status,
2659 "error",
2660 "Real Codex probe against a missing binary should surface as 'error' in the aggregator",
2661 );
2662 assert.strictEqual(codexPersonal?.installed, false);
2663 assert.include(codexPersonal?.message, missingBinary);
2664 assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path");
2665 }).pipe(Effect.provide(runtimeServices));
2666 }),
2667 );
2668
2669 // A binary path change must rebuild Codex and publish its new probe result.
2670 it.effect("re-probes when settings change the codex binaryPath", () =>
2671 Effect.gen(function* () {
2672 const firstMissing = `t3code_codex_first_`;
2673 const secondMissing = `t3code_codex_second_`;
2674 const spawnedCommands: Array<string> = [];
2675 const secondProbeStarted = yield* Deferred.make<void>();
2676 const releaseSecondProbe = yield* Deferred.make<void>();
2677 const allowLazySettingsStream = yield* Deferred.make<void>();
2678 const mutableServerSettings = yield* makeMutableServerSettingsService(
2679 decodeServerSettings(
2680 deepMerge(encodedDefaultServerSettings, {
2681 providers: {
2682 codex: { enabled: true, binaryPath: firstMissing },
2683 claudeAgent: { enabled: false },
2684 cursor: { enabled: false },
2685 grok: { enabled: false },
2686 opencode: { enabled: false },
2687 },
2688 }),
2689 ),
2690 );
2691 const serverSettings = {
2692 ...mutableServerSettings,
2693 streamChanges: Stream.unwrap(
2694 Deferred.await(allowLazySettingsStream).pipe(
2695 Effect.as(mutableServerSettings.streamChanges),
2696 ),
2697 ),
2698 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
2699 const scope = yield* Scope.make();
2700 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2701 const layerProviderRegistry = ProviderRegistry.layer.pipe(
2702 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2703 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2704 Layer.provideMerge(
2705 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2706 ),
2707 Layer.provideMerge(ServerSecretStore.layer),
2708 Layer.provideMerge(
2709 ServerConfig.layerTest(process.cwd(), {
2710 prefix: "t3-provider-registry-",
2711 }),
2712 ),
2713 Layer.provideMerge(layerTestHttpClient),
2714 Layer.provideMerge(
2715 Layer.succeed(
2716 ProviderEventLoggers.ProviderEventLoggers,
2717 ProviderEventLoggers.NoOpProviderEventLoggers,
2718 ),
2719 ),
2720 Layer.provideMerge(ModelManifest.layerTest),
2721 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2722 Layer.provideMerge(
2723 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2724 ),
2725 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
2726 ChildProcessSpawner.make((command) => {
2727 if (command._tag !== "StandardCommand") return spawner.spawn(command);
2728 spawnedCommands.push(command.command);
2729 const beforeSpawn =
2730 command.command === secondMissing
2731 ? Deferred.succeed(secondProbeStarted, undefined).pipe(
2732 Effect.andThen(Deferred.await(releaseSecondProbe)),
2733 )
2734 : Effect.void;
2735 return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command)));
2736 }),
2737 ),
2738 Layer.provideMerge(NodeServices.layer),
2739 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2740 );
2741 const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe(
2742 Scope.provide(scope),
2743 );
2744
2745 yield* Effect.gen(function* () {
2746 const registry = yield* ProviderRegistry.ProviderRegistry;
2747 const codexSnapshots = registry.streamChanges.pipe(
2748 Stream.map((providers) =>
2749 providers.find((provider) => provider.instanceId === "codex"),
2750 ),
2751 Stream.filter((provider): provider is ServerProvider => provider !== undefined),
2752 );
2753 const firstError = yield* Stream.toPull(
2754 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2755 );
2756 const currentCodex = (yield* registry.getProviders).find(
2757 (provider) => provider.instanceId === "codex",
2758 );
2759 const initialCodex =
2760 currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0];
2761 assert.strictEqual(initialCodex?.status, "error");
2762 assert.strictEqual(initialCodex?.installed, false);
2763 assert.deepStrictEqual(spawnedCommands, [firstMissing]);
2764
2765 const pendingRebuild = yield* Stream.toPull(
2766 codexSnapshots.pipe(
2767 Stream.filter((provider) => provider.status === "warning" && !provider.installed),
2768 ),
2769 );
2770 yield* serverSettings.updateSettings({
2771 providers: {
2772 codex: { enabled: true, binaryPath: secondMissing },
2773 },
2774 });
2775 // Start the lazy stream only after publishing. A watcher that did
2776 // not subscribe before forking has already lost this update.
2777 yield* Deferred.succeed(allowLazySettingsStream, undefined);
2778
2779 // Hold the second probe until the aggregator sees the rebuilt
2780 // instance. Its next error must come from the new executable.
2781 yield* Deferred.await(secondProbeStarted);
2782 yield* pendingRebuild;
2783 const rebuiltError = yield* Stream.toPull(
2784 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2785 );
2786 yield* Deferred.succeed(releaseSecondProbe, undefined);
2787 const [reprobedCodex] = yield* rebuiltError;
2788 assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);
2789 assert.strictEqual(reprobedCodex?.status, "error");
2790 assert.strictEqual(reprobedCodex?.installed, false);
2791 }).pipe(Effect.provide(runtimeServices));
2792 }),
2793 );
2794
2795 it.effect("includes unavailable instance snapshots in getProviders", () =>
2796 Effect.gen(function* () {
2797 const serverSettings = yield* makeMutableServerSettingsService(
2798 decodeServerSettings(
2799 deepMerge(encodedDefaultServerSettings, {
2800 providers: {
2801 codex: { enabled: false },
2802 claudeAgent: { enabled: false },
2803 cursor: { enabled: false },
2804 grok: { enabled: false },
2805 opencode: { enabled: false },
2806 },
2807 providerInstances: {
2808 ghost_main: {
2809 driver: "ghostDriver",
2810 displayName: "A fork-only driver we don't ship",
2811 enabled: false,
2812 config: { arbitrary: "payload" },
2813 },
2814 } as unknown as ContractServerSettings["providerInstances"],
2815 }),
2816 ),
2817 );
2818 const scope = yield* Scope.make();
2819 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2820 const layerProviderRegistry = ProviderRegistry.layer.pipe(
2821 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2822 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2823 Layer.provideMerge(
2824 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2825 ),
2826 Layer.provideMerge(ServerSecretStore.layer),
2827 Layer.provideMerge(
2828 ServerConfig.layerTest(process.cwd(), {
2829 prefix: "t3-provider-registry-",
2830 }),
2831 ),
2832 Layer.provideMerge(layerTestHttpClient),
2833 Layer.provideMerge(
2834 Layer.succeed(
2835 ProviderEventLoggers.ProviderEventLoggers,
2836 ProviderEventLoggers.NoOpProviderEventLoggers,
2837 ),
2838 ),
2839 Layer.provideMerge(ModelManifest.layerTest),
2840 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2841 Layer.provideMerge(
2842 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2843 ),
2844 Layer.provideMerge(NodeServices.layer),
2845 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2846 );
2847 const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe(
2848 Scope.provide(scope),
2849 );
2850
2851 yield* Effect.gen(function* () {
2852 const registry = yield* ProviderRegistry.ProviderRegistry;
2853 const providers = yield* registry.getProviders;
2854 const ghost = providers.find((provider) => provider.instanceId === "ghost_main");
2855
2856 assert.notStrictEqual(ghost, undefined);
2857 assert.strictEqual(ghost?.driver, "ghostDriver");
2858 assert.strictEqual(ghost?.availability, "unavailable");
2859 assert.match(ghost?.unavailableReason ?? "", /ghostDriver/);
2860 }).pipe(Effect.provide(runtimeServices));
2861 }),
2862 );
2863
2864 it.effect(
2865 "keeps Cursor disabled and skips provider probing when settings use their defaults",
2866 () =>
2867 Effect.gen(function* () {
2868 const serverSettings = yield* makeMutableServerSettingsService(
2869 decodeServerSettings(
2870 deepMerge(encodedDefaultServerSettings, {
2871 providers: {
2872 codex: {
2873 enabled: false,
2874 },
2875 grok: {
2876 enabled: false,
2877 },
2878 },
2879 }),
2880 ),
2881 );
2882 let cursorSpawned = false;
2883 const scope = yield* Scope.make();
2884 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2885 const layerProviderRegistry = ProviderRegistry.layer.pipe(
2886 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
2887 Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer),
2888 Layer.provideMerge(
2889 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2890 ),
2891 Layer.provideMerge(ServerSecretStore.layer),
2892 Layer.provideMerge(
2893 ServerConfig.layerTest(process.cwd(), {
2894 prefix: "t3-provider-registry-",
2895 }),
2896 ),
2897 Layer.provideMerge(layerTestHttpClient),
2898 Layer.provideMerge(
2899 Layer.succeed(
2900 ProviderEventLoggers.ProviderEventLoggers,
2901 ProviderEventLoggers.NoOpProviderEventLoggers,
2902 ),
2903 ),
2904 Layer.provideMerge(ModelManifest.layerTest),
2905 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2906 Layer.provideMerge(
2907 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
2908 ),
2909 Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
2910 Layer.provideMerge(
2911 layerMockCommandSpawner((command, args) => {
2912 if (command === "cursor-agent") {
2913 cursorSpawned = true;
2914 }
2915 const joined = args.join(" ");
2916 if (joined === "--version") {
2917 return {
2918 stdout: `${command} 1.0.0\n`,
2919 stderr: "",
2920 code: 0,
2921 };
2922 }
2923 if (joined === "auth status") {
2924 return {
2925 stdout: '{"authenticated":true}\n',
2926 stderr: "",
2927 code: 0,
2928 };
2929 }
2930 throw new Error(`Unexpected args: ${command} ${joined}`);
2931 }),
2932 ),
2933 );
2934 const runtimeServices = yield* Layer.build(
2935 Layer.mergeAll(
2936 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2937 layerProviderRegistry,
2938 ),
2939 ).pipe(Scope.provide(scope));
2940
2941 yield* Effect.gen(function* () {
2942 const registry = yield* ProviderRegistry.ProviderRegistry;
2943 const providers = yield* registry.getProviders;
2944 const cursorProvider = providers.find(
2945 (provider) => provider.instanceId === ProviderInstanceId.make("cursor"),
2946 );
2947
2948 assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [
2949 "antigravity",
2950 "claudeAgent",
2951 "codex",
2952 "cursor",
2953 "grok",
2954 "opencode",
2955 "pi",
2956 ]);
2957 assert.strictEqual(cursorProvider?.enabled, false);
2958 assert.strictEqual(cursorProvider?.status, "disabled");
2959 assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings.");
2960 assert.strictEqual(cursorSpawned, false);
2961 }).pipe(Effect.provide(runtimeServices));
2962 }),
2963 );
2964
2965 it.effect("skips codex probes entirely when the provider is disabled", () =>
2966 Effect.gen(function* () {
2967 const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe(
2968 Effect.provide(layerFailingSpawner("spawn codex ENOENT")),
2969 );
2970 assert.strictEqual(status.enabled, false);
2971 assert.strictEqual(status.status, "disabled");
2972 assert.strictEqual(status.installed, false);
2973 assert.strictEqual(status.message, "Codex is disabled in T3 Code settings.");
2974 }),
2975 );
2976 });
2977
2978 // ── checkClaudeProviderStatus tests ──────────────────────────
2979
2980 describe("checkClaudeProviderStatus", () => {
2981 it.effect("returns ready when claude is installed and authenticated", () =>
2982 Effect.gen(function* () {
2983 const status = yield* checkClaudeProviderStatus(
2984 defaultClaudeSettings,
2985 claudeCapabilities(),
2986 );
2987 assert.strictEqual(status.status, "ready");
2988 assert.strictEqual(status.installed, true);
2989 assert.strictEqual(status.auth.status, "authenticated");
2990 }).pipe(
2991 Effect.provide(
2992 layerMockSpawner((args) => {
2993 const joined = args.join(" ");
2994 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
2995 if (joined === "auth status")
2996 return {
2997 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2998 stderr: "",
2999 code: 0,
3000 };
3001 throw new Error(`Unexpected args: ${joined}`);
3002 }),
3003 ),
3004 ),
3005 );
3006
3007 it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () =>
3008 Effect.gen(function* () {
3009 // Bedrock authenticates via external AWS credentials, so the SDK init
3010 // reports only `apiProvider` with no subscription or token.
3011 const status = yield* checkClaudeProviderStatus(
3012 defaultClaudeSettings,
3013 claudeCapabilities({ apiProvider: "bedrock" }),
3014 );
3015 assert.strictEqual(status.status, "ready");
3016 assert.strictEqual(status.installed, true);
3017 assert.strictEqual(status.auth.status, "authenticated");
3018 assert.strictEqual(status.auth.type, "bedrock");
3019 assert.strictEqual(status.auth.label, "Amazon Bedrock");
3020 }).pipe(
3021 Effect.provide(
3022 layerMockSpawner((args) => {
3023 const joined = args.join(" ");
3024 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3025 throw new Error(`Unexpected args: ${joined}`);
3026 }),
3027 ),
3028 ),
3029 );
3030
3031 it.effect("returns a display label for claude subscription types", () =>
3032 Effect.gen(function* () {
3033 const status = yield* checkClaudeProviderStatus(
3034 defaultClaudeSettings,
3035 claudeCapabilities({ subscriptionType: "maxplan" }),
3036 );
3037 assert.strictEqual(status.status, "ready");
3038 assert.strictEqual(status.auth.status, "authenticated");
3039 assert.strictEqual(status.auth.type, "maxplan");
3040 assert.strictEqual(status.auth.label, "Claude Max Subscription");
3041 }).pipe(
3042 Effect.provide(
3043 layerMockSpawner((args) => {
3044 const joined = args.join(" ");
3045 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3046 if (joined === "auth status")
3047 return {
3048 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3049 stderr: "",
3050 code: 0,
3051 };
3052 throw new Error(`Unexpected args: ${joined}`);
3053 }),
3054 ),
3055 ),
3056 );
3057
3058 it.effect("reads banked resets only for subscription logins", () =>
3059 Effect.gen(function* () {
3060 const check = (overrides: Partial<TestClaudeCapabilities>) =>
3061 checkClaudeProviderStatus(
3062 defaultClaudeSettings,
3063 () =>
3064 Effect.succeed({
3065 email: undefined,
3066 subscriptionType: undefined,
3067 tokenSource: undefined,
3068 apiProvider: undefined,
3069 slashCommands: [],
3070 usage: { rate_limits_available: true, rate_limits: {} },
3071 ...overrides,
3072 }),
3073 undefined,
3074 undefined,
3075 undefined,
3076 undefined,
3077 () => Effect.succeed({ availableCount: 2 }),
3078 );
3079 const subscription = yield* check({ subscriptionType: "max" });
3080 const bedrock = yield* check({ apiProvider: "bedrock" });
3081 assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 });
3082 assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined);
3083 }).pipe(
3084 Effect.provide(
3085 layerMockSpawner((args) => {
3086 const joined = args.join(" ");
3087 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3088 throw new Error(`Unexpected args: ${joined}`);
3089 }),
3090 ),
3091 ),
3092 );
3093
3094 it.effect("does not duplicate Claude in full subscription labels", () =>
3095 Effect.gen(function* () {
3096 const status = yield* checkClaudeProviderStatus(
3097 defaultClaudeSettings,
3098 claudeCapabilities({
3099 subscriptionType: "Claude Max Subscription",
3100 }),
3101 );
3102 assert.strictEqual(status.auth.status, "authenticated");
3103 assert.strictEqual(status.auth.type, "Claude Max Subscription");
3104 assert.strictEqual(status.auth.label, "Claude Max Subscription");
3105 }).pipe(
3106 Effect.provide(
3107 layerMockSpawner((args) => {
3108 const joined = args.join(" ");
3109 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3110 throw new Error(`Unexpected args: ${joined}`);
3111 }),
3112 ),
3113 ),
3114 );
3115
3116 it.effect("does not duplicate Claude in provider-prefixed subscription names", () =>
3117 Effect.gen(function* () {
3118 const status = yield* checkClaudeProviderStatus(
3119 defaultClaudeSettings,
3120 claudeCapabilities({
3121 subscriptionType: "Claude Max",
3122 }),
3123 );
3124 assert.strictEqual(status.auth.status, "authenticated");
3125 assert.strictEqual(status.auth.type, "Claude Max");
3126 assert.strictEqual(status.auth.label, "Claude Max Subscription");
3127 }).pipe(
3128 Effect.provide(
3129 layerMockSpawner((args) => {
3130 const joined = args.join(" ");
3131 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3132 throw new Error(`Unexpected args: ${joined}`);
3133 }),
3134 ),
3135 ),
3136 );
3137
3138 it.effect("returns claude auth email from initialization result", () =>
3139 Effect.gen(function* () {
3140 const status = yield* checkClaudeProviderStatus(
3141 defaultClaudeSettings,
3142 claudeCapabilities({ email: "claude@example.com" }),
3143 );
3144 assert.strictEqual(status.auth.status, "authenticated");
3145 assert.strictEqual(status.auth.email, "claude@example.com");
3146 }).pipe(
3147 Effect.provide(
3148 layerMockSpawner((args) => {
3149 const joined = args.join(" ");
3150 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3151 if (joined === "auth status")
3152 return {
3153 stdout:
3154 '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n',
3155 stderr: "",
3156 code: 0,
3157 };
3158 throw new Error(`Unexpected args: ${joined}`);
3159 }),
3160 ),
3161 ),
3162 );
3163
3164 it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => {
3165 const claudeConfigDir = "/tmp/t3code-claude-home";
3166 const recorded = recordingMockSpawnerLayer((args) => {
3167 const joined = args.join(" ");
3168 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3169 if (joined === "auth status")
3170 return {
3171 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3172 stderr: "",
3173 code: 0,
3174 };
3175 throw new Error(`Unexpected args: ${joined}`);
3176 });
3177
3178 return Effect.gen(function* () {
3179 const status = yield* checkClaudeProviderStatus(
3180 {
3181 ...defaultClaudeSettings,
3182 homePath: claudeConfigDir,
3183 },
3184 claudeCapabilities(),
3185 );
3186 assert.strictEqual(status.status, "ready");
3187 // The home is resolved through the host Path before it reaches the env.
3188 assert.deepStrictEqual(
3189 recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR),
3190 [(yield* Path.Path).resolve(claudeConfigDir)],
3191 );
3192 }).pipe(Effect.provide(recorded.layer));
3193 });
3194
3195 it.effect("includes probed claude slash commands in the provider snapshot", () =>
3196 Effect.gen(function* () {
3197 const status = yield* checkClaudeProviderStatus(
3198 defaultClaudeSettings,
3199 claudeCapabilities({
3200 subscriptionType: "maxplan",
3201 slashCommands: [
3202 {
3203 name: "review",
3204 description: "Review a pull request",
3205 input: { hint: "pr-or-branch" },
3206 },
3207 ],
3208 }),
3209 );
3210
3211 assert.deepStrictEqual(status.slashCommands.slice(1), [
3212 {
3213 name: "review",
3214 description: "Review a pull request",
3215 input: { hint: "pr-or-branch" },
3216 },
3217 ]);
3218 }).pipe(
3219 Effect.provide(
3220 layerMockSpawner((args) => {
3221 const joined = args.join(" ");
3222 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3223 if (joined === "auth status")
3224 return {
3225 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3226 stderr: "",
3227 code: 0,
3228 };
3229 throw new Error(`Unexpected args: ${joined}`);
3230 }),
3231 ),
3232 ),
3233 );
3234
3235 it.effect("deduplicates probed claude slash commands by name", () =>
3236 Effect.gen(function* () {
3237 const status = yield* checkClaudeProviderStatus(
3238 defaultClaudeSettings,
3239 claudeCapabilities({
3240 subscriptionType: "maxplan",
3241 slashCommands: [
3242 {
3243 name: "ui",
3244 description: "Explore and refine UI",
3245 },
3246 {
3247 name: "ui",
3248 input: { hint: "component-or-screen" },
3249 },
3250 ],
3251 }),
3252 );
3253
3254 assert.deepStrictEqual(status.slashCommands, [
3255 COMPACT_SLASH_COMMAND,
3256 {
3257 name: "ui",
3258 description: "Explore and refine UI",
3259 input: { hint: "component-or-screen" },
3260 },
3261 ]);
3262 }).pipe(
3263 Effect.provide(
3264 layerMockSpawner((args) => {
3265 const joined = args.join(" ");
3266 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3267 if (joined === "auth status")
3268 return {
3269 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
3270 stderr: "",
3271 code: 0,
3272 };
3273 throw new Error(`Unexpected args: ${joined}`);
3274 }),
3275 ),
3276 ),
3277 );
3278
3279 it.effect("returns an api key label for claude api key auth", () =>
3280 Effect.gen(function* () {
3281 const status = yield* checkClaudeProviderStatus(
3282 defaultClaudeSettings,
3283 claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }),
3284 );
3285 assert.strictEqual(status.status, "ready");
3286 assert.strictEqual(status.auth.status, "authenticated");
3287 assert.strictEqual(status.auth.type, "apiKey");
3288 assert.strictEqual(status.auth.label, "Claude API Key");
3289 }).pipe(
3290 Effect.provide(
3291 layerMockSpawner((args) => {
3292 const joined = args.join(" ");
3293 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3294 if (joined === "auth status")
3295 return {
3296 stdout: '{"loggedIn":true,"authMethod":"api-key"}\n',
3297 stderr: "",
3298 code: 0,
3299 };
3300 throw new Error(`Unexpected args: ${joined}`);
3301 }),
3302 ),
3303 ),
3304 );
3305
3306 it.effect("returns unavailable when claude is missing", () =>
3307 Effect.gen(function* () {
3308 const status = yield* checkClaudeProviderStatus(
3309 defaultClaudeSettings,
3310 claudeCapabilities(),
3311 );
3312 assert.strictEqual(status.status, "error");
3313 assert.strictEqual(status.installed, false);
3314 assert.strictEqual(status.auth.status, "unknown");
3315 assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH.");
3316 }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))),
3317 );
3318
3319 it.effect("returns error when version check fails with non-zero exit code", () => {
3320 const secretStderr = "Something went wrong: secret-token-value";
3321 return Effect.gen(function* () {
3322 const status = yield* checkClaudeProviderStatus(
3323 defaultClaudeSettings,
3324 claudeCapabilities(),
3325 );
3326 assert.strictEqual(status.status, "error");
3327 assert.strictEqual(status.installed, true);
3328 assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run.");
3329 assert.ok(!(status.message ?? "").includes(secretStderr));
3330 }).pipe(
3331 Effect.provide(
3332 layerMockSpawner((args) => {
3333 const joined = args.join(" ");
3334 if (joined === "--version")
3335 return {
3336 stdout: "",
3337 stderr: secretStderr,
3338 code: 1,
3339 };
3340 throw new Error(`Unexpected args: ${joined}`);
3341 }),
3342 ),
3343 );
3344 });
3345
3346 it.effect("returns warning when the Claude initialization result is unavailable", () =>
3347 Effect.gen(function* () {
3348 const status = yield* checkClaudeProviderStatus(
3349 defaultClaudeSettings,
3350 noClaudeCapabilities,
3351 );
3352 assert.strictEqual(status.status, "warning");
3353 assert.strictEqual(status.installed, true);
3354 assert.strictEqual(status.auth.status, "unknown");
3355 assert.strictEqual(
3356 status.message,
3357 "Could not verify Claude authentication status from initialization result.",
3358 );
3359 }).pipe(
3360 Effect.provide(
3361 layerMockSpawner((args) => {
3362 const joined = args.join(" ");
3363 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
3364 if (joined === "auth status")
3365 return {
3366 stdout: '{"loggedIn":false}\n',
3367 stderr: "",
3368 code: 1,
3369 };
3370 throw new Error(`Unexpected args: ${joined}`);
3371 }),
3372 ),
3373 ),
3374 );
3375 });
3376});
apps/server/src/provider/ProviderRegistry.ts created+1086
......@@ -0,0 +1,1086 @@
1/**
2 * ProviderRegistry - Provider snapshot service.
3 *
4 * Owns provider install/auth/version/model snapshots and exposes the latest
5 * provider state to transport layers.
6 *
7 * The live layer aggregates per-instance snapshot streams into a
8 * single materialized list.
9 *
10 * Historically this Layer composed four per-kind Live Layers
11 * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a
12 * `ServerProviderShape`. Those Lives were deleted during the driver /
13 * instance refactor — every driver now carries its `snapshot: ServerProviderShape`
14 * bundled onto the `ProviderInstance` the registry produces.
15 *
16 * Each configured instance (including multi-instance setups like
17 * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`,
18 * keyed by `instanceId`. Instances whose driver is unavailable or whose
19 * config failed to decode are merged from `instanceRegistry.listUnavailable`
20 * as shadow snapshots so the UI can render their exact unavailable reason.
21 *
22 * Cache paths on disk are now keyed by `instanceId`. Because
23 * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing
24 * `<kind>.json` files remain the on-disk location for that driver's default
25 * instance. Identity-less legacy cache contents are ignored and replaced by
26 * the first live refresh.
27 *
28 * @module ProviderRegistry
29 */
30import {
31 defaultInstanceIdForDriver,
32 ProviderDriverKind,
33 type ProviderInstanceId,
34 type ServerProvider,
35 type ServerProviderUpdateState,
36} from "@t3tools/contracts";
37import * as Cause from "effect/Cause";
38import * as Context from "effect/Context";
39import * as Effect from "effect/Effect";
40import * as Equal from "effect/Equal";
41import * as FileSystem from "effect/FileSystem";
42import * as Layer from "effect/Layer";
43import * as Path from "effect/Path";
44import * as PubSub from "effect/PubSub";
45import * as Ref from "effect/Ref";
46import * as Stream from "effect/Stream";
47import * as Semaphore from "effect/Semaphore";
48
49import * as ModelManifest from "./ModelManifest.ts";
50import { applyProviderCompatibility } from "./providerCompatibility.ts";
51import * as ServerConfig from "../config.ts";
52import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
53import {
54 hydrateCachedProvider,
55 isCachedProviderCorrelated,
56 orderProviderSnapshots,
57 readProviderStatusCache,
58 resolveProviderStatusCachePath,
59 writeProviderStatusCache,
60} from "./providerStatusCache.ts";
61import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts";
62import {
63 makeManualOnlyProviderMaintenanceCapabilities,
64 type ProviderMaintenanceCapabilities,
65} from "./providerMaintenance.ts";
66import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts";
67
68export type ProviderMaintenanceActionKind = "update";
69
70export class ProviderRegistry extends Context.Service<
71 ProviderRegistry,
72 {
73 /**
74 * Read the latest provider snapshots for every configured instance.
75 * Multiple snapshots may share the same `provider` kind (multiple
76 * instances of the same driver) and disambiguate via `instanceId`.
77 */
78 readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>;
79
80 /**
81 * Refresh all providers, or the default instance of the specified
82 * kind when supplied.
83 *
84 * Retained for back-compat with legacy call sites (WS refresh RPC,
85 * orchestration metrics). New code should prefer `refreshInstance`.
86 *
87 * @deprecated prefer `refreshInstance` for new call sites.
88 */
89 readonly refresh: (
90 provider?: ProviderDriverKind,
91 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
92
93 /**
94 * Refresh the specific configured instance. Returns the updated snapshot
95 * list. When the instance id is unknown the call resolves with the
96 * currently cached list (no error) — matching the legacy `refresh` shim
97 * behaviour so transport layers don't have to special-case unknowns.
98 */
99 readonly refreshInstance: (
100 instanceId: ProviderInstanceId,
101 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
102
103 /**
104 * Fill the skills and slash commands snapshot for one cwd. A cwd that
105 * already has a snapshot is left alone unless `fresh` is set. A fresh scan
106 * also refreshes the instance's machine snapshot and drops other
107 * instances' snapshots for the cwd.
108 */
109 readonly refreshWorkspaceSnapshot: (input: {
110 readonly instanceId: ProviderInstanceId;
111 readonly cwd: string;
112 readonly fresh?: boolean;
113 }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
114
115 /**
116 * Resolve the maintenance capabilities owned by one live provider instance.
117 * Falls back to manual-only capabilities when the instance is not live.
118 * `fresh` re-derives ownership from the executable instead of the cache.
119 */
120 readonly getProviderMaintenanceCapabilitiesForInstance: (
121 instanceId: ProviderInstanceId,
122 provider: ProviderDriverKind,
123 options?: { readonly fresh?: boolean },
124 ) => Effect.Effect<ProviderMaintenanceCapabilities>;
125
126 /**
127 * Apply volatile maintenance-action state to one configured instance.
128 * This state is never persisted to disk. Today only update actions are
129 * projected onto `ServerProvider.updateState`; install/auth actions can
130 * extend this action map without adding driver-scoped APIs.
131 */
132 readonly setProviderMaintenanceActionState: (input: {
133 readonly instanceId: ProviderInstanceId;
134 readonly action: ProviderMaintenanceActionKind;
135 readonly state: ServerProviderUpdateState | null;
136 }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
137
138 /**
139 * Stream of provider snapshot updates — one emission per aggregated
140 * change. The array contains the full current state.
141 */
142 readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>;
143 }
144>()("t3/provider/ProviderRegistry") {}
145
146const loadProviders = (
147 providerSources: ReadonlyArray<ProviderSnapshotSource>,
148): Effect.Effect<ReadonlyArray<ServerProvider>> =>
149 Effect.forEach(
150 providerSources,
151 (providerSource) =>
152 providerSource.getSnapshot.pipe(
153 Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)),
154 ),
155 {
156 concurrency: "unbounded",
157 },
158 );
159
160const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) =>
161 makeManualOnlyProviderMaintenanceCapabilities({
162 provider,
163 packageName: null,
164 });
165
166const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
167 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
168
169const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16;
170
171function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider {
172 return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd)
173 ? {
174 ...provider,
175 workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd),
176 }
177 : provider;
178}
179
180export function upsertProviderWorkspaceSnapshot(
181 provider: ServerProvider,
182 cwd: string,
183 scopedSnapshot: ProviderWorkspaceSnapshot,
184): ServerProvider {
185 const workspaceSnapshot = {
186 cwd,
187 checkedAt: scopedSnapshot.checkedAt,
188 slashCommands: scopedSnapshot.slashCommandsPending
189 ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ??
190 scopedSnapshot.slashCommands)
191 : scopedSnapshot.slashCommands,
192 ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}),
193 skills: scopedSnapshot.skills,
194 } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];
195 return {
196 ...provider,
197 workspaceSnapshots: [
198 ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd),
199 workspaceSnapshot,
200 ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER),
201 };
202}
203
204const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {
205 if (provider.driver === ProviderDriverKind.make("acpRegistry")) {
206 // ACP Registry discovery probes return the agent's complete inventory, so
207 // a completed probe (ready and authenticated) replaces the model list —
208 // otherwise agents that rename or collapse models leave stale entries
209 // pinned forever through the snapshot cache. Readiness-only and failed
210 // probe snapshots only know the "default" placeholder and stay partial.
211 return !(
212 provider.installed &&
213 provider.status === "ready" &&
214 provider.auth.status === "authenticated"
215 );
216 }
217
218 const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity");
219 const isCodex = provider.driver === ProviderDriverKind.make("codex");
220 if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) {
221 return true;
222 }
223
224 if (
225 (isAntigravity || isCodex) &&
226 (!provider.enabled || provider.auth.status === "unauthenticated")
227 ) {
228 return false;
229 }
230
231 // Successful discovery replaces these inventories so cached retired models disappear.
232 // Antigravity's local health check does not authenticate or discover models.
233 const isPendingAntigravityAuthentication =
234 isAntigravity && provider.status === "warning" && provider.auth.status === "unknown";
235 const isPendingInitialProbe =
236 provider.enabled && !provider.installed && provider.status === "warning";
237 const didInstalledProviderProbeFail = provider.installed && provider.status === "error";
238 return (
239 isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail
240 );
241};
242
243const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>
244 provider.driver === ProviderDriverKind.make("opencode") &&
245 shouldRetainMissingProviderModels(provider);
246
247const mergeProviderModels = (
248 provider: ServerProvider,
249 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
250 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
251): ReadonlyArray<ServerProvider["models"][number]> => {
252 const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider);
253 // Custom rows are derived from settings and every snapshot carries the full
254 // current list, so a custom model missing from `nextModels` was removed by
255 // the user and must not be resurrected from the previous snapshot.
256 const retainablePreviousModels = previousModels.filter((model) => !model.isCustom);
257
258 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {
259 return retainablePreviousModels;
260 }
261
262 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
263 const mergedModels = nextModels.map((model) => {
264 const previousModel = previousBySlug.get(model.slug);
265 if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) {
266 return model;
267 }
268 return {
269 ...model,
270 capabilities: previousModel.capabilities,
271 };
272 });
273 const nextSlugs = new Set(nextModels.map((model) => model.slug));
274 return shouldRetainMissingModels
275 ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))]
276 : mergedModels;
277};
278
279/**
280 * Antigravity's health check only initializes the agent, so after a server
281 * restart it reports the account as unchecked. The saved Google login still
282 * works, and the previous snapshot proves it. Carry that account state until
283 * a session, refresh, or sign-out reports something new. A confirmed missing
284 * installation, sign-out, disabled instance, or a changed sign-in method is
285 * never overridden.
286 */
287const carrySavedAntigravityAccount = (
288 previousProvider: ServerProvider,
289 nextProvider: ServerProvider,
290): Pick<ServerProvider, "auth" | "status"> | undefined => {
291 const antigravity = ProviderDriverKind.make("antigravity");
292 if (
293 nextProvider.driver !== antigravity ||
294 previousProvider.driver !== antigravity ||
295 !nextProvider.enabled ||
296 nextProvider.auth.status !== "unknown" ||
297 previousProvider.auth.status !== "authenticated" ||
298 (nextProvider.auth.type !== undefined &&
299 nextProvider.auth.type !== previousProvider.auth.type) ||
300 (!nextProvider.installed && nextProvider.status !== "warning")
301 ) {
302 return undefined;
303 }
304 // The pending boot probe (`installed: false`, warning) and a failed probe
305 // keep their own status; only a passed health check reads as ready.
306 const status =
307 nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status;
308 return { auth: previousProvider.auth, status };
309};
310
311export const mergeProviderSnapshot = (
312 previousProvider: ServerProvider | undefined,
313 nextProvider: ServerProvider,
314): ServerProvider => {
315 if (!previousProvider) {
316 return nextProvider;
317 }
318 const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider);
319 // "Google account access is not checked yet" describes the probe, not the
320 // account; it must not outlive the state it explained.
321 const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider;
322 return {
323 ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider),
324 ...savedAccount,
325 models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models),
326 ...(nextProvider.workspaceSnapshots !== undefined
327 ? { workspaceSnapshots: nextProvider.workspaceSnapshots }
328 : previousProvider.workspaceSnapshots !== undefined
329 ? { workspaceSnapshots: previousProvider.workspaceSnapshots }
330 : {}),
331 ...(shouldRetainMissingOpenCodeMetadata(nextProvider)
332 ? {
333 slashCommands:
334 nextProvider.slashCommands.length === 0
335 ? previousProvider.slashCommands
336 : nextProvider.slashCommands,
337 skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills,
338 }
339 : {}),
340 };
341};
342
343export const mergeProviderSnapshots = (
344 previousProviders: ReadonlyArray<ServerProvider>,
345 nextProviders: ReadonlyArray<ServerProvider>,
346): ReadonlyArray<ServerProvider> => {
347 const mergedProviders = new Map(
348 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
349 );
350
351 for (const provider of nextProviders) {
352 mergedProviders.set(
353 snapshotInstanceKey(provider),
354 mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider),
355 );
356 }
357
358 return orderProviderSnapshots([...mergedProviders.values()]);
359};
360
361export const selectProvidersByKind = (
362 providers: ReadonlyArray<ServerProvider>,
363 providerKinds: ReadonlySet<ProviderDriverKind>,
364): ReadonlyArray<ServerProvider> =>
365 providers.filter((provider) => providerKinds.has(provider.driver));
366
367const haveProvidersChanged = (
368 previousProviders: ReadonlyArray<ServerProvider>,
369 nextProviders: ReadonlyArray<ServerProvider>,
370): boolean => !Equal.equals(previousProviders, nextProviders);
371
372const correlateSnapshotWithSource = (
373 source: ProviderSnapshotSource,
374 snapshot: ServerProvider,
375): Effect.Effect<ServerProvider> => {
376 if (snapshot.instanceId !== source.instanceId) {
377 return Effect.die(
378 new Error(
379 `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`,
380 ),
381 );
382 }
383 if (snapshot.driver !== source.driverKind) {
384 return Effect.die(
385 new Error(
386 `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`,
387 ),
388 );
389 }
390 return Effect.succeed(snapshot);
391};
392
393/**
394 * Key a snapshot for aggregation and persistence. Snapshot sources
395 * must be correlated by instance id before reaching this map; missing
396 * identities are defects, not runtime routing fallbacks.
397 */
398const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => {
399 return provider.instanceId;
400};
401
402// Project a live `ProviderInstance` into the aggregator's consumption
403// shape. Each call re-captures the instance's `snapshot` closures, so
404// after `ProviderInstanceRegistry` rebuilds an instance (e.g. because
405// its settings changed), a fresh source rides the new PubSub instead
406// of a closed one.
407const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({
408 instanceId: instance.instanceId,
409 driverKind: instance.driverKind,
410 getSnapshot: instance.snapshot.getSnapshot,
411 refresh: instance.snapshot.refresh,
412 streamChanges: instance.snapshot.streamChanges,
413});
414
415export const layer = Layer.effect(
416 ProviderRegistry,
417 Effect.gen(function* () {
418 const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
419 const manifestService = yield* ModelManifest.ModelManifest;
420 const serviceScope = yield* Effect.scope;
421 const config = yield* ServerConfig.ServerConfig;
422 const fileSystem = yield* FileSystem.FileSystem;
423 const path = yield* Path.Path;
424
425 // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for
426 // coalesced updates across every instance.
427 const changesPubSub = yield* Effect.acquireRelease(
428 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
429 PubSub.shutdown,
430 );
431
432 // Boot-only: hydrate `providersRef` from the on-disk per-instance
433 // cache so the UI has something to render during the first refresh.
434 // Instances added post-boot skip this path; their first entry in
435 // `providersRef` comes from the reactive `syncLiveSources` pass
436 // below.
437 const bootInstances = yield* instanceRegistry.listInstances;
438 const bootSources = bootInstances.map(buildSnapshotSource);
439 const fallbackProviders = yield* loadProviders(bootSources);
440 const fallbackByInstance = new Map<ProviderInstanceId, ServerProvider>();
441 for (let index = 0; index < fallbackProviders.length; index++) {
442 const provider = fallbackProviders[index];
443 const source = bootSources[index];
444 if (provider === undefined || source === undefined) {
445 continue;
446 }
447 fallbackByInstance.set(source.instanceId, provider);
448 }
449
450 const cachedProviders = yield* Effect.forEach(
451 bootSources,
452 (source) =>
453 Effect.gen(function* () {
454 // One cache file per configured instance. For the default
455 // instance of a built-in kind the path equals `<kind>.json` —
456 // identical to the legacy filename. We still require the cache
457 // payload to carry matching instance id + driver kind; old
458 // identity-less payloads are discarded and the awaited refresh
459 // below repopulates the cache.
460 const filePath = yield* resolveProviderStatusCachePath({
461 cacheDir: config.providerStatusCacheDir,
462 instanceId: source.instanceId,
463 }).pipe(Effect.provideService(Path.Path, path));
464 const fallbackProvider = fallbackByInstance.get(source.instanceId);
465 if (fallbackProvider === undefined) {
466 return undefined;
467 }
468 return yield* readProviderStatusCache(filePath).pipe(
469 Effect.provideService(FileSystem.FileSystem, fileSystem),
470 Effect.flatMap((cachedProvider) => {
471 if (cachedProvider === undefined) {
472 return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined));
473 }
474 const correlation = {
475 cachedProvider,
476 fallbackProvider,
477 } as const;
478 if (!isCachedProviderCorrelated(correlation)) {
479 return Effect.logWarning("provider status cache identity mismatch, ignoring", {
480 path: filePath,
481 instanceId: source.instanceId,
482 cachedInstanceId: cachedProvider.instanceId ?? null,
483 driver: source.driverKind,
484 cachedDriver: cachedProvider.driver ?? null,
485 }).pipe(Effect.as(undefined as ServerProvider | undefined));
486 }
487 return Effect.succeed(hydrateCachedProvider(correlation));
488 }),
489 );
490 }),
491 { concurrency: "unbounded" },
492 ).pipe(
493 Effect.map((providers) =>
494 orderProviderSnapshots(
495 providers.filter((provider): provider is ServerProvider => provider !== undefined),
496 ),
497 ),
498 );
499 const initialManifest = yield* manifestService.current;
500 const classifyCompatibility = (
501 provider: ServerProvider,
502 manifest: ModelManifest.ModelManifestData,
503 ) =>
504 applyProviderCompatibility(
505 provider,
506 manifest.compatibility,
507 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
508 );
509 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(
510 cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)),
511 );
512 const workspaceRefreshesRef = yield* Ref.make<
513 ReadonlyMap<ProviderInstance, ReadonlySet<string>>
514 >(new Map());
515 const maintenanceActionStatesRef = yield* Ref.make<
516 ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
517 >(new Map());
518
519 // Live-source registry — the dynamic counterpart to the boot-time
520 // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance`
521 // reference is used for identity equality so "no-op" reconciles
522 // (settings unchanged) skip re-subscribing + re-probing.
523 const liveSubsRef = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ProviderInstance>>(
524 new Map(),
525 );
526 // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't
527 // interleave two passes clobbering each other's fiber bookkeeping.
528 const syncSemaphore = yield* Semaphore.make(1);
529
530 const getLiveSources: Effect.Effect<ReadonlyArray<ProviderSnapshotSource>> = Ref.get(
531 liveSubsRef,
532 ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource)));
533
534 const persistProvider = (provider: ServerProvider) =>
535 Effect.gen(function* () {
536 // Persist every instance — the file name is the instance id, so
537 // multi-instance setups (e.g. `codex_personal`, `codex_work`) each
538 // get their own cache. We resolve the path fresh so snapshots
539 // produced by newly-added instances post-boot still land on disk
540 // without the aggregator holding a stale `cachePathByInstance`
541 // entry.
542 const key = snapshotInstanceKey(provider);
543 const filePath = yield* resolveProviderStatusCachePath({
544 cacheDir: config.providerStatusCacheDir,
545 instanceId: key,
546 }).pipe(Effect.provideService(Path.Path, path));
547 const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider;
548 yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe(
549 Effect.provideService(FileSystem.FileSystem, fileSystem),
550 Effect.provideService(Path.Path, path),
551 Effect.tapError(Effect.logError),
552 Effect.ignore,
553 );
554 });
555
556 const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* (
557 provider: ServerProvider,
558 ) {
559 const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef);
560 const updateState = maintenanceActionStates.get(provider.instanceId)?.update;
561 if (!updateState) {
562 const { updateState: _updateState, ...providerWithoutUpdateState } = provider;
563 return providerWithoutUpdateState;
564 }
565 return {
566 ...provider,
567 updateState,
568 };
569 });
570
571 const upsertProviders = Effect.fn("upsertProviders")(function* (
572 nextProviders: ReadonlyArray<ServerProvider>,
573 options?: {
574 readonly publish?: boolean;
575 readonly persist?: boolean;
576 readonly replace?: boolean;
577 },
578 ) {
579 const manifest = yield* manifestService.current;
580 const nextProvidersWithUpdateState = yield* Effect.forEach(
581 nextProviders,
582 applyProviderUpdateState,
583 {
584 concurrency: "unbounded",
585 },
586 );
587 const [previousProviders, providers, providersToPersist] = yield* Ref.modify(
588 providersRef,
589 (previousProviders) => {
590 const mergedProviders = new Map(
591 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
592 );
593 const updatedKeys = new Set<ProviderInstanceId>();
594
595 for (const provider of nextProvidersWithUpdateState) {
596 const key = snapshotInstanceKey(provider);
597 updatedKeys.add(key);
598 mergedProviders.set(
599 key,
600 options?.replace === true
601 ? provider
602 : mergeProviderSnapshot(mergedProviders.get(key), provider),
603 );
604 }
605
606 const providers = orderProviderSnapshots(
607 [...mergedProviders.values()].map((provider) =>
608 classifyCompatibility(provider, manifest),
609 ),
610 );
611 const providersToPersist = providers.filter((provider) =>
612 updatedKeys.has(snapshotInstanceKey(provider)),
613 );
614 return [[previousProviders, providers, providersToPersist] as const, providers];
615 },
616 );
617
618 if (haveProvidersChanged(previousProviders, providers)) {
619 if (options?.persist !== false) {
620 yield* Effect.forEach(providersToPersist, persistProvider, {
621 concurrency: "unbounded",
622 discard: true,
623 });
624 }
625 if (options?.publish !== false) {
626 yield* PubSub.publish(changesPubSub, providers);
627 }
628 }
629
630 return providers;
631 });
632
633 const compatibilityRefreshRunning = yield* Ref.make(false);
634 const syncProvider = Effect.fn("syncProvider")(function* (
635 provider: ServerProvider,
636 options?: {
637 readonly publish?: boolean;
638 },
639 ) {
640 const providers = yield* upsertProviders([provider], options);
641 // Reclassify the current read model after fetching. Never republish the
642 // probe captured before the fetch: a newer health result may have landed.
643 if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) {
644 yield* manifestService.refresh.pipe(
645 Effect.andThen(upsertProviders([], { persist: false })),
646 Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)),
647 Effect.forkIn(serviceScope),
648 );
649 }
650 return providers;
651 });
652
653 const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
654 function* (input: {
655 readonly instanceId: ProviderInstanceId;
656 readonly action: "update";
657 readonly state: ServerProviderUpdateState | null;
658 }) {
659 yield* Ref.update(maintenanceActionStatesRef, (previous) => {
660 const previousActions = previous.get(input.instanceId);
661 const nextActions = { ...previousActions };
662 if (input.state === null || input.state.status === "idle") {
663 delete nextActions[input.action];
664 } else {
665 nextActions[input.action] = input.state;
666 }
667
668 const next = new Map(previous);
669 if (Object.keys(nextActions).length === 0) {
670 next.delete(input.instanceId);
671 } else {
672 next.set(input.instanceId, nextActions);
673 }
674 return next;
675 });
676
677 const existingProviders = yield* Ref.get(providersRef);
678 const matchingProvider = existingProviders.find(
679 (candidate) => candidate.instanceId === input.instanceId,
680 );
681 if (!matchingProvider) {
682 return existingProviders;
683 }
684
685 const nextProvider = yield* applyProviderUpdateState(matchingProvider);
686 return yield* upsertProviders([nextProvider], {
687 persist: false,
688 });
689 },
690 );
691
692 const refreshOneSource = Effect.fn("refreshOneSource")(function* (
693 providerSource: ProviderSnapshotSource,
694 ) {
695 return yield* providerSource.refresh.pipe(
696 Effect.flatMap((nextProvider) =>
697 correlateSnapshotWithSource(providerSource, nextProvider).pipe(
698 Effect.flatMap(syncProvider),
699 ),
700 ),
701 );
702 });
703
704 const refreshAll = Effect.fn("refreshAll")(function* () {
705 const sources = yield* getLiveSources;
706 return yield* Effect.forEach(sources, (source) => refreshOneSource(source), {
707 concurrency: "unbounded",
708 discard: true,
709 }).pipe(Effect.andThen(Ref.get(providersRef)));
710 });
711
712 const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) {
713 if (provider === undefined) {
714 return yield* refreshAll();
715 }
716 // Kind-scoped refreshes target the default instance for that driver.
717 const defaultInstanceId = defaultInstanceIdForDriver(provider);
718 const sources = yield* getLiveSources;
719 const providerSource = sources.find(
720 (candidate) => candidate.instanceId === defaultInstanceId,
721 );
722 if (!providerSource) {
723 return yield* Ref.get(providersRef);
724 }
725 return yield* refreshOneSource(providerSource);
726 });
727
728 const refreshInstance = Effect.fn("refreshInstance")(function* (
729 instanceId: ProviderInstanceId,
730 ) {
731 const sources = yield* getLiveSources;
732 const providerSource = sources.find((candidate) => candidate.instanceId === instanceId);
733 if (!providerSource) {
734 return yield* Ref.get(providersRef);
735 }
736 return yield* refreshOneSource(providerSource);
737 });
738
739 const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
740 "getProviderMaintenanceCapabilitiesForInstance",
741 )(function* (
742 instanceId: ProviderInstanceId,
743 provider: ProviderDriverKind,
744 options?: { readonly fresh?: boolean },
745 ) {
746 // Read the instance registry, not `liveSubsRef`: the latter trails
747 // reconciliation, and an update must never run a retired instance's
748 // command against a freshly configured executable.
749 const instance = yield* instanceRegistry.getInstance(instanceId);
750 if (!instance || instance.driverKind !== provider) {
751 return makeManualProviderMaintenanceCapabilities(provider);
752 }
753 return yield* instance.snapshot.resolveMaintenance(options);
754 });
755
756 /**
757 * Diff the aggregator's live-source set against the current
758 * `ProviderInstanceRegistry` and:
759 * - subscribe to each newly-added or rebuilt instance's
760 * `streamChanges` (so periodic + enrichment refreshes land in
761 * `providersRef`);
762 * - read each newly-added/rebuilt instance's current snapshot after
763 * subscribing, closing the race with its independently-running
764 * background startup probe;
765 * - prune `providersRef` of instances that no longer exist.
766 *
767 * Provider refreshes are owned by each managed provider and never run
768 * on this layer's construction path. Consumers see cached or pending
769 * snapshots immediately, then receive live probe results through the
770 * already-attached change stream.
771 *
772 * Per-instance subscription fibers are not tracked explicitly. When
773 * a rebuilt instance's old child scope closes, its PubSub shuts
774 * down and our `Stream.runForEach` fiber exits naturally.
775 */
776 const syncLiveSources = syncSemaphore.withPermits(1)(
777 Effect.gen(function* () {
778 const instances = yield* instanceRegistry.listInstances;
779 const unavailableProviders = yield* instanceRegistry.listUnavailable;
780 const nextByInstance = new Map<ProviderInstanceId, ProviderInstance>(
781 instances.map((instance) => [instance.instanceId, instance] as const),
782 );
783 const knownInstanceIds = new Set<ProviderInstanceId>(nextByInstance.keys());
784 for (const provider of unavailableProviders) {
785 knownInstanceIds.add(snapshotInstanceKey(provider));
786 }
787 const previousSubs = yield* Ref.get(liveSubsRef);
788
789 // Carry over subscriptions for instances whose identity is
790 // unchanged (reconcile treated them as no-op). Instances that
791 // disappeared, or were rebuilt with a different reference,
792 // fall through to the "newly-added" branch below.
793 const carriedOver = new Map<ProviderInstanceId, ProviderInstance>();
794 for (const [instanceId, previousInstance] of previousSubs) {
795 const nextInstance = nextByInstance.get(instanceId);
796 if (nextInstance !== undefined && nextInstance === previousInstance) {
797 carriedOver.set(instanceId, previousInstance);
798 }
799 }
800
801 // Collect new/rebuilt instances in `nextByInstance` insertion
802 // order (which preserves settings-author order).
803 const newlyAdded: Array<readonly [ProviderInstanceId, ProviderInstance]> = [];
804 for (const [instanceId, instance] of nextByInstance) {
805 if (carriedOver.has(instanceId)) {
806 continue;
807 }
808 newlyAdded.push([instanceId, instance] as const);
809 }
810
811 const rebuiltInstanceIds = new Set(
812 newlyAdded
813 .map(([instanceId]) => instanceId)
814 .filter((instanceId) => previousSubs.has(instanceId)),
815 );
816 if (rebuiltInstanceIds.size > 0) {
817 const [previousProviders, providers] = yield* Ref.modify(
818 providersRef,
819 (previousProviders) => {
820 const providers = previousProviders.map((provider) => {
821 if (!rebuiltInstanceIds.has(provider.instanceId)) return provider;
822 const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider;
823 return machineSnapshot;
824 });
825 return [[previousProviders, providers] as const, providers];
826 },
827 );
828 if (haveProvidersChanged(previousProviders, providers)) {
829 yield* PubSub.publish(changesPubSub, providers);
830 }
831 }
832
833 // Fork long-lived subscriptions to each new/rebuilt instance's
834 // change stream before reading its current snapshot. If the
835 // driver's own initial probe finishes during this sync, either
836 // the current read or the active subscriber observes the result.
837 for (const [, instance] of newlyAdded) {
838 const source = buildSnapshotSource(instance);
839 yield* Stream.runForEach(source.streamChanges, (provider) =>
840 correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)),
841 ).pipe(Effect.forkScoped);
842 }
843 yield* Effect.yieldNow;
844
845 // Snapshot current state without starting a probe. Managed providers
846 // launch their startup refresh independently, so this closes the
847 // subscription race without putting external work on the registry
848 // or HTTP server construction path.
849 yield* Effect.forEach(
850 newlyAdded,
851 ([, instance]) =>
852 Effect.gen(function* () {
853 const source = buildSnapshotSource(instance);
854 const provider = yield* source.getSnapshot;
855 yield* correlateSnapshotWithSource(source, provider).pipe(
856 Effect.flatMap(syncProvider),
857 );
858 }).pipe(Effect.ignoreCause({ log: true })),
859 { concurrency: "unbounded", discard: true },
860 );
861 yield* upsertProviders(unavailableProviders, {
862 persist: false,
863 replace: true,
864 });
865
866 const nextSubs = new Map(carriedOver);
867 for (const [instanceId, instance] of newlyAdded) {
868 nextSubs.set(instanceId, instance);
869 }
870 yield* Ref.set(liveSubsRef, nextSubs);
871
872 // Drop aggregator state for instances that have disappeared —
873 // otherwise the UI would keep rendering ghosts.
874 const [previousProviders, providers] = yield* Ref.modify(
875 providersRef,
876 (previousProviders) => {
877 const providers = orderProviderSnapshots(
878 previousProviders.filter((provider) =>
879 knownInstanceIds.has(snapshotInstanceKey(provider)),
880 ),
881 );
882 return [[previousProviders, providers] as const, providers];
883 },
884 );
885 if (haveProvidersChanged(previousProviders, providers)) {
886 yield* PubSub.publish(changesPubSub, providers);
887 }
888 yield* Ref.update(maintenanceActionStatesRef, (previous) => {
889 const next = new Map(previous);
890 for (const instanceId of previous.keys()) {
891 if (!knownInstanceIds.has(instanceId)) {
892 next.delete(instanceId);
893 }
894 }
895 return next;
896 });
897 }),
898 );
899 const syncLiveSourcesAndContinue = syncLiveSources.pipe(
900 Effect.catchCause((cause) => {
901 if (Cause.hasInterruptsOnly(cause)) {
902 return Effect.interrupt;
903 }
904 return Effect.logError(
905 "provider registry instance sync failed; keeping subscription alive",
906 {
907 cause: Cause.pretty(cause),
908 },
909 );
910 }),
911 );
912
913 // Seed `providersRef` with the boot-time fallback snapshots so
914 // consumers calling `getProviders` immediately after layer build see
915 // a populated list — even before the first `syncLiveSources` refresh
916 // resolves. Cached snapshots (already in `providersRef`) merge with
917 // these via `upsertProviders` so on-disk state wins where present
918 // and pending fallbacks fill the gaps.
919 yield* upsertProviders(fallbackProviders, { publish: false });
920 // Subscribe to registry mutations BEFORE running the initial sync.
921 // `subscribeChanges` acquires the dequeue synchronously in this
922 // fibre; the subscription is active the instant this `yield*`
923 // returns. Forking the consumer loop later cannot lose a publish
924 // because no publish can reach a not-yet-subscribed dequeue.
925 //
926 // (Contrast with the pre-fix code that did
927 // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`.
928 // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start,
929 // and `forkScoped` only schedules the fibre — so a reconcile that
930 // published between "fibre scheduled" and "fibre starts running"
931 // was dropped, which made any settings change that replaced an
932 // instance never propagate to the aggregator's `providersRef`.)
933 // Subscribe to registry mutations BEFORE running the initial sync.
934 // `subscribeChanges` acquires the `PubSub.Subscription` synchronously
935 // in this fibre; the subscription is registered with the PubSub the
936 // instant this `yield*` returns, so any subsequent publish is
937 // buffered in the subscription regardless of when the consumer
938 // fibre below actually starts running.
939 //
940 // (Contrast with the pre-fix code that did
941 // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`.
942 // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`,
943 // which defers `PubSub.subscribe` to stream start. `forkScoped` only
944 // schedules the consumer fibre — so a reconcile that published
945 // between "fibre scheduled" and "fibre starts running + subscribes"
946 // was dropped, which made any settings change that replaced an
947 // instance never propagate to the aggregator's `providersRef`.)
948 const instanceChanges = yield* instanceRegistry.subscribeChanges;
949 // Initial sync attaches subscriptions and snapshots current state for
950 // every instance present at boot. Provider probes are already running in
951 // their managed background fibers and never block this layer.
952 yield* syncLiveSources;
953 // React to registry mutations — instance added / removed / rebuilt.
954 // `Stream.fromSubscription` builds a stream over the pre-acquired
955 // subscription rather than subscribing on stream start, which is
956 // what closes the race.
957 yield* Stream.runForEach(
958 Stream.fromSubscription(instanceChanges),
959 () => syncLiveSourcesAndContinue,
960 ).pipe(Effect.forkScoped);
961
962 const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* (
963 cause: Cause.Cause<unknown>,
964 ) {
965 if (Cause.hasInterruptsOnly(cause)) {
966 return yield* Effect.interrupt;
967 }
968 yield* Effect.logError("provider registry refresh failed; preserving cached providers", {
969 cause: Cause.pretty(cause),
970 });
971 return yield* Ref.get(providersRef);
972 });
973
974 const updateProviders = (
975 update: (providers: ReadonlyArray<ServerProvider>) => ReadonlyArray<ServerProvider>,
976 ) =>
977 Ref.modify(providersRef, (currentProviders) => {
978 const nextProviders = update(currentProviders);
979 return [[currentProviders, nextProviders] as const, nextProviders];
980 }).pipe(
981 Effect.tap(([previousProviders, nextProviders]) =>
982 haveProvidersChanged(previousProviders, nextProviders)
983 ? PubSub.publish(changesPubSub, nextProviders)
984 : Effect.void,
985 ),
986 Effect.map(([, nextProviders]) => nextProviders),
987 );
988
989 const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: {
990 readonly instanceId: ProviderInstanceId;
991 readonly cwd: string;
992 readonly fresh?: boolean;
993 }) {
994 // Fresh scans drop other instances' snapshots for this cwd first, so a
995 // composer on one of them scans again on next use, even when this
996 // instance is gone or cannot be scanned.
997 if (input.fresh) {
998 yield* updateProviders((providers) =>
999 providers.map((candidate) =>
1000 candidate.instanceId === input.instanceId
1001 ? candidate
1002 : dropProviderWorkspaceSnapshot(candidate, input.cwd),
1003 ),
1004 );
1005 }
1006 const providers = yield* Ref.get(providersRef);
1007 const provider = providers.find((candidate) => candidate.instanceId === input.instanceId);
1008 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
1009 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
1010 const scannedFrom = workspaceSnapshotOf(provider);
1011 if (
1012 !provider ||
1013 !provider.enabled ||
1014 (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending)
1015 ) {
1016 return providers;
1017 }
1018 const instance = yield* instanceRegistry.getInstance(input.instanceId);
1019 if (!instance?.snapshotForCwd) return providers;
1020 const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => {
1021 const current = refreshes.get(instance);
1022 if (current?.has(input.cwd)) return [false, refreshes] as const;
1023 const next = new Map(refreshes);
1024 next.set(instance, new Set(current).add(input.cwd));
1025 return [true, next] as const;
1026 });
1027 // A fresh scan never joins a running one, which may predate the change.
1028 if (!claimed && !input.fresh) return yield* Ref.get(providersRef);
1029 // Fresh scans also re-read the machine snapshot after invalidating caches.
1030 const refreshMachineSnapshot = input.fresh
1031 ? (instance.invalidateCaches ?? Effect.void).pipe(
1032 Effect.andThen(refreshInstance(input.instanceId)),
1033 )
1034 : Effect.void;
1035 return yield* refreshMachineSnapshot.pipe(
1036 Effect.andThen(instance.snapshotForCwd(input.cwd)),
1037 Effect.flatMap((scopedSnapshot) =>
1038 scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined
1039 ? Ref.get(providersRef)
1040 : instanceRegistry.getInstance(input.instanceId).pipe(
1041 Effect.flatMap((currentInstance) => {
1042 if (currentInstance !== instance) return Ref.get(providersRef);
1043 // Write only if the cwd's snapshot did not change during the
1044 // scan. A session event or another scan that landed first is newer.
1045 return updateProviders((currentProviders) =>
1046 currentProviders.map((candidate) =>
1047 candidate.instanceId === input.instanceId &&
1048 Equal.equals(workspaceSnapshotOf(candidate), scannedFrom)
1049 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot)
1050 : candidate,
1051 ),
1052 );
1053 }),
1054 ),
1055 ),
1056 Effect.ensuring(
1057 claimed
1058 ? Ref.update(workspaceRefreshesRef, (refreshes) => {
1059 const next = new Map(refreshes);
1060 const current = new Set(next.get(instance));
1061 current.delete(input.cwd);
1062 if (current.size) next.set(instance, current);
1063 else next.delete(instance);
1064 return next;
1065 })
1066 : Effect.void,
1067 ),
1068 );
1069 });
1070
1071 return {
1072 getProviders: Ref.get(providersRef),
1073 refresh: (provider?: ProviderDriverKind) =>
1074 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
1075 refreshInstance: (instanceId: ProviderInstanceId) =>
1076 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
1077 refreshWorkspaceSnapshot: (input) =>
1078 refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)),
1079 getProviderMaintenanceCapabilitiesForInstance,
1080 setProviderMaintenanceActionState,
1081 get streamChanges() {
1082 return Stream.fromPubSub(changesPubSub);
1083 },
1084 } satisfies ProviderRegistry["Service"];
1085 }),
1086);
apps/server/src/provider/ProviderUsageLimitsIngestion.ts created+7
......@@ -0,0 +1,7 @@
1/**
2 * V2 drivers fold live account usage events directly into their managed
3 * snapshots. Retained as an empty layer for the shared server composition.
4 */
5import * as Layer from "effect/Layer";
6
7export const layer = Layer.empty;
apps/server/src/provider/ServerProvider.ts created+26
......@@ -0,0 +1,26 @@
1import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts";
2import type * as Effect from "effect/Effect";
3import type * as Stream from "effect/Stream";
4import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
5
6export interface ServerProviderShape {
7 /**
8 * Ownership-derived update capabilities. Cached between reads; pass
9 * `{ fresh: true }` before executing an update so it never trusts a
10 * resolution older than the click.
11 */
12 readonly resolveMaintenance: (options?: {
13 readonly fresh?: boolean;
14 }) => Effect.Effect<ProviderMaintenanceCapabilities>;
15 readonly getSnapshot: Effect.Effect<ServerProvider>;
16 readonly refresh: Effect.Effect<ServerProvider>;
17 readonly streamChanges: Stream.Stream<ServerProvider>;
18 /**
19 * Fold a runtime rate-limit update into the published snapshot without
20 * waiting for the next status probe. Sparse: windows merge by id and an
21 * update with no usable window leaves the snapshot untouched.
22 */
23 readonly applyUsageLimits: (
24 update: ProviderUsageLimitsUpdate & { readonly checkedAt: string },
25 ) => Effect.Effect<void>;
26}
apps/server/src/provider/Services/ProviderAuthService.ts deleted-104
......@@ -1,104 +0,0 @@
1import type {
2 ChatGptReconnectProfile,
3 ChatGptTransferredProfile,
4 ProviderAuthRespondInput,
5 ProviderAuthStartInput,
6 ProviderAuthState,
7 ProviderInstanceId,
8 ProviderSetupError,
9} from "@t3tools/contracts";
10import * as Context from "effect/Context";
11import type * as Effect from "effect/Effect";
12import type * as Stream from "effect/Stream";
13import type * as Scope from "effect/Scope";
14
15export interface ProviderAuthController {
16 /** Equal keys mean these instances share credentials on this environment. */
17 readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string };
18 readonly reconnectProfile?: (
19 methodId: string,
20 ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>;
21 readonly importProfile?: (
22 profile: ChatGptTransferredProfile,
23 stopSessions: Effect.Effect<void, ProviderSetupError>,
24 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
25 readonly adoptCredentials?: (
26 update: Effect.Effect<void, ProviderSetupError>,
27 stopSessions: Effect.Effect<void, ProviderSetupError>,
28 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
29 readonly isChangingCredentials?: Effect.Effect<boolean>;
30 readonly invalidate?: Effect.Effect<void>;
31 readonly refreshMethods?: Effect.Effect<void>;
32 readonly withAccess?: <A, E, R>(
33 task: Effect.Effect<A, E, R>,
34 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
35 readonly start: (
36 ownerSessionId: string,
37 stopSessions?: Effect.Effect<void, ProviderSetupError>,
38 methodId?: string,
39 returnUrl?: string,
40 callbackMode?: "server" | "client",
41 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
42 readonly complete: (
43 ownerSessionId: string,
44 input: { readonly flowId: string; readonly callbackUrl: string },
45 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
46 readonly cancel: (
47 ownerSessionId: string,
48 flowId: string,
49 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
50 readonly respond?: (
51 ownerSessionId: string,
52 input: ProviderAuthRespondInput,
53 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
54 /** The controller closes process admission before it stops routed sessions. */
55 readonly logout: (
56 stopSessions: Effect.Effect<void, ProviderSetupError>,
57 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
58 readonly subscribe: (ownerSessionId: string) => Stream.Stream<ProviderAuthState>;
59 readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean;
60}
61
62interface ProviderAuthTarget {
63 readonly instanceId: ProviderInstanceId;
64}
65
66export interface ProviderAuthServiceShape {
67 readonly reconnectProfile: (
68 input: ProviderAuthTarget & { methodId: string },
69 ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>;
70 readonly importProfile: (
71 input: ProviderAuthTarget & { profile: ChatGptTransferredProfile },
72 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
73 readonly start: (
74 input: ProviderAuthStartInput,
75 ownerSessionId: string,
76 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
77 readonly complete: (
78 input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string },
79 ownerSessionId: string,
80 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
81 readonly respond: (
82 input: ProviderAuthRespondInput,
83 ownerSessionId: string,
84 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
85 readonly cancel: (
86 input: ProviderAuthTarget & { readonly flowId: string },
87 ownerSessionId: string,
88 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
89 readonly logout: (
90 input: ProviderAuthTarget,
91 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
92 readonly subscribe: (
93 input: ProviderAuthTarget,
94 ownerSessionId: string,
95 ) => Stream.Stream<ProviderAuthState, ProviderSetupError>;
96 readonly tryHandlePromptCommand: (
97 input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean },
98 ) => Effect.Effect<boolean, ProviderSetupError>;
99}
100
101export class ProviderAuthService extends Context.Service<
102 ProviderAuthService,
103 ProviderAuthServiceShape
104>()("t3/provider/Services/ProviderAuthService") {}
apps/server/src/provider/Services/ProviderInstanceRegistry.ts deleted-87
......@@ -1,87 +0,0 @@
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 * as Context from "effect/Context";
22import type * as Effect from "effect/Effect";
23import type * as PubSub from "effect/PubSub";
24import type * as Scope from "effect/Scope";
25import type * as Stream from "effect/Stream";
26
27import type { ProviderInstance } from "../ProviderDriver.ts";
28
29export interface ProviderInstanceRegistryShape {
30 /**
31 * Look up one instance by id. Returns `undefined` (not Option) when the
32 * id is unknown — callers branch on falsy and emit
33 * `ProviderInstanceNotFoundError`.
34 */
35 readonly getInstance: (
36 instanceId: ProviderInstanceId,
37 ) => Effect.Effect<ProviderInstance | undefined>;
38 /**
39 * Every available (driver-registered, successfully created) instance,
40 * in stable settings-author order.
41 */
42 readonly listInstances: Effect.Effect<ReadonlyArray<ProviderInstance>>;
43 /**
44 * Wire-shape shadow snapshots for instances whose driver is unknown to
45 * this build (or whose config failed to decode). Suitable for merging
46 * directly into `ProviderRegistry` output.
47 */
48 readonly listUnavailable: Effect.Effect<ReadonlyArray<ServerProvider>>;
49 /**
50 * Push notification stream emitted whenever the registry's contents
51 * change — instance added, removed, or rebuilt. The payload is `void`
52 * because consumers always want to re-pull `listInstances` /
53 * `listUnavailable` together.
54 *
55 * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the
56 * stream starts running, forking a consumer via
57 * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next
58 * publish — the forked fiber may not have subscribed yet when the
59 * publish lands. Hot-reload consumers that must not miss a publish
60 * should use `subscribeChanges` below instead, which acquires the
61 * subscription synchronously in the caller's fiber before the consumer
62 * loop is forked.
63 */
64 readonly streamChanges: Stream.Stream<void>;
65 /**
66 * Acquire a subscription to the registry's change channel synchronously
67 * in the caller's fiber. Returns a `PubSub.Subscription<void>` whose
68 * lifetime is scoped to the provided `Scope` (the subscription is
69 * released when the scope closes). Consumers typically `yield*` this
70 * in the same fiber that forks their consumer loop, then drain with
71 * `PubSub.take(subscription)` inside `Effect.forever`. Because the
72 * subscription is registered with the PubSub before this `yield*`
73 * returns, no subsequent publish can land in a gap.
74 *
75 * This exists because the `ProviderInstanceRegistry` publishes on a
76 * PubSub and `Stream.fromPubSub` defers subscription until the stream
77 * starts executing — a consumer that `forkScoped`s the stream
78 * consumption can miss a publish that lands in the narrow window
79 * between "fiber scheduled" and "fiber starts running".
80 */
81 readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>;
82}
83
84export class ProviderInstanceRegistry extends Context.Service<
85 ProviderInstanceRegistry,
86 ProviderInstanceRegistryShape
87>()("t3/provider/Services/ProviderInstanceRegistry") {}
apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts deleted-52
......@@ -1,52 +0,0 @@
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 * as Context from "effect/Context";
33import type * as Effect from "effect/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 deleted-95
......@@ -1,95 +0,0 @@
1/**
2 * ProviderRegistry - Provider snapshot service.
3 *
4 * Owns provider install/auth/version/model snapshots and exposes the latest
5 * provider state to transport layers.
6 *
7 * @module ProviderRegistry
8 */
9import type {
10 ProviderInstanceId,
11 ProviderDriverKind,
12 ServerProvider,
13 ServerProviderUpdateState,
14} from "@t3tools/contracts";
15import * as Context from "effect/Context";
16import type * as Effect from "effect/Effect";
17import type * as Stream from "effect/Stream";
18import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
19
20export type ProviderMaintenanceActionKind = "update";
21
22export interface ProviderRegistryShape {
23 /**
24 * Read the latest provider snapshots for every configured instance.
25 * Multiple snapshots may share the same `provider` kind (multiple
26 * instances of the same driver) and disambiguate via `instanceId`.
27 */
28 readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>;
29
30 /**
31 * Refresh all providers, or the default instance of the specified
32 * kind when supplied.
33 *
34 * Retained for back-compat with legacy call sites (WS refresh RPC,
35 * orchestration metrics). New code should prefer `refreshInstance`.
36 *
37 * @deprecated prefer `refreshInstance` for new call sites.
38 */
39 readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect<ReadonlyArray<ServerProvider>>;
40
41 /**
42 * Refresh the specific configured instance. Returns the updated snapshot
43 * list. When the instance id is unknown the call resolves with the
44 * currently cached list (no error) — matching the legacy `refresh` shim
45 * behaviour so transport layers don't have to special-case unknowns.
46 */
47 readonly refreshInstance: (
48 instanceId: ProviderInstanceId,
49 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
50
51 /**
52 * Fill the skills and slash commands snapshot for one cwd. A cwd that
53 * already has a snapshot is left alone unless `fresh` is set. A fresh scan
54 * also refreshes the instance's machine snapshot and drops other
55 * instances' snapshots for the cwd.
56 */
57 readonly refreshWorkspaceSnapshot: (input: {
58 readonly instanceId: ProviderInstanceId;
59 readonly cwd: string;
60 readonly fresh?: boolean;
61 }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
62
63 /**
64 * Resolve the maintenance capabilities owned by one live provider instance.
65 * Falls back to manual-only capabilities when the instance is not live.
66 * `fresh` re-derives ownership from the executable instead of the cache.
67 */
68 readonly getProviderMaintenanceCapabilitiesForInstance: (
69 instanceId: ProviderInstanceId,
70 provider: ProviderDriverKind,
71 options?: { readonly fresh?: boolean },
72 ) => Effect.Effect<ProviderMaintenanceCapabilities>;
73
74 /**
75 * Apply volatile maintenance-action state to one configured instance.
76 * This state is never persisted to disk. Today only update actions are
77 * projected onto `ServerProvider.updateState`; install/auth actions can
78 * extend this action map without adding driver-scoped APIs.
79 */
80 readonly setProviderMaintenanceActionState: (input: {
81 readonly instanceId: ProviderInstanceId;
82 readonly action: ProviderMaintenanceActionKind;
83 readonly state: ServerProviderUpdateState | null;
84 }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
85
86 /**
87 * Stream of provider snapshot updates — one emission per aggregated
88 * change. The array contains the full current state.
89 */
90 readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>;
91}
92
93export class ProviderRegistry extends Context.Service<ProviderRegistry, ProviderRegistryShape>()(
94 "t3/provider/Services/ProviderRegistry",
95) {}
apps/server/src/provider/Services/ServerProvider.ts deleted-26
......@@ -1,26 +0,0 @@
1import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts";
2import type * as Effect from "effect/Effect";
3import type * as Stream from "effect/Stream";
4import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
5
6export interface ServerProviderShape {
7 /**
8 * Ownership-derived update capabilities. Cached between reads; pass
9 * `{ fresh: true }` before executing an update so it never trusts a
10 * resolution older than the click.
11 */
12 readonly resolveMaintenance: (options?: {
13 readonly fresh?: boolean;
14 }) => Effect.Effect<ProviderMaintenanceCapabilities>;
15 readonly getSnapshot: Effect.Effect<ServerProvider>;
16 readonly refresh: Effect.Effect<ServerProvider>;
17 readonly streamChanges: Stream.Stream<ServerProvider>;
18 /**
19 * Fold a runtime rate-limit update into the published snapshot without
20 * waiting for the next status probe. Sparse: windows merge by id and an
21 * update with no usable window leaves the snapshot untouched.
22 */
23 readonly applyUsageLimits: (
24 update: ProviderUsageLimitsUpdate & { readonly checkedAt: string },
25 ) => Effect.Effect<void>;
26}
apps/server/src/provider/acp/AcpNativeLogging.test.ts+1-1
......@@ -8,7 +8,7 @@ import * as Logger from "effect/Logger";
88import * as Schema from "effect/Schema";
99import * as AcpErrors from "effect-acp/errors";
1010
11import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";
11import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
1212import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts";
1313
1414const nodeServicesIt = it.layer(NodeServices.layer);
apps/server/src/provider/acp/AcpNativeLogging.ts+1-1
......@@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime";
66import * as Effect from "effect/Effect";
77import type * as EffectAcpProtocol from "effect-acp/protocol";
88
9import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";
9import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
1010import {
1111 structuralProtocolMethod,
1212 summarizeNativeProtocolPayload,
apps/server/src/provider/builtInProviderCatalog.ts+1-1
......@@ -1,6 +1,6 @@
11import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
22import type * as Stream from "effect/Stream";
3import type { ServerProviderShape } from "./Services/ServerProvider.ts";
3import type { ServerProviderShape } from "./ServerProvider.ts";
44
55export type ProviderSnapshotSource = {
66 /**
apps/server/src/provider/claudeResetCredits.test.ts created+259
......@@ -0,0 +1,259 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import { HttpClient, HttpClientResponse, UrlParams } from "effect/http";
12import { describe, expect, it } from "vite-plus/test";
13
14import * as ClaudeResetCredits from "./claudeResetCredits.ts";
15
16const NOW = Date.parse("2026-09-22T12:00:00.000Z");
17const grant = (overrides: Record<string, unknown>) => ({
18 id: "grant_a",
19 resets_left: 1,
20 usable_now: true,
21 ...overrides,
22});
23
24const writeLogin = Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const directory = yield* fs.makeTempDirectoryScoped();
28 yield* fs.writeFileString(
29 path.join(directory, ".credentials.json"),
30 '{"claudeAiOauth":{"accessToken":"oauth-token"}}',
31 );
32 const accountConfigPath = path.join(directory, ".claude.json");
33 yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}');
34 return { configDir: directory, accountConfigPath };
35});
36
37const respond = (status: number, body: unknown) =>
38 HttpClient.make((request) =>
39 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))),
40 );
41const refuseRequests = HttpClient.make(() => Effect.die("must not send a request"));
42
43describe("claudeResetCreditsToContract", () => {
44 it("counts live grants and pins the next usable one", () => {
45 expect(
46 ClaudeResetCredits.claudeResetCreditsToContract(
47 {
48 eligible: true,
49 next_grant_id: "grant_a",
50 grants: [
51 grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }),
52 grant({ id: "paused", paused: true }),
53 grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }),
54 grant({ id: "garbled", ends_at: "not a date" }),
55 grant({ id: "date_only", ends_at: "2026-10-01" }),
56 grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }),
57 grant({ id: "empty", ends_at: "" }),
58 grant({ id: "Not Valid" }),
59 grant({ id: "grant_b", resets_left: 3, usable_now: false }),
60 ],
61 },
62 NOW,
63 ),
64 ).toEqual({
65 availableCount: 2,
66 nextCreditId: "grant_a",
67 nextExpiresAt: "2026-10-01T00:00:00.000Z",
68 });
69 });
70
71 it("offers nothing to redeem without a usable next grant or an eligible account", () => {
72 expect(
73 ClaudeResetCredits.claudeResetCreditsToContract(
74 { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] },
75 NOW,
76 ),
77 ).toEqual({ availableCount: 0 });
78 expect(
79 ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW),
80 ).toEqual({
81 availableCount: 0,
82 });
83 expect(
84 ClaudeResetCredits.claudeResetCreditsToContract(
85 { eligible: false, grants: [grant({})] },
86 NOW,
87 ),
88 ).toBeUndefined();
89 expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined();
90 });
91});
92
93effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => {
94 it.effect("reads the grants with the CLI's request", () =>
95 Effect.gen(function* () {
96 const { configDir } = yield* writeLogin;
97 const client = HttpClient.make((request) => {
98 expect(request.method).toBe("GET");
99 expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage");
100 expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1");
101 expect(request.headers.authorization).toBe("Bearer oauth-token");
102 expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
103 expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)");
104 return Effect.succeed(
105 HttpClientResponse.fromWeb(
106 request,
107 Response.json({
108 cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] },
109 }),
110 ),
111 );
112 });
113 const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
114 Effect.provideService(HostProcessPlatform, "linux"),
115 Effect.provideService(HttpClient.HttpClient, client),
116 );
117 expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" });
118 }),
119 );
120
121 it.effect("reads nothing from keychain logins or failed requests", () =>
122 Effect.gen(function* () {
123 const { configDir } = yield* writeLogin;
124 const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
125 Effect.provideService(HostProcessPlatform, "darwin"),
126 Effect.provideService(HttpClient.HttpClient, refuseRequests),
127 );
128 const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
129 Effect.provideService(HostProcessPlatform, "linux"),
130 Effect.provideService(HttpClient.HttpClient, respond(429, {})),
131 );
132 expect([darwin, limited]).toEqual([undefined, undefined]);
133 }),
134 );
135});
136
137const ClaimBody = Schema.fromJsonString(
138 Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }),
139);
140const decodeClaimBody = Schema.decodeEffect(ClaimBody);
141
142const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) =>
143 Effect.gen(function* () {
144 const login = yield* writeLogin;
145 return yield* ClaudeResetCredits.consumeClaudeResetCredit({
146 ...login,
147 version: "2.1.0",
148 ...ids,
149 }).pipe(
150 Effect.provideService(HostProcessPlatform, "linux"),
151 Effect.provideService(HttpClient.HttpClient, client),
152 Effect.result,
153 );
154 });
155
156effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => {
157 it.effect("claims the grant for the organization", () =>
158 Effect.gen(function* () {
159 const client = HttpClient.make((request) =>
160 Effect.gen(function* () {
161 expect(request.method).toBe("POST");
162 expect(request.url).toBe(
163 "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits",
164 );
165 expect(request.headers.authorization).toBe("Bearer oauth-token");
166 const body =
167 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
168 expect(yield* decodeClaimBody(body)).toEqual({
169 program: "cedar_ember",
170 grant_id: "grant_a",
171 request_id: "r-1",
172 });
173 return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }));
174 }).pipe(Effect.orDie),
175 );
176 expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" });
177 }),
178 );
179
180 it.effect("maps each answer to an outcome or a failure", () =>
181 Effect.gen(function* () {
182 for (const [result, outcome] of [
183 ["not_limited", "nothingToReset"],
184 ["already_used", "alreadyRedeemed"],
185 ["ineligible", "noCredit"],
186 ] as const) {
187 expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome });
188 }
189 for (const client of [
190 respond(200, { result: "cooldown" }),
191 respond(429, {}),
192 respond(401, {}),
193 ]) {
194 const result = yield* consume(client);
195 expect(result).toMatchObject({ _tag: "Failure" });
196 // Claude answered, so a retry must be a new claim.
197 if (result._tag === "Failure") {
198 expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true);
199 }
200 }
201 // No answer, or Claude could not confirm the claim: a retry is the same claim.
202 for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) {
203 const unanswered = yield* consume(client);
204 expect(unanswered).toMatchObject({ _tag: "Failure" });
205 if (unanswered._tag === "Failure") {
206 expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe(
207 false,
208 );
209 }
210 }
211 }),
212 );
213
214 it.effect("times out a stalled claim body", () =>
215 Effect.gen(function* () {
216 const login = yield* writeLogin;
217 const readingBody = yield* Deferred.make<void>();
218 const client = HttpClient.make((request) => {
219 const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }));
220 Object.defineProperty(response, "json", {
221 value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)),
222 });
223 return Effect.succeed(response);
224 });
225 const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({
226 ...login,
227 version: "2.1.0",
228 grantId: "grant_a",
229 requestId: "r-1",
230 }).pipe(
231 Effect.provideService(HostProcessPlatform, "linux"),
232 Effect.provideService(HttpClient.HttpClient, client),
233 Effect.result,
234 Effect.forkChild,
235 );
236 yield* Deferred.await(readingBody);
237 yield* TestClock.adjust("26 seconds");
238 expect(yield* Fiber.join(claim)).toMatchObject({
239 _tag: "Failure",
240 failure: {
241 _tag: "ClaudeResetCreditError",
242 reason: "requestFailed",
243 cause: { _tag: "TimeoutError" },
244 },
245 });
246 }).pipe(Effect.provide(TestClock.layer())),
247 );
248
249 it.effect("refuses malformed ids without sending anything", () =>
250 Effect.gen(function* () {
251 for (const ids of [
252 { grantId: "Bad Grant", requestId: "r-1" },
253 { grantId: "grant_a", requestId: "has space" },
254 ]) {
255 expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" });
256 }
257 }),
258 );
259});
apps/server/src/provider/claudeResetCredits.ts created+270
......@@ -0,0 +1,270 @@
1/**
2 * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the
3 * grants from the OAuth usage endpoint and claims one against the
4 * organization; this module does the same with the credentials the CLI keeps
5 * in its config directory. macOS keeps them in the keychain, so there the
6 * feature is not offered.
7 *
8 * @module provider/claudeResetCredits
9 */
10import * as NodeOS from "node:os";
11import type {
12 ProviderConsumeResetCreditOutcome,
13 ServerProviderResetCredits,
14} from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
23
24const API_BASE = "https://api.anthropic.com";
25const PROGRAM = "cedar_ember";
26const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
27const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
28const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
29
30const Credentials = Schema.Struct({
31 claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })),
32});
33const Config = Schema.Struct({
34 oauthAccount: Schema.optional(
35 Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }),
36 ),
37});
38const Grant = Schema.Struct({
39 id: Schema.String.check(Schema.isPattern(GRANT_ID)),
40 resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
41 ends_at: Schema.optional(Schema.NullOr(Schema.String)),
42 paused: Schema.optional(Schema.Boolean),
43 usable_now: Schema.optional(Schema.Boolean),
44});
45const decodeGrant = Schema.decodeUnknownOption(Grant);
46const CedarEmber = Schema.Struct({
47 eligible: Schema.Boolean,
48 grants: Schema.optional(Schema.Array(Schema.Unknown)),
49 next_grant_id: Schema.optional(Schema.NullOr(Schema.String)),
50});
51const UsageResponse = Schema.Struct({
52 cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)),
53});
54const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber);
55const ClaimResponse = Schema.Struct({
56 result: Schema.Literals([
57 "reset",
58 "already_used",
59 "not_limited",
60 "cooldown",
61 "ineligible",
62 "unavailable",
63 ]),
64});
65
66const RESET_CREDIT_FAILURES = {
67 malformedCredit: "Claude returned a malformed reset credit.",
68 loginUnreadable: "Claude could not read its login.",
69 accountUnreadable: "Claude could not read its account.",
70 signedOut: "Sign in to Claude again to redeem resets.",
71 rateLimited: "Claude is rate limiting resets. Try again soon.",
72 coolingDown: "Claude resets are cooling down. Try again later.",
73 unconfirmed:
74 "Claude could not confirm the reset. If you are still limited in a moment, try again.",
75 requestFailed: "Claude could not redeem the reset.",
76} as const;
77
78class ClaudeResetCreditError extends Schema.TaggedError<ClaudeResetCreditError>()(
79 "ClaudeResetCreditError",
80 {
81 reason: Schema.Literals(
82 Object.keys(RESET_CREDIT_FAILURES) as Array<keyof typeof RESET_CREDIT_FAILURES>,
83 ),
84 cause: Schema.optional(Schema.Defect()),
85 },
86) {
87 override get message(): string {
88 return RESET_CREDIT_FAILURES[this.reason];
89 }
90}
91
92const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError);
93
94/**
95 * Every reset failure except `requestFailed` and `unconfirmed` is final:
96 * Claude answered, or nothing was sent. An unanswered or unconfirmed claim
97 * retries with the same request id.
98 */
99export const isSettledClaudeResetCreditFailure = (error: unknown) =>
100 isClaudeResetCreditError(error) &&
101 error.reason !== "requestFailed" &&
102 error.reason !== "unconfirmed";
103
104/** Rejects unparseable and calendar-invalid timestamps such as February 30. */
105const isFutureTimestamp = (value: string, nowMs: number) => {
106 if (!COMPLETE_TIMESTAMP.test(value)) return false;
107 const [year, month, day] = value.slice(0, 10).split("-").map(Number);
108 return (
109 Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0)
110 );
111};
112
113/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */
114export function claudeResetCreditsToContract(
115 block: unknown,
116 nowMs: number,
117): ServerProviderResetCredits | undefined {
118 const parsed = decodeCedarEmber(block);
119 if (Option.isNone(parsed) || !parsed.value.eligible) return undefined;
120 const live = (parsed.value.grants ?? [])
121 .flatMap((raw) => Option.toArray(decodeGrant(raw)))
122 .filter(
123 (grant) =>
124 !grant.paused &&
125 grant.usable_now &&
126 (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)),
127 );
128 const next = live.find((grant) => grant.id === parsed.value.next_grant_id);
129 const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none();
130 return {
131 availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0,
132 ...(Option.isSome(nextExpiresAt)
133 ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) }
134 : {}),
135 ...(next ? { nextCreditId: next.id } : {}),
136 };
137}
138
139const readJson = <S extends Schema.Top>(schema: S, file: string) =>
140 Effect.gen(function* () {
141 const fs = yield* FileSystem.FileSystem;
142 return yield* fs.readFileString(file).pipe(
143 Effect.catchTags({
144 PlatformError: (error) =>
145 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
146 }),
147 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))),
148 );
149 });
150
151const readAccessToken = (configDir: string) =>
152 Effect.gen(function* () {
153 if ((yield* HostProcessPlatform) === "darwin") return undefined;
154 const path = yield* Path.Path;
155 const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json"));
156 return credentials.claudeAiOauth?.accessToken?.trim() || undefined;
157 });
158
159const withClaudeHeaders = (token: string, version: string) =>
160 HttpClientRequest.setHeaders({
161 authorization: `Bearer ${token}`,
162 "anthropic-beta": "oauth-2025-04-20",
163 "user-agent": `claude-cli/${version} (external, cli)`,
164 });
165
166/**
167 * Reads the banked resets for the login in `configDir`. Any failure reads as
168 * "no resets" so the usage bars never break on this optional extra.
169 */
170export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")(
171 function* (configDir: string, version: string) {
172 const token = yield* readAccessToken(configDir);
173 if (!token) return undefined;
174 const client = yield* HttpClient.HttpClient;
175 const response = yield* client.execute(
176 HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, {
177 urlParams: { cedar_ember: "1", skip_spend: "1" },
178 }).pipe(withClaudeHeaders(token, version)),
179 );
180 const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)(
181 yield* HttpClientResponse.filterStatusOk(response),
182 );
183 return claudeResetCreditsToContract(
184 body.cedar_ember,
185 DateTime.toEpochMillis(yield* DateTime.now),
186 );
187 },
188 Effect.timeout("10 seconds"),
189 Effect.orElseSucceed(() => undefined),
190);
191
192/** The CLI keeps the account record beside its settings, or in the home directory by default. */
193export const claudeAccountConfigPath = (configDir: string | undefined) =>
194 Effect.map(Path.Path, (path) =>
195 configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"),
196 );
197
198const CLAIM_OUTCOMES = {
199 reset: "reset",
200 not_limited: "nothingToReset",
201 already_used: "alreadyRedeemed",
202 ineligible: "noCredit",
203} as const satisfies Record<string, ProviderConsumeResetCreditOutcome>;
204
205/**
206 * Claims `grantId`. `requestId` is the idempotency key: a retry with the same
207 * id is the same claim. Ids are checked before anything is sent.
208 */
209export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: {
210 readonly configDir: string;
211 readonly accountConfigPath: string;
212 readonly version: string;
213 readonly grantId: string;
214 readonly requestId: string;
215}) {
216 if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) {
217 return yield* new ClaudeResetCreditError({ reason: "malformedCredit" });
218 }
219 const token = yield* readAccessToken(input.configDir).pipe(
220 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })),
221 );
222 const config = yield* readJson(Config, input.accountConfigPath).pipe(
223 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })),
224 );
225 const organization = config.oauthAccount?.organizationUuid?.trim();
226 if (!token || !organization) {
227 return yield* new ClaudeResetCreditError({ reason: "signedOut" });
228 }
229 const client = yield* HttpClient.HttpClient;
230 const response = yield* client
231 .execute(
232 HttpClientRequest.post(
233 new URL(
234 `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`,
235 API_BASE,
236 ),
237 ).pipe(
238 withClaudeHeaders(token, input.version),
239 HttpClientRequest.bodyJsonUnsafe({
240 program: PROGRAM,
241 grant_id: input.grantId,
242 request_id: input.requestId,
243 }),
244 ),
245 )
246 .pipe(
247 Effect.timeout("25 seconds"),
248 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
249 );
250 if (response.status === 429) {
251 return yield* new ClaudeResetCreditError({ reason: "rateLimited" });
252 }
253 if (response.status === 401 || response.status === 403) {
254 return yield* new ClaudeResetCreditError({ reason: "signedOut" });
255 }
256 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
257 Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)),
258 Effect.timeout("25 seconds"),
259 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
260 );
261 if (body.result === "cooldown") {
262 return yield* new ClaudeResetCreditError({ reason: "coolingDown" });
263 }
264 // Claude could not say whether the claim landed, so, like the CLI, keep the
265 // request id and let the retry ask about the same claim.
266 if (body.result === "unavailable") {
267 return yield* new ClaudeResetCreditError({ reason: "unconfirmed" });
268 }
269 return CLAIM_OUTCOMES[body.result];
270});
apps/server/src/provider/claudeUsageLimits.test.ts created+180
......@@ -0,0 +1,180 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts";
4
5const checkedAt = "2026-07-18T10:00:00.000Z";
6const noNames = { overageIncluded: undefined } as const;
7
8describe("claudeUsageResponseToLimits", () => {
9 it("maps the session, weekly, and model-scoped weekly windows", () => {
10 expect(
11 claudeUsageResponseToLimits({
12 checkedAt,
13 response: {
14 rate_limits_available: true,
15 rate_limits: {
16 five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" },
17 seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" },
18 seven_day_opus: { utilization: 3, resets_at: null },
19 // Newer CLIs add this on top of the typed keys; the pinned SDK
20 // typings do not know it yet.
21 ...({
22 model_scoped: [
23 { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" },
24 { display_name: "Ghost", utilization: null, resets_at: null },
25 ],
26 } as object),
27 extra_usage: {
28 is_enabled: false,
29 monthly_limit: null,
30 used_credits: null,
31 utilization: null,
32 },
33 },
34 },
35 }),
36 ).toEqual({
37 names: { overageIncluded: "Fable" },
38 limits: {
39 checkedAt,
40 windows: [
41 {
42 id: "five_hour",
43 kind: "session",
44 label: "Session",
45 usedPercent: 54,
46 windowDurationMins: 300,
47 resetsAt: "2026-07-18T14:39:00.000Z",
48 },
49 {
50 id: "seven_day",
51 kind: "weekly",
52 label: "Weekly",
53 usedPercent: 18.4,
54 windowDurationMins: 10080,
55 resetsAt: "2026-07-24T08:59:00.000Z",
56 },
57 {
58 id: "seven_day_fable",
59 kind: "weekly",
60 label: "Weekly · Fable",
61 usedPercent: 73,
62 windowDurationMins: 10080,
63 resetsAt: "2026-07-24T08:59:00.000Z",
64 },
65 ],
66 },
67 });
68 });
69
70 it("names the overage-included bucket only from a scoped entry that drew a row", () => {
71 expect(
72 claudeUsageResponseToLimits({
73 checkedAt,
74 response: {
75 rate_limits_available: true,
76 rate_limits: {
77 ...({
78 model_scoped: [
79 { display_name: "Ghost", utilization: null, resets_at: null },
80 { display_name: "Fable", utilization: 5, resets_at: null },
81 ],
82 } as object),
83 },
84 },
85 }).names,
86 ).toEqual({ overageIncluded: "Fable" });
87 });
88
89 it("reports API key and Bedrock accounts as unsupported", () => {
90 expect(
91 claudeUsageResponseToLimits({
92 checkedAt,
93 response: { rate_limits_available: false, rate_limits: null },
94 }).limits,
95 ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } });
96 });
97
98 it("skips a window the endpoint reports without a utilization", () => {
99 expect(
100 claudeUsageResponseToLimits({
101 checkedAt,
102 response: {
103 rate_limits_available: true,
104 rate_limits: {
105 five_hour: { utilization: null, resets_at: null },
106 seven_day: { utilization: 250, resets_at: null },
107 },
108 },
109 }).limits.windows,
110 ).toEqual([
111 {
112 id: "seven_day",
113 kind: "weekly",
114 label: "Weekly",
115 usedPercent: 100,
116 windowDurationMins: 10080,
117 },
118 ]);
119 });
120});
121
122describe("claudeRateLimitEventToUpdate", () => {
123 it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => {
124 expect(
125 claudeRateLimitEventToUpdate(
126 {
127 status: "allowed_warning",
128 rateLimitType: "seven_day",
129 utilization: 0.85,
130 resetsAt: 1_784_000_000,
131 },
132 noNames,
133 ),
134 ).toEqual({
135 windows: [
136 {
137 id: "seven_day",
138 kind: "weekly",
139 label: "Weekly",
140 usedPercent: 85,
141 windowDurationMins: 10080,
142 resetsAt: "2026-07-14T03:33:20.000Z",
143 },
144 ],
145 });
146 });
147
148 it("lands the streamed overage-included bucket on the row the probe named", () => {
149 const event = {
150 status: "allowed",
151 rateLimitType: "seven_day_overage_included" as never,
152 utilization: 0.4,
153 } as const;
154 // No probe has named the bucket yet: guessing would open a stray row.
155 expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined();
156 expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({
157 windows: [
158 {
159 id: "seven_day_fable",
160 kind: "weekly",
161 label: "Weekly · Fable",
162 usedPercent: 40,
163 windowDurationMins: 10080,
164 },
165 ],
166 });
167 });
168
169 it("ignores windows the page does not render and events without a utilization", () => {
170 expect(
171 claudeRateLimitEventToUpdate(
172 { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 },
173 noNames,
174 ),
175 ).toBeUndefined();
176 expect(
177 claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames),
178 ).toBeUndefined();
179 });
180});
apps/server/src/provider/claudeUsageLimits.ts created+199
......@@ -0,0 +1,199 @@
1/**
2 * Claude Code subscription usage. Both sources produce windows with the same
3 * ids so a turn-driven `rate_limit_event` lands on the row the SDK's
4 * `get_usage` read established:
5 *
6 * - `get_usage` (on demand, during the capabilities probe) reports every
7 * window at once as 0–100 percentages with ISO reset times.
8 * - `rate_limit_event` (streamed during a turn) names one window at a time
9 * with a 0–1 utilization fraction and an epoch-seconds reset.
10 *
11 * @module provider/claudeUsageLimits
12 */
13import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
14import type {
15 ProviderUsageLimitsUpdate,
16 ServerProviderUsageLimits,
17 ServerProviderUsageWindow,
18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Ref from "effect/Ref";
23
24import {
25 clampPercent,
26 makeUnavailableUsageLimits,
27 makeUsageLimits,
28} from "./providerUsageLimits.ts";
29
30const SESSION_MINS = 5 * 60;
31const WEEK_MINS = 7 * 24 * 60;
32
33/**
34 * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped
35 * weeklies are additive on top of these: the CLI reports them under
36 * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included
37 * model bucket (Fable today) as `seven_day_overage_included`.
38 */
39const WINDOWS: Readonly<
40 Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">>
41> = {
42 five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS },
43 seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS },
44};
45
46/**
47 * The streamed event names the overage-included bucket by type
48 * (`seven_day_overage_included`), while `get_usage` names it by the model's
49 * `display_name`. Which model that is changes over time, so the probe records
50 * the name it saw and the event mapper reuses it; the mid-turn update then
51 * lands on the row the probe drew instead of opening a second one.
52 */
53const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included";
54
55export interface ClaudeScopedLimitNames {
56 readonly overageIncluded: string | undefined;
57}
58
59export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
60 overageIncluded: undefined,
61});
62
63function scopedWindowId(displayName: string): string {
64 return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
65}
66
67function scopedWindow(
68 displayName: string,
69 usedPercent: number,
70 resetsAt: string | undefined,
71): ServerProviderUsageWindow {
72 return {
73 id: scopedWindowId(displayName),
74 kind: "weekly",
75 label: `Weekly · ${displayName}`,
76 windowDurationMins: WEEK_MINS,
77 usedPercent: clampPercent(usedPercent),
78 ...(resetsAt ? { resetsAt } : {}),
79 };
80}
81
82/**
83 * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is
84 * read structurally until the `.d.ts` catches up.
85 */
86interface ModelScopedWindow {
87 readonly display_name: string;
88 readonly utilization: number | null;
89 readonly resets_at: string | null;
90}
91
92function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> {
93 const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped;
94 if (!Array.isArray(raw)) return [];
95 return raw.filter(
96 (entry): entry is ModelScopedWindow =>
97 typeof entry === "object" &&
98 entry !== null &&
99 typeof (entry as ModelScopedWindow).display_name === "string",
100 );
101}
102
103function isoFromEpochSeconds(value: number | undefined): string | undefined {
104 if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined;
105 const dt = DateTime.make(value * 1000);
106 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
107}
108
109function isoFromString(value: string | null | undefined): string | undefined {
110 if (!value) return undefined;
111 const dt = DateTime.make(value);
112 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
113}
114
115function makeWindow(
116 id: keyof typeof WINDOWS & string,
117 usedPercent: number,
118 resetsAt: string | undefined,
119): ServerProviderUsageWindow {
120 const window = WINDOWS[id]!;
121 return {
122 id,
123 ...window,
124 usedPercent: clampPercent(usedPercent),
125 ...(resetsAt ? { resetsAt } : {}),
126 };
127}
128
129/**
130 * Utilization is a 0–1 fraction on the streamed event. An overage-included
131 * event before any probe has named the bucket is dropped: guessing a name
132 * would draw a row the next probe cannot reconcile.
133 */
134export function claudeRateLimitEventToUpdate(
135 info: SDKRateLimitInfo,
136 names: ClaudeScopedLimitNames,
137): ProviderUsageLimitsUpdate | undefined {
138 const type: string | undefined = info.rateLimitType;
139 if (!type || typeof info.utilization !== "number") {
140 return undefined;
141 }
142 const usedPercent = info.utilization * 100;
143 const resetsAt = isoFromEpochSeconds(info.resetsAt);
144 if (type in WINDOWS) {
145 return { windows: [makeWindow(type, usedPercent, resetsAt)] };
146 }
147 if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) {
148 return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] };
149 }
150 return undefined;
151}
152
153/**
154 * Percentages on the `get_usage` response are already 0–100. Also yields the
155 * scoped-bucket names the response carried, for the event mapper to reuse.
156 */
157export function claudeUsageResponseToLimits(input: {
158 readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
159 readonly checkedAt: string;
160}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
161 const { response, checkedAt } = input;
162 if (!response.rate_limits_available || !response.rate_limits) {
163 return {
164 limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }),
165 names: { overageIncluded: undefined },
166 };
167 }
168 const windows: ServerProviderUsageWindow[] = [];
169 for (const id of Object.keys(WINDOWS)) {
170 const window = response.rate_limits[id as "five_hour" | "seven_day"];
171 if (!window || typeof window.utilization !== "number") continue;
172 windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at)));
173 }
174 // The CLI filters `model_scoped` to the overage-included allowlist, which
175 // today holds one model; the first entry is the one the event refers to.
176 let overageIncluded: string | undefined;
177 for (const entry of readModelScoped(response.rate_limits)) {
178 if (typeof entry.utilization !== "number") continue;
179 windows.push(
180 scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)),
181 );
182 // Only a bucket that drew a row may receive events; naming one that was
183 // skipped would let a mid-turn event open a row the probe never showed.
184 overageIncluded ??= entry.display_name;
185 }
186 return {
187 limits: makeUsageLimits({ checkedAt, windows }),
188 names: { overageIncluded },
189 };
190}
191
192/** Probe-side helper: map the response and remember the scoped names for events. */
193export const recordClaudeUsageResponse = (
194 namesRef: Ref.Ref<ClaudeScopedLimitNames>,
195 input: Parameters<typeof claudeUsageResponseToLimits>[0],
196): Effect.Effect<ServerProviderUsageLimits> => {
197 const { limits, names } = claudeUsageResponseToLimits(input);
198 return Ref.set(namesRef, names).pipe(Effect.as(limits));
199};
apps/server/src/provider/codexLaunchArgs.test.ts created+59
......@@ -0,0 +1,59 @@
1import * as NodeAssert from "node:assert/strict";
2
3import { describe, it } from "vite-plus/test";
4
5import {
6 codexAppServerArgs,
7 codexExecLaunchArgs,
8 resolveCodexLaunchArgs,
9} from "./codexLaunchArgs.ts";
10
11describe("resolveCodexLaunchArgs", () => {
12 it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => {
13 NodeAssert.equal(
14 resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }),
15 "--enable foo",
16 );
17 });
18
19 it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => {
20 NodeAssert.equal(
21 resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }),
22 "--strict-config",
23 );
24 });
25
26 it("ignores whitespace-only environment values", () => {
27 NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), "");
28 });
29});
30
31describe("codexAppServerArgs", () => {
32 it("returns the app-server command for empty launch args", () => {
33 NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]);
34 });
35
36 it("appends parsed launch args after app-server", () => {
37 NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [
38 "app-server",
39 "--strict-config",
40 "--enable",
41 "foo",
42 ]);
43 });
44});
45
46describe("codexExecLaunchArgs", () => {
47 it("keeps shared codex flags and omits app-server-only flags", () => {
48 NodeAssert.deepStrictEqual(
49 codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'),
50 ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"],
51 );
52 });
53
54 it("does not pair value-taking flags with adjacent flags", () => {
55 NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [
56 "--strict-config",
57 ]);
58 });
59});
apps/server/src/provider/codexLaunchArgs.ts created+39
......@@ -0,0 +1,39 @@
1import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
2
3const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS";
4
5export const resolveCodexLaunchArgs = (
6 launchArgs?: string,
7 environment: NodeJS.ProcessEnv = process.env,
8) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || "";
9
10const codexLaunchArgv = (launchArgs?: string): ReadonlyArray<string> => tokenizeCliArgs(launchArgs);
11
12export const codexAppServerArgs = (launchArgs?: string) => [
13 "app-server",
14 ...codexLaunchArgv(launchArgs),
15];
16
17export const codexExecLaunchArgs = (launchArgs?: string) => {
18 const args = codexLaunchArgv(launchArgs);
19 const execArgs: Array<string> = [];
20
21 for (let index = 0; index < args.length; index++) {
22 const arg = args[index];
23 if (arg === undefined) continue;
24
25 if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) {
26 execArgs.push(arg);
27 } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") {
28 const value = args[index + 1];
29 if (value !== undefined && !value.startsWith("-")) {
30 execArgs.push(arg, value);
31 index++;
32 }
33 } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) {
34 execArgs.push(arg);
35 }
36 }
37
38 return execArgs;
39};
apps/server/src/provider/codexUsageLimits.test.ts created+316
......@@ -0,0 +1,316 @@
1import * as CodexErrors from "effect-codex-app-server/errors";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 codexRateLimitsFailureMessage,
6 codexRateLimitsToLimits,
7 codexRateLimitsToUpdate,
8 codexResetCreditsToContract,
9 codexUsageLimitMessage,
10 mergeCodexRateLimits,
11} from "./codexUsageLimits.ts";
12
13const checkedAt = "2026-07-18T10:00:00.000Z";
14
15describe("codexRateLimitsToLimits", () => {
16 it("maps primary and secondary onto the session and weekly windows", () => {
17 expect(
18 codexRateLimitsToLimits({
19 checkedAt,
20 snapshot: {
21 planType: "plus",
22 primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 },
23 secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 },
24 },
25 }),
26 ).toEqual({
27 checkedAt,
28 windows: [
29 {
30 id: "primary",
31 kind: "session",
32 label: "Session",
33 usedPercent: 12,
34 windowDurationMins: 300,
35 resetsAt: "2026-07-14T03:33:20.000Z",
36 },
37 {
38 id: "secondary",
39 kind: "weekly",
40 label: "Weekly",
41 usedPercent: 47,
42 windowDurationMins: 10080,
43 resetsAt: "2026-07-19T22:26:40.000Z",
44 },
45 ],
46 });
47 });
48
49 it("treats a lone duration-less primary as monthly on Free and Go", () => {
50 expect(
51 codexRateLimitsToLimits({
52 checkedAt,
53 snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } },
54 }).windows,
55 ).toEqual([
56 {
57 id: "primary",
58 kind: "monthly",
59 label: "Monthly",
60 usedPercent: 80,
61 windowDurationMins: 43_200,
62 },
63 ]);
64 });
65
66 it("selects the main Codex allowance and leaves Spark out", () => {
67 const spark = {
68 limitId: "codex_bengalfox",
69 primary: { usedPercent: 0, windowDurationMins: 300 },
70 secondary: { usedPercent: 90, windowDurationMins: 10080 },
71 };
72 expect(
73 codexRateLimitsToLimits({
74 checkedAt,
75 snapshot: spark,
76 rateLimitsByLimitId: {
77 codex_bengalfox: spark,
78 codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } },
79 },
80 }).windows,
81 ).toEqual([
82 {
83 id: "secondary",
84 kind: "weekly",
85 label: "Weekly",
86 usedPercent: 42,
87 windowDurationMins: 10080,
88 },
89 ]);
90 });
91
92 it.each([undefined, null, {}])(
93 "supports legacy reads with no bucket map: %j",
94 (rateLimitsByLimitId) => {
95 const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } };
96 expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual(
97 codexRateLimitsToLimits({ checkedAt, snapshot }),
98 );
99 },
100 );
101
102 it("does not show a model-specific legacy snapshot as the main allowance", () => {
103 expect(
104 codexRateLimitsToLimits({
105 checkedAt,
106 snapshot: {
107 limitId: "codex_bengalfox",
108 secondary: { usedPercent: 90 },
109 },
110 }).windows,
111 ).toEqual([]);
112 });
113});
114
115describe("codexRateLimitsToUpdate", () => {
116 it("carries only the windows the notification names", () => {
117 expect(
118 codexRateLimitsToUpdate({
119 secondary: { usedPercent: 51, windowDurationMins: 10080 },
120 }),
121 ).toEqual({
122 windows: [
123 {
124 id: "secondary",
125 kind: "weekly",
126 label: "Weekly",
127 usedPercent: 51,
128 windowDurationMins: 10080,
129 },
130 ],
131 });
132 expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined();
133 });
134
135 it("ignores Spark notifications so they cannot overwrite the main allowance", () => {
136 expect(
137 codexRateLimitsToUpdate({
138 limitId: "codex_bengalfox",
139 primary: { usedPercent: 0, windowDurationMins: 300 },
140 secondary: { usedPercent: 90, windowDurationMins: 10080 },
141 }),
142 ).toBeUndefined();
143 expect(
144 codexRateLimitsToUpdate({
145 limitId: "codex",
146 secondary: { usedPercent: 42, windowDurationMins: 10080 },
147 })?.windows,
148 ).toEqual([
149 {
150 id: "secondary",
151 kind: "weekly",
152 label: "Weekly",
153 usedPercent: 42,
154 windowDurationMins: 10080,
155 },
156 ]);
157 });
158});
159
160describe("codexRateLimitsFailureMessage", () => {
161 it("keeps the JSON-RPC code and nothing else from a request failure", () => {
162 expect(
163 codexRateLimitsFailureMessage(
164 new CodexErrors.CodexAppServerRequestError({
165 code: -32603,
166 errorMessage:
167 "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized",
168 }),
169 ),
170 ).toBe("Codex could not read usage (JSON-RPC -32603).");
171 });
172
173 it("phrases a dead process differently from a bad answer", () => {
174 expect(
175 codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })),
176 ).toBe("Codex exited before it could report usage.");
177 });
178});
179
180describe("codexResetCreditsToContract", () => {
181 it("counts available credits and reports the soonest expiry", () => {
182 expect(
183 codexResetCreditsToContract({
184 availableCount: 2,
185 credits: [
186 { status: "available", expiresAt: 1_784_500_000 },
187 { status: "redeemed", expiresAt: 1_700_000_000 },
188 { status: "available", expiresAt: 1_784_000_000 },
189 ],
190 }),
191 ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" });
192 expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 });
193 expect(codexResetCreditsToContract(null)).toBeUndefined();
194 });
195
196 it("rides along on the probe's limits", () => {
197 expect(
198 codexRateLimitsToLimits({
199 checkedAt,
200 snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } },
201 resetCredits: { availableCount: 1 },
202 }).resetCredits,
203 ).toEqual({ availableCount: 1 });
204 });
205});
206
207describe("codexUsageLimitMessage", () => {
208 const at = "2026-01-01T00:00:00.000Z";
209 const atSeconds = Date.parse(at) / 1000;
210
211 it("names the exhausted window and the workspace's missing credits", () => {
212 expect(
213 codexUsageLimitMessage(
214 {
215 limitId: "codex",
216 rateLimitReachedType: "workspace_owner_credits_depleted",
217 primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 },
218 secondary: {
219 usedPercent: 100,
220 resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600,
221 windowDurationMins: 10_080,
222 },
223 },
224 at,
225 ),
226 ).toBe(
227 "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.",
228 );
229 });
230
231 it("points a reached spend cap at the workspace owner", () => {
232 expect(
233 codexUsageLimitMessage(
234 {
235 limitId: "codex",
236 rateLimitReachedType: "workspace_member_usage_limit_reached",
237 primary: {
238 usedPercent: 100,
239 resetsAt: atSeconds + 3 * 3_600 + 20 * 60,
240 windowDurationMins: 300,
241 },
242 },
243 at,
244 ),
245 ).toBe(
246 "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.",
247 );
248 });
249
250 it("names no window when credits run out without one", () => {
251 expect(
252 codexUsageLimitMessage(
253 { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" },
254 at,
255 ),
256 ).toBe(
257 "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.",
258 );
259 });
260
261 it("says only what it knows without a snapshot", () => {
262 expect(codexUsageLimitMessage(undefined, at)).toBe(
263 "Codex usage limit reached. Send the message again once the limit resets.",
264 );
265 });
266});
267
268describe("mergeCodexRateLimits", () => {
269 it("keeps windows an update does not carry", () => {
270 const merged = mergeCodexRateLimits(
271 {
272 limitId: "codex",
273 planType: "business",
274 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
275 },
276 { rateLimitReachedType: "rate_limit_reached" },
277 );
278
279 expect(merged).toEqual({
280 limitId: "codex",
281 planType: "business",
282 rateLimitReachedType: "rate_limit_reached",
283 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
284 });
285 });
286
287 it("ignores a model-specific snapshot so it cannot replace the main allowance", () => {
288 const main = {
289 limitId: "codex",
290 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
291 };
292 expect(
293 mergeCodexRateLimits(main, {
294 limitId: "spark",
295 primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 },
296 }),
297 ).toBe(main);
298 });
299});
300
301import { codexUsageLimitResetAt } from "./codexUsageLimits.ts";
302
303describe("codexUsageLimitResetAt", () => {
304 it("waits for every exhausted window and never invents an unknown reset", () => {
305 expect(
306 codexUsageLimitResetAt({
307 primary: { usedPercent: 100, resetsAt: 2000000000 },
308 secondary: { usedPercent: 100, resetsAt: 2000100000 },
309 }),
310 ).toBe("2033-05-19T07:20:00.000Z");
311 expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull();
312 expect(
313 codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }),
314 ).toBeNull();
315 });
316});
apps/server/src/provider/codexUsageLimits.ts created+250
......@@ -0,0 +1,250 @@
1/**
2 * Codex subscription usage. The `account/rateLimits/read` response and the
3 * `account/rateLimits/updated` notification carry the same snapshot shape, so
4 * one mapper serves the status probe and the turn-driven update; both emit
5 * windows with the same ids so they merge onto the same rows.
6 *
7 * @module provider/codexUsageLimits
8 */
9import type {
10 ProviderUsageLimitsUpdate,
11 ServerProviderResetCredits,
12 ServerProviderUsageLimits,
13 ServerProviderUsageWindow,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import * as Option from "effect/Option";
17import type * as CodexErrors from "effect-codex-app-server/errors";
18
19import { clampPercent, makeUsageLimits } from "./providerUsageLimits.ts";
20
21interface CodexRateLimitWindow {
22 readonly usedPercent: number;
23 readonly resetsAt?: number | null;
24 readonly windowDurationMins?: number | null;
25}
26
27/** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */
28export interface CodexRateLimitSnapshot {
29 readonly limitId?: string | null;
30 readonly planType?: string | null;
31 readonly rateLimitReachedType?: string | null;
32 readonly primary?: CodexRateLimitWindow | null;
33 readonly secondary?: CodexRateLimitWindow | null;
34}
35
36/** Structural view of the read response's `rateLimitResetCredits`. */
37export interface CodexResetCreditsSummary {
38 readonly availableCount: number;
39 readonly credits?: ReadonlyArray<{
40 readonly status: string;
41 readonly expiresAt?: number | null;
42 }> | null;
43}
44
45const SESSION_MINS = 5 * 60;
46const WEEK_MINS = 7 * 24 * 60;
47const MONTH_MINS = 30 * 24 * 60;
48
49function isoFromEpochSeconds(value: number | null | undefined): string | undefined {
50 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
51 const dt = DateTime.make(value * 1000);
52 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
53}
54
55function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] {
56 if (mins >= MONTH_MINS) return "monthly";
57 if (mins >= WEEK_MINS) return "weekly";
58 return "session";
59}
60
61function labelForKind(kind: ServerProviderUsageWindow["kind"]): string {
62 return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly";
63}
64
65/**
66 * `primary` / `secondary` are positions, not durations. Codex usually sends
67 * `windowDurationMins`; when it does not, paid plans expose the 5-hour and
68 * weekly pair and Free/Go expose one monthly allowance.
69 */
70function codexRateLimitsToWindows(
71 snapshot: CodexRateLimitSnapshot,
72): ReadonlyArray<ServerProviderUsageWindow> {
73 // Show the main allowance only. Model-specific notifications (such as Spark)
74 // must not replace its primary/secondary rows. Older CLIs omit the limit id.
75 if (snapshot.limitId && snapshot.limitId !== "codex") return [];
76 const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go";
77 const positions = [
78 ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS],
79 ["secondary", snapshot.secondary, WEEK_MINS],
80 ] as const;
81 const windows: ServerProviderUsageWindow[] = [];
82 for (const [id, window, fallbackMins] of positions) {
83 if (!window || !Number.isFinite(window.usedPercent)) continue;
84 const windowDurationMins =
85 typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins;
86 const kind = kindForDuration(windowDurationMins);
87 const resetsAt = isoFromEpochSeconds(window.resetsAt);
88 windows.push({
89 id,
90 kind,
91 label: labelForKind(kind),
92 usedPercent: clampPercent(window.usedPercent),
93 windowDurationMins,
94 ...(resetsAt ? { resetsAt } : {}),
95 });
96 }
97 return windows;
98}
99
100export function codexResetCreditsToContract(
101 summary: CodexResetCreditsSummary | null | undefined,
102): ServerProviderResetCredits | undefined {
103 if (!summary) return undefined;
104 const expiries = (summary.credits ?? [])
105 .filter((credit) => credit.status === "available")
106 .map((credit) => credit.expiresAt)
107 .filter((value): value is number => typeof value === "number");
108 const nextExpiresAt =
109 expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined;
110 return {
111 availableCount: Math.max(0, summary.availableCount),
112 ...(nextExpiresAt ? { nextExpiresAt } : {}),
113 };
114}
115
116export function codexRateLimitsToLimits(input: {
117 readonly snapshot: CodexRateLimitSnapshot;
118 readonly rateLimitsByLimitId?:
119 | Readonly<Record<string, CodexRateLimitSnapshot>>
120 | null
121 | undefined;
122 readonly resetCredits?: CodexResetCreditsSummary | null | undefined;
123 readonly checkedAt: string;
124}): ServerProviderUsageLimits {
125 const resetCredits = codexResetCreditsToContract(input.resetCredits);
126 // Select the main bucket explicitly; the legacy snapshot can name another limit.
127 const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot);
128 return {
129 ...makeUsageLimits({
130 checkedAt: input.checkedAt,
131 windows,
132 }),
133 ...(resetCredits ? { resetCredits } : {}),
134 };
135}
136
137export function codexRateLimitsToUpdate(
138 snapshot: CodexRateLimitSnapshot,
139): ProviderUsageLimitsUpdate | undefined {
140 const windows = codexRateLimitsToWindows(snapshot);
141 return windows.length > 0 ? { windows } : undefined;
142}
143
144/**
145 * A bounded, client-safe reason for a failed `account/rateLimits/read`. The
146 * raw error is for the log; only the category and, for a JSON-RPC failure,
147 * the code reach the Limits view.
148 */
149export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string {
150 switch (error._tag) {
151 case "CodexAppServerRequestError":
152 return `Codex could not read usage (JSON-RPC ${error.code}).`;
153 case "CodexAppServerSpawnError":
154 return "Codex could not be started to read usage.";
155 case "CodexAppServerProcessExitedError":
156 return "Codex exited before it could report usage.";
157 default:
158 return "Codex did not answer the usage request.";
159 }
160}
161
162/**
163 * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a
164 * field the update omits keeps the value observed earlier in the session, so a
165 * later notification that only names the limit it reached must not drop the
166 * windows an earlier one carried.
167 */
168export function mergeCodexRateLimits(
169 previous: CodexRateLimitSnapshot | undefined,
170 update: CodexRateLimitSnapshot,
171): CodexRateLimitSnapshot | undefined {
172 // Model-specific snapshots (such as Spark) describe a different allowance
173 // and must not overwrite the main one, the same rule the usage rows apply.
174 if (update.limitId && update.limitId !== "codex") return previous;
175 if (!previous) return update;
176 return {
177 ...previous,
178 ...(update.limitId !== undefined ? { limitId: update.limitId } : {}),
179 ...(update.planType !== undefined ? { planType: update.planType } : {}),
180 ...(update.rateLimitReachedType !== undefined
181 ? { rateLimitReachedType: update.rateLimitReachedType }
182 : {}),
183 ...(update.primary !== undefined ? { primary: update.primary } : {}),
184 ...(update.secondary !== undefined ? { secondary: update.secondary } : {}),
185 };
186}
187
188/** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */
189function formatCodexUsageLimitWait(waitMs: number): string {
190 const totalMinutes = Math.ceil(waitMs / 60_000);
191 const days = Math.floor(totalMinutes / (24 * 60));
192 const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
193 const minutes = totalMinutes % 60;
194 if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`;
195 if (hours === 0) return `${totalMinutes}m`;
196 return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
197}
198
199function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string {
200 switch (rateLimitReachedType) {
201 case "workspace_owner_credits_depleted":
202 case "workspace_member_credits_depleted":
203 return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.";
204 case "workspace_owner_usage_limit_reached":
205 case "workspace_member_usage_limit_reached":
206 return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.";
207 default:
208 return " Send the message again once the limit resets.";
209 }
210}
211
212/**
213 * The message a usage-limit stop shows instead of the provider sentence, which
214 * on a Business workspace blames credits for a window that simply ran out. The
215 * window named is the exhausted one that has yet to reset, latest first; `atIso`
216 * is the stopping event's timestamp, not the wall clock.
217 */
218export function codexUsageLimitMessage(
219 snapshot: CodexRateLimitSnapshot | undefined,
220 atIso: string,
221): string {
222 const atMs = Date.parse(atIso);
223 const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : [];
224 let reset = "";
225 let latestResetMs = Number.NEGATIVE_INFINITY;
226 for (const window of windows) {
227 if (window.usedPercent < 100 || !window.resetsAt) continue;
228 const resetMs = Date.parse(window.resetsAt);
229 if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue;
230 latestResetMs = resetMs;
231 reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`;
232 }
233 return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`;
234}
235
236/** All exhausted windows must reset before a continuation can run. */
237export function codexUsageLimitResetAt(
238 snapshot: CodexRateLimitSnapshot | undefined,
239): string | null {
240 if (!snapshot) return null;
241 const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100);
242 if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null;
243 return windows.reduce<string | null>(
244 (latest, window) =>
245 latest === null || Date.parse(window.resetsAt!) > Date.parse(latest)
246 ? window.resetsAt!
247 : latest,
248 null,
249 );
250}
apps/server/src/provider/cursorUsageLimits.test.ts created+264
......@@ -0,0 +1,264 @@
1import { describe, expect, it } from "@effect/vitest";
2import { NodeServices } from "@effect/platform-node";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { HttpClient, HttpClientResponse } from "effect/http";
8import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts";
9
10const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
11 effect.pipe(Effect.provide(NodeServices.layer));
12
13describe("Cursor usage limits", () => {
14 const checkedAt = "2026-09-16T00:00:00.000Z";
15
16 it("uses the advertised percentages and billing-cycle reset", () => {
17 const limits = cursorUsageResponseToLimits(
18 {
19 billingCycleEnd: "1789876386000",
20 planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 },
21 },
22 checkedAt,
23 );
24 expect(limits.windows).toEqual(
25 expect.arrayContaining([
26 {
27 id: "totalPercentUsed",
28 kind: "monthly",
29 label: "Overall",
30 usedPercent: 72.4,
31 resetsAt: "2026-09-20T03:53:06.000Z",
32 },
33 {
34 id: "autoPercentUsed",
35 kind: "monthly",
36 label: "Cursor Models",
37 usedPercent: 69.5,
38 resetsAt: "2026-09-20T03:53:06.000Z",
39 },
40 {
41 id: "apiPercentUsed",
42 kind: "monthly",
43 label: "Other Models",
44 usedPercent: 100,
45 resetsAt: "2026-09-20T03:53:06.000Z",
46 },
47 ]),
48 );
49 });
50
51 it("does not invent unused allowance for absent buckets", () => {
52 expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe(
53 "unsupported",
54 );
55 expect(
56 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,
57 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]);
58 expect(
59 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,
60 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]);
61 });
62
63 it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () =>
64 Effect.gen(function* () {
65 yield* withNodeServices(
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const directory = yield* fs.makeTempDirectoryScoped();
70 yield* fs.makeDirectory(path.join(directory, "cursor"));
71 yield* fs.writeFileString(
72 path.join(directory, "cursor", "auth.json"),
73 '{"accessToken":"instance-token"}',
74 );
75 const client = HttpClient.make((request) => {
76 expect(request.url).toBe(
77 "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
78 );
79 expect(request.method).toBe("POST");
80 expect(request.headers.authorization).toBe("Bearer instance-token");
81 expect(request.headers["connect-protocol-version"]).toBe("1");
82 return Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }),
86 ),
87 );
88 });
89 yield* fs.makeDirectory(path.join(directory, ".cursor"));
90 yield* fs.writeFileString(
91 path.join(directory, ".cursor", "auth.json"),
92 '{"accessToken":"instance-token"}',
93 );
94 for (const platform of ["linux", "darwin"] as const) {
95 const limits = yield* readCursorUsageLimits(
96 { apiEndpoint: "https://cursor.example/" },
97 { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" },
98 ).pipe(
99 Effect.provideService(HostProcessPlatform, platform),
100 Effect.provideService(HttpClient.HttpClient, client),
101 );
102 expect(limits.windows[0]?.usedPercent).toBe(42);
103 }
104 }).pipe(Effect.scoped),
105 );
106 }),
107 );
108
109 it.effect(
110 "never reads stale files for keychain or memory logins, but accepts an explicit auth token",
111 () =>
112 Effect.gen(function* () {
113 for (const platform of ["linux", "darwin"] as const) {
114 for (const token of [undefined, "explicit-token"]) {
115 const limits = yield* withNodeServices(
116 readCursorUsageLimits(
117 { apiEndpoint: "" },
118 {
119 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",
120 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
121 },
122 false,
123 async () => {
124 throw new Error("must not read Keychain before opt-in");
125 },
126 ).pipe(
127 Effect.provideService(HostProcessPlatform, platform),
128 Effect.provideService(
129 FileSystem.FileSystem,
130 FileSystem.makeNoop({
131 readFileString: () => Effect.die("must not read an unrelated credential file"),
132 }),
133 ),
134 Effect.provideService(
135 HttpClient.HttpClient,
136 HttpClient.make((request) => {
137 expect(token).toBe("explicit-token");
138 expect(request.headers.authorization).toBe("Bearer explicit-token");
139 return Effect.succeed(
140 HttpClientResponse.fromWeb(
141 request,
142 Response.json({ planUsage: { totalPercentUsed: 10 } }),
143 ),
144 );
145 }),
146 ),
147 ),
148 );
149 if (token) expect(limits.windows[0]?.usedPercent).toBe(10);
150 else expect(limits.unavailable?.reason).toBe("unsupported");
151 }
152 }
153 }),
154 );
155
156 it.effect("reports failed requests without exposing credentials or response bodies", () =>
157 Effect.gen(function* () {
158 const limits = yield* withNodeServices(
159 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(
160 Effect.provideService(
161 HttpClient.HttpClient,
162 HttpClient.make((request) =>
163 Effect.succeed(
164 HttpClientResponse.fromWeb(
165 request,
166 new Response("private response", { status: 401 }),
167 ),
168 ),
169 ),
170 ),
171 ),
172 );
173 expect(limits.unavailable).toEqual({
174 reason: "probeFailed",
175 message: "Cursor could not read usage limits.",
176 });
177 }),
178 );
179
180 it.effect("does not use a stored login for an explicit API key", () =>
181 Effect.gen(function* () {
182 const limits = yield* withNodeServices(
183 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe(
184 Effect.provideService(
185 HttpClient.HttpClient,
186 HttpClient.make(() => Effect.die("must not request usage")),
187 ),
188 ),
189 );
190 expect(limits.unavailable?.reason).toBe("unsupported");
191 }),
192 );
193
194 it.effect("reads the default macOS Cursor login from Keychain for limits", () =>
195 Effect.gen(function* () {
196 const limits = yield* withNodeServices(
197 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe(
198 Effect.provideService(HostProcessPlatform, "darwin"),
199 Effect.provideService(
200 FileSystem.FileSystem,
201 FileSystem.makeNoop({
202 readFileString: () => Effect.die("must not read a stale credential file"),
203 }),
204 ),
205 Effect.provideService(
206 HttpClient.HttpClient,
207 HttpClient.make((request) => {
208 expect(request.headers.authorization).toBe("Bearer keychain-token");
209 return Effect.succeed(
210 HttpClientResponse.fromWeb(
211 request,
212 Response.json({ planUsage: { totalPercentUsed: 42 } }),
213 ),
214 );
215 }),
216 ),
217 ),
218 );
219 expect(limits.windows[0]?.usedPercent).toBe(42);
220 }),
221 );
222
223 it.effect("reports a Keychain initialization failure without failing the provider refresh", () =>
224 Effect.gen(function* () {
225 const limits = yield* withNodeServices(
226 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => {
227 throw new Error("Keychain initialization failed");
228 }).pipe(
229 Effect.provideService(HostProcessPlatform, "darwin"),
230 Effect.provideService(
231 HttpClient.HttpClient,
232 HttpClient.make(() => Effect.die("must not request limits without a login")),
233 ),
234 ),
235 );
236 expect(limits.unavailable?.reason).toBe("probeFailed");
237 }),
238 );
239
240 it.effect("does not read Keychain or send its token to a custom endpoint", () =>
241 Effect.gen(function* () {
242 for (const [apiEndpoint, environment] of [
243 ["http://localhost:3000", {}],
244 ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }],
245 ["https://cursor-proxy.example", {}],
246 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],
247 ] as const) {
248 const limits = yield* withNodeServices(
249 readCursorUsageLimits({ apiEndpoint }, environment, true, async () => {
250 throw new Error("must not read Keychain for a custom endpoint");
251 }).pipe(
252 Effect.provideService(HostProcessPlatform, "darwin"),
253 Effect.provideService(
254 HttpClient.HttpClient,
255 HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")),
256 ),
257 ),
258 );
259 expect(limits.unavailable?.reason).toBe("unsupported");
260 expect(limits.unavailable?.message).toContain("default Cursor endpoint");
261 }
262 }),
263 );
264});
apps/server/src/provider/cursorUsageLimits.ts created+151
......@@ -0,0 +1,151 @@
1import * as NodeOS from "node:os";
2import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12import {
13 clampPercent,
14 makeUnavailableUsageLimits,
15 makeUsageLimits,
16} from "./providerUsageLimits.ts";
17import { readMacCursorAccessToken } from "./cursorKeychainToken.ts";
18
19const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
20const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
21const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
22const CursorUsageResponse = Schema.Struct({
23 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
24 planUsage: Schema.optional(
25 Schema.Struct({
26 totalPercentUsed: Schema.optional(Schema.Number),
27 autoPercentUsed: Schema.optional(Schema.Number),
28 apiPercentUsed: Schema.optional(Schema.Number),
29 }),
30 ),
31});
32
33/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */
34export function cursorUsageResponseToLimits(
35 response: typeof CursorUsageResponse.Type,
36 checkedAt: string,
37) {
38 const reset = DateTime.make(Number(response.billingCycleEnd));
39 const resetsAt =
40 Number(response.billingCycleEnd) > 0 && Option.isSome(reset)
41 ? DateTime.formatIso(reset.value)
42 : undefined;
43 const windows: ServerProviderUsageWindow[] = [];
44 if (response.planUsage) {
45 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
46 const usedPercent = response.planUsage[id];
47 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
48 windows.push({
49 id,
50 kind: "monthly",
51 label,
52 usedPercent: clampPercent(usedPercent),
53 ...(resetsAt ? { resetsAt } : {}),
54 });
55 }
56 }
57 return windows.length > 0
58 ? makeUsageLimits({ checkedAt, windows })
59 : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
60}
61
62export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
63 settings: Pick<CursorSettings, "apiEndpoint">,
64 environment: NodeJS.ProcessEnv = process.env,
65 allowKeychain = false,
66 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
67) {
68 const checkedAt = DateTime.formatIso(yield* DateTime.now);
69 return yield* Effect.gen(function* () {
70 const fs = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
72 const platform = yield* HostProcessPlatform;
73 const endpoint = (
74 settings.apiEndpoint?.trim() ||
75 environment.CURSOR_API_ENDPOINT?.trim() ||
76 DEFAULT_CURSOR_API_ENDPOINT
77 ).replace(/\/$/, "");
78 let token = environment.CURSOR_AUTH_TOKEN?.trim();
79 // An explicit API key can name a different account from the stored login.
80 if (!token && environment.CURSOR_API_KEY?.trim()) {
81 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
82 }
83 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
84 if (!token && credentialStore === "memory") {
85 return makeUnavailableUsageLimits({
86 checkedAt,
87 reason: "unsupported",
88 message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.",
89 });
90 }
91 if (!token && platform === "darwin" && credentialStore !== "file") {
92 if (!allowKeychain) {
93 return makeUnavailableUsageLimits({
94 checkedAt,
95 reason: "unsupported",
96 message: "Enable Cursor account usage in T3 Code to read its Keychain login.",
97 });
98 }
99 if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) {
100 return makeUnavailableUsageLimits({
101 checkedAt,
102 reason: "unsupported",
103 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
104 });
105 }
106 token = (yield* Effect.tryPromise(keychainToken))?.trim();
107 } else if (!token) {
108 const home =
109 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();
110 const directory =
111 platform === "win32"
112 ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor")
113 : platform === "darwin"
114 ? path.join(home, ".cursor")
115 : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor");
116 const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe(
117 Effect.catchTags({
118 PlatformError: (error) =>
119 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
120 }),
121 Effect.flatMap(decodeCredentials),
122 );
123 token = credentials.accessToken?.trim();
124 }
125 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
126 const client = yield* HttpClient.HttpClient;
127 const response = yield* client.execute(
128 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
129 HttpClientRequest.bearerToken(token),
130 HttpClientRequest.setHeaders({
131 "connect-protocol-version": "1",
132 "x-cursor-client-type": "cli",
133 }),
134 HttpClientRequest.bodyJsonUnsafe({}),
135 ),
136 );
137 const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)(
138 yield* HttpClientResponse.filterStatusOk(response),
139 );
140 return cursorUsageResponseToLimits(body, checkedAt);
141 }).pipe(
142 Effect.timeout("10 seconds"),
143 Effect.orElseSucceed(() =>
144 makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Cursor could not read usage limits.",
148 }),
149 ),
150 );
151});
apps/server/src/provider/grokUsageLimits.ts created+175
......@@ -0,0 +1,175 @@
1import * as NodeOS from "node:os";
2import type { ServerProviderUsageWindow } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
10import {
11 clampPercent,
12 makeUnavailableUsageLimits,
13 makeUsageLimits,
14} from "./providerUsageLimits.ts";
15
16const GrokCredentials = Schema.Record(
17 Schema.String,
18 Schema.Struct({
19 key: Schema.optional(Schema.String),
20 auth_mode: Schema.optional(Schema.String),
21 email: Schema.optional(Schema.String),
22 }),
23);
24const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials));
25const GrokUsageResponse = Schema.Struct({
26 config: Schema.optional(
27 Schema.Struct({
28 creditUsagePercent: Schema.optional(Schema.Number),
29 currentPeriod: Schema.optional(
30 Schema.Struct({
31 type: Schema.optional(Schema.String),
32 end: Schema.optional(Schema.String),
33 }),
34 ),
35 }),
36 ),
37});
38
39export function grokUsageResponseToLimits(
40 response: typeof GrokUsageResponse.Type,
41 checkedAt: string,
42) {
43 const usedPercent = response.config?.creditUsagePercent;
44 if (usedPercent === undefined || !Number.isFinite(usedPercent)) {
45 // A billing read that succeeded but carries no percentage is an account
46 // with nothing metered yet, not one that can never report: xAI omits the
47 // field entirely (rather than sending 0) until usage registers, then fills
48 // it in. Calling that `unsupported` would strand the account — the Limits
49 // view drops unsupported entries and deliberately mutes their notice, so a
50 // freshly signed-in Grok account would vanish with no explanation until it
51 // happened to be used, and `applyUsageLimitsUpdate` would refuse the
52 // mid-turn windows that could have recovered it.
53 return makeUsageLimits({ checkedAt, windows: [] });
54 }
55 const period = response.config?.currentPeriod;
56 const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, "");
57 const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other";
58 const reset = period?.end ? DateTime.make(period.end) : Option.none();
59 const window: ServerProviderUsageWindow = {
60 id: "subscription",
61 kind,
62 label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription",
63 usedPercent: clampPercent(usedPercent),
64 ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}),
65 };
66 return makeUsageLimits({ checkedAt, windows: [window] });
67}
68
69/**
70 * The grok.com login the CLI uses by default, or undefined when the CLI is
71 * configured to pick another account, endpoint, or an API key.
72 */
73const readGrokCredential = Effect.fn("readGrokCredential")(function* (
74 environment: NodeJS.ProcessEnv,
75) {
76 // T3's ACP adapter explicitly selects API-key auth when this variable is set.
77 if (environment.XAI_API_KEY?.trim()) return undefined;
78 // Alternate auth deployments can select another scope or account from the same file.
79 if (
80 [
81 "GROK_OIDC_ISSUER",
82 "GROK_OIDC_CLIENT_ID",
83 "GROK_OAUTH2_ISSUER",
84 "GROK_OAUTH2_CLIENT_ID",
85 "GROK_OAUTH2_PRINCIPAL_TYPE",
86 "GROK_OAUTH2_PRINCIPAL_ID",
87 "GROK_AUTH_PROVIDER_COMMAND",
88 "GROK_LOCAL_AUTH",
89 "GROK_CLI_CHAT_PROXY_BASE_URL",
90 "GROK_MODELS_BASE_URL",
91 "GROK_CONFIG",
92 "GROK_CONFIG_PATH",
93 ].some((name) => environment[name]?.trim())
94 ) {
95 return undefined;
96 }
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const home =
100 environment.GROK_HOME?.trim() ||
101 path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok");
102 for (const configPath of [
103 path.join(home, "config.toml"),
104 path.join(home, "managed_config.toml"),
105 path.join(home, "requirements.toml"),
106 "/etc/grok/managed_config.toml",
107 "/etc/grok/requirements.toml",
108 ]) {
109 const config = yield* fs.readFileString(configPath).pipe(
110 Effect.catchTags({
111 PlatformError: (error) =>
112 error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
113 }),
114 );
115 // These sections can change the selected account or endpoint. Leave custom deployments to the CLI.
116 if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) {
117 return undefined;
118 }
119 }
120 const contents =
121 environment.GROK_AUTH?.trim() ||
122 (yield* fs.readFileString(path.join(home, "auth.json")).pipe(
123 Effect.catchTags({
124 PlatformError: (error) =>
125 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
126 }),
127 ));
128 const credentials = yield* decodeCredentials(contents);
129 // Never pick an arbitrary account from other deployments stored in the same file.
130 const credential =
131 credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ??
132 credentials["https://accounts.x.ai/sign-in"];
133 return credential?.auth_mode === "api_key" ? undefined : credential;
134});
135
136/**
137 * Reads the default grok.com login once and reports its usage limits along with
138 * its email, so the email always names the account whose quota was read.
139 */
140export const readGrokAccount = Effect.fn("readGrokAccount")(function* (
141 environment: NodeJS.ProcessEnv = process.env,
142) {
143 const checkedAt = DateTime.formatIso(yield* DateTime.now);
144 const probeFailed = makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Grok could not read usage limits.",
148 });
149 const credential = yield* Effect.option(
150 readGrokCredential(environment).pipe(Effect.timeout("10 seconds")),
151 );
152 if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed };
153 const email = credential.value?.email?.trim() || undefined;
154 const token = credential.value?.key?.trim();
155 if (!token) {
156 return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) };
157 }
158 // A failed quota request still knows which account it asked about.
159 const usageLimits = yield* Effect.gen(function* () {
160 const client = yield* HttpClient.HttpClient;
161 const response = yield* client.execute(
162 HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe(
163 HttpClientRequest.bearerToken(token),
164 ),
165 );
166 const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)(
167 yield* HttpClientResponse.filterStatusOk(response),
168 );
169 return grokUsageResponseToLimits(body, checkedAt);
170 }).pipe(
171 Effect.timeout("10 seconds"),
172 Effect.orElseSucceed(() => probeFailed),
173 );
174 return { email, usageLimits };
175});
apps/server/src/provider/makeManagedServerProvider.ts+1-1
......@@ -18,7 +18,7 @@ import * as Semaphore from "effect/Semaphore";
1818import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
1919import * as ServerSettings from "../serverSettings.ts";
2020import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts";
21import type { ServerProviderShape } from "./Services/ServerProvider.ts";
21import type { ServerProviderShape } from "./ServerProvider.ts";
2222
2323interface ProviderSnapshotState {
2424 readonly snapshot: ServerProvider;
apps/server/src/provider/openCodeUsageLimits.ts created+119
......@@ -0,0 +1,119 @@
1import * as NodeOS from "node:os";
2import * as NodeCrypto from "node:crypto";
3
4import type { ServerProviderUsageWindow } from "@t3tools/contracts";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12
13import {
14 clampPercent,
15 makeUnavailableUsageLimits,
16 makeUsageLimits,
17} from "./providerUsageLimits.ts";
18
19const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
20const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
21const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
22const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
23const UsageWindow = Schema.Struct({
24 percent: Schema.Finite,
25 resetsAt: Schema.DateTimeUtcFromString,
26});
27const UsageResponse = Schema.Struct({
28 usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
29});
30
31/** External OpenCode servers own their credentials; never read the host's account for them. */
32export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
33 readonly enabled: boolean;
34 readonly serverUrl: string;
35 readonly environment: NodeJS.ProcessEnv;
36}) {
37 const checkedAt = DateTime.formatIso(yield* DateTime.now);
38 const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
39 if (!input.enabled || input.serverUrl.trim()) return unsupported;
40
41 return yield* Effect.gen(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const env = input.environment;
45 const dataHome =
46 env.XDG_DATA_HOME ||
47 path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share");
48 const authPath = path.join(dataHome, "opencode", "auth.json");
49 const contents =
50 env.OPENCODE_AUTH_CONTENT ||
51 (yield* fs.readFileString(authPath).pipe(
52 Effect.catchTags({
53 PlatformError: (error) =>
54 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
55 }),
56 ));
57 const auth = yield* decodeAuthFile(contents);
58 const apiAuth = decodeApiAuth(auth["opencode-go"]);
59 // OpenCode overlays stored API credentials after environment credentials.
60 const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
61 if (!apiKey) return unsupported;
62
63 const client = yield* HttpClient.HttpClient;
64 const response = yield* client.execute(
65 HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
66 HttpClientRequest.bearerToken(apiKey),
67 ),
68 );
69 // A valid Zen key can exist without a Go subscription.
70 if (response.status === 403) return unsupported;
71 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
72 Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)),
73 );
74 const windows: ServerProviderUsageWindow[] = [
75 {
76 id: "go_rolling",
77 kind: "session",
78 label: "Go · Session",
79 windowDurationMins: 5 * 60,
80 usedPercent: clampPercent(body.usage.rolling.percent),
81 resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt),
82 },
83 {
84 id: "go_weekly",
85 kind: "weekly",
86 label: "Go · Weekly",
87 windowDurationMins: 7 * 24 * 60,
88 usedPercent: clampPercent(body.usage.weekly.percent),
89 resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt),
90 },
91 {
92 id: "go_monthly",
93 kind: "monthly",
94 label: "Go · Monthly",
95 usedPercent: clampPercent(body.usage.monthly.percent),
96 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
97 },
98 ];
99 return {
100 ...makeUsageLimits({ checkedAt, windows }),
101 // Go's usage response has no account ID. An unkeyed hash matches across
102 // environments without a shared secret. It permits offline guesses, but
103 // Go keys are randomly generated.
104 credentialFingerprint: NodeCrypto.createHash("sha256")
105 .update("opencode-go\0")
106 .update(apiKey)
107 .digest("hex"),
108 };
109 }).pipe(
110 Effect.timeout("5 seconds"),
111 Effect.orElseSucceed(() =>
112 makeUnavailableUsageLimits({
113 checkedAt,
114 reason: "probeFailed",
115 message: "OpenCode Go could not read usage.",
116 }),
117 ),
118 );
119});
apps/server/src/provider/piThinkingCapabilities.test.ts created+52
......@@ -0,0 +1,52 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts";
4
5describe("thinkingCapabilitiesForPiModel", () => {
6 it("gives every choice its real level id and tags Pi's default", () => {
7 const capabilities = thinkingCapabilitiesForPiModel(
8 {
9 reasoning: true,
10 thinkingLevelMap: { off: null, xhigh: "extra_high", max: null },
11 },
12 "xhigh",
13 );
14 const descriptors = capabilities.optionDescriptors ?? [];
15 const thinking = descriptors[0];
16 assert.equal(thinking?.id, "thinking");
17 assert.equal(thinking?.type, "select");
18 if (thinking?.type !== "select") return;
19 assert.deepEqual(
20 thinking.options.map((option) => [option.id, option.label, option.isDefault === true]),
21 [
22 ["minimal", "Minimal", false],
23 ["low", "Low", false],
24 ["medium", "Medium", false],
25 ["high", "High", false],
26 ["xhigh", "Extra High", true],
27 ],
28 );
29 });
30
31 it("clamps Pi's default to each model's supported levels", () => {
32 const capabilities = thinkingCapabilitiesForPiModel(
33 {
34 reasoning: true,
35 thinkingLevelMap: { xhigh: "extra_high", max: null },
36 },
37 "max",
38 );
39 const thinking = capabilities.optionDescriptors?.[0];
40 assert.equal(thinking?.type, "select");
41 if (thinking?.type !== "select") return;
42 assert.deepInclude(thinking.options, {
43 id: "xhigh",
44 label: "Extra High",
45 isDefault: true,
46 });
47 assert.notInclude(
48 thinking.options.map((option) => option.id),
49 "max",
50 );
51 });
52});
apps/server/src/provider/piThinkingCapabilities.ts created+97
......@@ -0,0 +1,97 @@
1import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts";
2import { createModelCapabilities } from "@t3tools/shared/model";
3import * as Predicate from "effect/Predicate";
4
5/**
6 * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per
7 * model via `thinkingLevelMap`; advertising them globally makes
8 * `set_thinking_level` fail on models that lack them.
9 */
10const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
11
12export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number];
13
14const PI_THINKING_LEVEL_LABELS: Record<PiThinkingLevel, string> = {
15 off: "Off",
16 minimal: "Minimal",
17 low: "Low",
18 medium: "Medium",
19 high: "High",
20 xhigh: "Extra High",
21 max: "Max",
22};
23
24export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
25 optionDescriptors: [],
26});
27
28export function thinkingCapabilitiesForPiModel(
29 model: unknown,
30 defaultThinkingLevel: unknown,
31): ModelCapabilities {
32 const levels = supportedPiThinkingLevelsFromModel(model);
33 if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES;
34 const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels);
35 return createModelCapabilities({
36 optionDescriptors: [
37 {
38 id: "thinking",
39 label: "Thinking",
40 type: "select",
41 options: levels.map((level): ProviderOptionChoice => ({
42 id: level,
43 label: PI_THINKING_LEVEL_LABELS[level],
44 ...(level === defaultLevel ? { isDefault: true } : {}),
45 })),
46 },
47 ],
48 });
49}
50
51/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */
52function clampPiThinkingLevel(
53 input: unknown,
54 availableLevels: ReadonlyArray<PiThinkingLevel>,
55): PiThinkingLevel | undefined {
56 if (typeof input !== "string") return undefined;
57 const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input);
58 if (requestedIndex === -1) return undefined;
59 const exact = availableLevels.find((level) => level === input);
60 if (exact !== undefined) return exact;
61 for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) {
62 const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]);
63 if (higher !== undefined) return higher;
64 }
65 for (let index = requestedIndex - 1; index >= 0; index -= 1) {
66 const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]);
67 if (lower !== undefined) return lower;
68 }
69 return availableLevels[0];
70}
71
72/**
73 * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`.
74 *
75 * A reasoning model always exposes off through high unless a map entry is
76 * `null`. Extra High and Max appear only when the map has a non-null entry.
77 */
78function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray<PiThinkingLevel> {
79 if (recordField(model, "reasoning") !== true) return [];
80 const thinkingLevelMap = thinkingLevelMapFromModel(model);
81 return PI_THINKING_LEVELS.filter((level) => {
82 const mapped = thinkingLevelMap?.[level];
83 if (mapped === null) return false;
84 if (level === "xhigh" || level === "max") return mapped !== undefined;
85 return true;
86 });
87}
88
89function thinkingLevelMapFromModel(model: unknown): Record<string, unknown> | undefined {
90 const value = recordField(model, "thinkingLevelMap");
91 if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
92 return value as Record<string, unknown>;
93}
94
95function recordField(input: unknown, key: string): unknown {
96 return Predicate.isObject(input) ? input[key] : undefined;
97}
apps/server/src/provider/providerCompatibility.test.ts+3-4
......@@ -10,9 +10,8 @@ import * as Ref from "effect/Ref";
1010import * as Stream from "effect/Stream";
1111import * as ServerConfig from "../config.ts";
1212import * as ModelManifest from "./ModelManifest.ts";
13import * as ProviderRegistryLayer from "./Layers/ProviderRegistry.ts";
14import * as ProviderRegistry from "./Services/ProviderRegistry.ts";
15import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts";
13import * as ProviderRegistry from "./ProviderRegistry.ts";
14import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
1615import type { ProviderInstance } from "./ProviderDriver.ts";
1716import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
1817import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts";
......@@ -362,7 +361,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist
362361 assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported");
363362 assert.strictEqual(updated?.status, "error");
364363 assert.strictEqual(updated?.message, "Authentication failed");
365 }).pipe(Effect.provide(ProviderRegistryLayer.layer.pipe(Layer.provide(layerDependencies))));
364 }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies))));
366365 }).pipe(Effect.scoped),
367366);
368367
apps/server/src/provider/providerInstallation.test.ts+2-2
......@@ -17,8 +17,8 @@ import * as CodexInstallation from "./CodexInstallation.ts";
1717import * as AntigravityInstallation from "./AntigravityInstallation.ts";
1818import type { ProviderInstance } from "./ProviderDriver.ts";
1919import { makeProviderInstallation } from "./providerInstallation.ts";
20import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts";
21import * as ProviderRegistry from "./Services/ProviderRegistry.ts";
20import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
21import * as ProviderRegistry from "./ProviderRegistry.ts";
2222
2323const instanceId = ProviderInstanceId.make("antigravity");
2424const driver = ProviderDriverKind.make("antigravity");
apps/server/src/provider/providerInstallation.ts+3-3
......@@ -15,9 +15,9 @@ import * as Stream from "effect/Stream";
1515import * as CodexInstallation from "./CodexInstallation.ts";
1616import * as ServerSettings from "../serverSettings.ts";
1717import * as AntigravityInstallation from "./AntigravityInstallation.ts";
18import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts";
19import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts";
20import * as ProviderRegistry from "./Services/ProviderRegistry.ts";
18import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts";
19import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
20import * as ProviderRegistry from "./ProviderRegistry.ts";
2121import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
2222
2323const ANTIGRAVITY = ProviderDriverKind.make("antigravity");
apps/server/src/provider/providerMaintenanceRunner.test.ts+3-3
......@@ -20,7 +20,7 @@ import { ChildProcessSpawner } from "effect/process";
2020import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2121import { SpawnExecutableResolution } from "@t3tools/shared/shell";
2222
23import * as ProviderRegistry from "./Services/ProviderRegistry.ts";
23import * as ProviderRegistry from "./ProviderRegistry.ts";
2424import * as ModelManifest from "./ModelManifest.ts";
2525import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
2626import {
......@@ -190,7 +190,7 @@ function makeRegistry(
190190 );
191191 });
192192
193 const registry: ProviderRegistry.ProviderRegistryShape = {
193 const registry: ProviderRegistry.ProviderRegistry["Service"] = {
194194 getProviders: Ref.get(providersRef),
195195 refresh: () => Ref.get(providersRef),
196196 refreshInstance: () => Ref.get(providersRef),
......@@ -210,7 +210,7 @@ function makeRegistry(
210210}
211211
212212const makeTestRunner = (
213 registry: ProviderRegistry.ProviderRegistryShape,
213 registry: ProviderRegistry.ProviderRegistry["Service"],
214214 // Generic updater fixtures use synthetic versions. Keep their compatibility
215215 // unknown so real harness minimums do not bypass the command under test.
216216 manifest: ModelManifest.ModelManifestData = {
apps/server/src/provider/providerMaintenanceRunner.ts+1-1
......@@ -23,7 +23,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
2323
2424import * as ModelManifest from "./ModelManifest.ts";
2525import { resolveProviderCompatibility } from "./providerCompatibility.ts";
26import * as ProviderRegistry from "./Services/ProviderRegistry.ts";
26import * as ProviderRegistry from "./ProviderRegistry.ts";
2727import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
2828import {
2929 enrichProviderSnapshotWithVersionAdvisory,
apps/server/src/provider/resetCreditCoordinator.test.ts created+105
......@@ -0,0 +1,105 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Ref from "effect/Ref";
6
7import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
8
9describe("ResetCreditCoordinator", () => {
10 it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () =>
11 Effect.gen(function* () {
12 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
13 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
14 const attempts = yield* Ref.make(0);
15 const consume = (key: string) =>
16 Effect.gen(function* () {
17 yield* Ref.update(keys, (seen) => [...seen, key]);
18 const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1);
19 if (attempt === 1) return yield* Effect.fail("timed out" as const);
20 return "reset" as const;
21 });
22
23 const first = yield* redeem("acct", consume).pipe(Effect.result);
24 assert.isTrue(first._tag === "Failure");
25 const second = yield* redeem("acct", consume);
26 assert.strictEqual(second, "reset");
27 // A fresh redemption after success must be a fresh attempt.
28 yield* redeem("acct", consume);
29
30 const seen = yield* Ref.get(keys);
31 assert.strictEqual(seen.length, 3);
32 assert.strictEqual(seen[0], seen[1]);
33 assert.notStrictEqual(seen[1], seen[2]);
34 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
35 );
36
37 it.effect("starts a fresh attempt after a settled failure", () =>
38 Effect.gen(function* () {
39 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
40 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
41 const consume = (key: string) =>
42 Ref.update(keys, (seen) => [...seen, key]).pipe(
43 Effect.andThen(Effect.fail("cooldown" as const)),
44 );
45 const isSettled = (error: "cooldown") => error === "cooldown";
46
47 yield* redeem("acct", consume, isSettled).pipe(Effect.result);
48 yield* redeem("acct", consume, isSettled).pipe(Effect.result);
49
50 const seen = yield* Ref.get(keys);
51 assert.strictEqual(seen.length, 2);
52 assert.notStrictEqual(seen[0], seen[1]);
53 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
54 );
55
56 it.effect("serialises concurrent redemptions on the same account, not per caller", () =>
57 Effect.gen(function* () {
58 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
59 const release = yield* Deferred.make<void>();
60 const inFlight = yield* Ref.make(0);
61 const peak = yield* Ref.make(0);
62 const consume = () =>
63 Effect.gen(function* () {
64 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
65 yield* Ref.update(peak, (p) => Math.max(p, now));
66 yield* Deferred.await(release);
67 yield* Ref.update(inFlight, (n) => n - 1);
68 return "reset" as const;
69 });
70
71 // Two instances of the same account redeem at once.
72 const a = yield* redeem("acct", consume).pipe(Effect.forkChild);
73 const b = yield* redeem("acct", consume).pipe(Effect.forkChild);
74 yield* Effect.yieldNow;
75 yield* Deferred.succeed(release, undefined);
76 yield* Fiber.join(a);
77 yield* Fiber.join(b);
78
79 assert.strictEqual(yield* Ref.get(peak), 1);
80 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
81 );
82
83 it.effect("keeps different accounts independent", () =>
84 Effect.gen(function* () {
85 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
86 const release = yield* Deferred.make<void>();
87 const peak = yield* Ref.make(0);
88 const inFlight = yield* Ref.make(0);
89 const consume = () =>
90 Effect.gen(function* () {
91 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
92 yield* Ref.update(peak, (p) => Math.max(p, now));
93 yield* Deferred.await(release);
94 return "reset" as const;
95 });
96 const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild);
97 const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild);
98 yield* Effect.yieldNow;
99 yield* Deferred.succeed(release, undefined);
100 yield* Fiber.join(a);
101 yield* Fiber.join(b);
102 assert.strictEqual(yield* Ref.get(peak), 2);
103 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
104 );
105});
apps/server/src/provider/resetCreditCoordinator.ts created+111
......@@ -0,0 +1,111 @@
1/**
2 * Redeeming a reset credit is an account-level action: instances that share
3 * the directory holding a provider's login share the credit, so their
4 * redemptions must serialise on that directory, not the instance. This
5 * service keeps one lock and one pending idempotency key per account key so
6 * overlapping confirmations from any instance queue rather than spending two
7 * credits, and a retry after a timeout re-sends the same attempt.
8 *
9 * @module provider/resetCreditCoordinator
10 */
11import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import type * as PlatformError from "effect/PlatformError";
17import * as Ref from "effect/Ref";
18import * as Semaphore from "effect/Semaphore";
19
20interface AccountRedemptionState {
21 readonly lock: Semaphore.Semaphore;
22 readonly pendingKey: Ref.Ref<string | null>;
23}
24
25export class ResetCreditCoordinator extends Context.Service<
26 ResetCreditCoordinator,
27 {
28 /**
29 * Run `consume` under the account's lock with a stable idempotency key.
30 * The key is cleared when the provider reports an outcome, or when
31 * `isSettled` says a failure was a final answer (such as a cooldown).
32 * Any other failure (timeout included) keeps it so the next attempt is
33 * the same attempt.
34 */
35 readonly redeem: <E, R>(
36 accountKey: string,
37 consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>,
38 isSettled?: (error: E) => boolean,
39 ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>;
40 }
41>()("t3/provider/resetCreditCoordinator") {}
42
43/** @public Service construction is part of the canonical Effect module API. */
44export const make = Effect.gen(function* () {
45 const crypto = yield* Crypto.Crypto;
46 const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map());
47
48 // Get-or-create through one Ref.modify so two first redemptions for the
49 // same account cannot each install their own lock.
50 const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) {
51 const existing = (yield* Ref.get(statesRef)).get(accountKey);
52 if (existing) return existing;
53 const candidate = {
54 lock: yield* Semaphore.make(1),
55 pendingKey: yield* Ref.make<string | null>(null),
56 };
57 return yield* Ref.modify(statesRef, (states) => {
58 const current = states.get(accountKey);
59 if (current) return [current, states] as const;
60 const next = new Map(states);
61 next.set(accountKey, candidate);
62 return [candidate, next] as const;
63 });
64 });
65
66 const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) =>
67 Effect.gen(function* () {
68 const state = yield* stateFor(accountKey);
69 return yield* state.lock.withPermits(1)(
70 Effect.gen(function* () {
71 const existing = yield* Ref.get(state.pendingKey);
72 const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4);
73 yield* Ref.set(state.pendingKey, idempotencyKey);
74 const outcome = yield* consume(idempotencyKey).pipe(
75 Effect.tapError((error) =>
76 isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void,
77 ),
78 );
79 yield* Ref.set(state.pendingKey, null);
80 return outcome;
81 }),
82 );
83 });
84
85 return { redeem } satisfies ResetCreditCoordinator["Service"];
86});
87
88export const layer = Layer.effect(ResetCreditCoordinator, make);
89
90/**
91 * Self-contained for tests: a counter-backed Crypto so keys are deterministic
92 * and distinct without the platform layer.
93 */
94export const layerTest = Layer.effect(
95 ResetCreditCoordinator,
96 Effect.gen(function* () {
97 let counter = 0;
98 return yield* make.pipe(
99 Effect.provideService(
100 Crypto.Crypto,
101 Crypto.make({
102 randomBytes: (size) => {
103 counter += 1;
104 return new Uint8Array(size).fill(counter);
105 },
106 digest: (_algorithm, data) => Effect.succeed(data),
107 }),
108 ),
109 );
110 }),
111);
apps/server/src/provider/testUtils/providerRegistryMock.ts+2-2
......@@ -1,4 +1,4 @@
1import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
1import * as ProviderRegistry from "../ProviderRegistry.ts";
22import type { ServerProvider } from "@t3tools/contracts";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
......@@ -7,7 +7,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte
77
88const makeProviderRegistryMock = (
99 providers: ReadonlyArray<ServerProvider> = [],
10): ProviderRegistry.ProviderRegistryShape => ({
10): ProviderRegistry.ProviderRegistry["Service"] => ({
1111 getProviders: Effect.succeed(providers),
1212 refresh: () => Effect.succeed(providers),
1313 refreshInstance: () => Effect.succeed(providers),
apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs created+66
......@@ -0,0 +1,66 @@
1#!/usr/bin/env node
2import * as NodeFS from "node:fs";
3import * as NodeReadline from "node:readline";
4const args = process.argv.slice(2);
5const mcpConfigIndex = args.indexOf("--mcp-config");
6const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;
7let mcpConfig;
8if (rawMcpConfig) {
9 const contents = NodeFS.existsSync(rawMcpConfig)
10 ? NodeFS.readFileSync(rawMcpConfig, "utf8")
11 : rawMcpConfig;
12 try {
13 mcpConfig = JSON.parse(contents);
14 } catch {
15 mcpConfig = contents;
16 }
17}
18NodeFS.writeFileSync(
19 process.env.T3_PROBE_INVOCATION_PATH,
20 JSON.stringify({
21 args,
22 cwd: process.cwd(),
23 connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,
24 mcpConfig,
25 }),
26);
27const lines = NodeReadline.createInterface({ input: process.stdin });
28lines.on("line", (line) => {
29 const message = JSON.parse(line);
30 if (message.type !== "control_request") return;
31 const reply = (response) =>
32 process.stdout.write(
33 JSON.stringify({
34 type: "control_response",
35 response: { subtype: "success", request_id: message.request_id, response },
36 }) + "\n",
37 );
38 if (message.request?.subtype === "initialize") {
39 reply({
40 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
41 agents: [],
42 output_style: "default",
43 available_output_styles: ["default"],
44 models: [],
45 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
46 });
47 }
48 // The probe follows initialize with get_usage on the same process.
49 if (message.request?.subtype === "get_usage") {
50 reply({
51 session: {},
52 subscription_type: "pro",
53 rate_limits_available: true,
54 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
55 behaviors: null,
56 });
57 }
58});
59// Stay alive for follow-up control requests, but never outlive the
60// parent: the probe aborts the SDK without awaiting the child, so an
61// unconditional interval would strand this process until reboot.
62const keepAlive = setInterval(() => {}, 1_000);
63lines.on("close", () => {
64 clearInterval(keepAlive);
65 process.exit(0);
66});
apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs created+71
......@@ -0,0 +1,71 @@
1#!/usr/bin/env node
2import * as NodeFS from "node:fs";
3import * as NodeReadline from "node:readline";
4if (process.argv.includes("--version")) {
5 process.stdout.write("claude 2.1.219\n");
6 process.exit(0);
7}
8const lines = NodeReadline.createInterface({ input: process.stdin });
9lines.on("line", (line) => {
10 const message = JSON.parse(line);
11 if (message.type !== "control_request") return;
12 if (message.request?.subtype === "get_usage") {
13 const marker = process.env.T3_CLAUDE_RESET_MARKER;
14 if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) {
15 process.stdout.write(
16 JSON.stringify({
17 type: "control_response",
18 response: { subtype: "error", request_id: message.request_id, error: "usage failed" },
19 }) + "\n",
20 );
21 return;
22 }
23 process.stdout.write(
24 JSON.stringify({
25 type: "control_response",
26 response: {
27 subtype: "success",
28 request_id: message.request_id,
29 response: {
30 session: {},
31 subscription_type: "pro",
32 rate_limits_available: true,
33 rate_limits: {
34 five_hour: {
35 utilization: marker && NodeFS.existsSync(marker) ? 0 : 100,
36 resets_at: null,
37 },
38 },
39 },
40 },
41 }) + "\n",
42 );
43 return;
44 }
45 if (message.request?.subtype !== "initialize") return;
46 process.stdout.write(
47 JSON.stringify({
48 type: "control_response",
49 response: {
50 subtype: "success",
51 request_id: message.request_id,
52 response: {
53 commands: [],
54 agents: [],
55 models: [],
56 output_style: "default",
57 available_output_styles: ["default"],
58 account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },
59 },
60 },
61 }) + "\n",
62 );
63});
64// Stay alive for follow-up control requests, but never outlive the
65// parent: the probe aborts the SDK without awaiting the child, so an
66// unconditional interval would strand this process until reboot.
67const keepAlive = setInterval(() => {}, 1_000);
68lines.on("close", () => {
69 clearInterval(keepAlive);
70 process.exit(0);
71});
apps/server/src/pullRequest/PullRequestService.test.ts+1-1
......@@ -25,7 +25,7 @@ import * as ServerSettingsService from "../serverSettings.ts";
2525import { PullRequestOperationError } from "@t3tools/contracts";
2626
2727import * as ProjectService from "../project/ProjectService.ts";
28import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
28import * as SqlitePersistence from "../persistence/Sqlite.ts";
2929import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
3030import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
3131import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
apps/server/src/pullRequest/linkedThreads.test.ts+1-1
......@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
33import * as Schema from "effect/Schema";
44import * as SqlClient from "effect/sql/SqlClient";
55
6import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
6import * as SqlitePersistence from "../persistence/Sqlite.ts";
77import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
88
99const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts+1-1
......@@ -11,7 +11,7 @@ import * as TestClock from "effect/testing/TestClock";
1111import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
1212import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
1313import * as SecretRequests from "../secrets/SecretRequests.ts";
14import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
14import * as SqlitePersistence from "../persistence/Sqlite.ts";
1515import * as ScheduledTaskService from "./ScheduledTaskService.ts";
1616
1717const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput);
apps/server/src/scheduledTasks/ScheduledTaskService.test.ts+1-1
......@@ -18,7 +18,7 @@ import * as TestClock from "effect/testing/TestClock";
1818import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
1919import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2020import * as SecretRequests from "../secrets/SecretRequests.ts";
21import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
21import * as SqlitePersistence from "../persistence/Sqlite.ts";
2222import * as ScheduledTaskService from "./ScheduledTaskService.ts";
2323
2424const isScheduledTaskError = Schema.is(ScheduledTaskError);
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+1-1
......@@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock";
1717import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
1818import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
1919import * as SecretRequests from "../secrets/SecretRequests.ts";
20import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
20import * as SqlitePersistence from "../persistence/Sqlite.ts";
2121import * as Scheduler from "../scheduling/Scheduler.ts";
2222import * as ScheduledTaskService from "./ScheduledTaskService.ts";
2323
apps/server/src/scheduling/Scheduler.integration.test.ts+1-1
......@@ -22,7 +22,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
2222import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
2323import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2424import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts";
25import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
25import * as SqlitePersistence from "../persistence/Sqlite.ts";
2626import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
2727import * as SecretRequests from "../secrets/SecretRequests.ts";
2828import * as ServerSettings from "../serverSettings.ts";
apps/server/src/server.ts+11-12
......@@ -35,17 +35,17 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts";
3535import * as PullRequestHttp from "./pullRequest/http.ts";
3636import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";
3737import * as PullRequestService from "./pullRequest/PullRequestService.ts";
38import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts";
38import * as SqlitePersistence from "./persistence/Sqlite.ts";
3939import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts";
4040import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
4141import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
4242import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts";
4343import * as ModelManifest from "./provider/ModelManifest.ts";
44import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts";
45import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
44import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";
45import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts";
4646import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";
4747import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts";
48import * as AcpRegistryCatalog from "./provider/Layers/AcpRegistryCatalog.ts";
48import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts";
4949import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
5050import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
5151import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
......@@ -54,7 +54,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts";
5454import * as GitLabCli from "./sourceControl/GitLabCli.ts";
5555import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
5656import * as TextGeneration from "./textGeneration/TextGeneration.ts";
57import * as ProviderInstanceRegistryHydration from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
57import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
5858import * as TerminalManager from "./terminal/Manager.ts";
5959import * as McpHttpServer from "./mcp/McpHttpServer.ts";
6060import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
......@@ -70,16 +70,15 @@ import * as Keybindings from "./keybindings.ts";
7070import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
7171import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
7272import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
73import * as ProviderRegistryLayer from "./provider/Layers/ProviderRegistry.ts";
7473import * as ServerSettings from "./serverSettings.ts";
7574import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts";
7675import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
7776import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts";
7877import * as CodexInstallation from "./provider/CodexInstallation.ts";
79import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";
78import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts";
8079import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts";
81import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
82import * as ProviderUsageLimitsIngestion from "./provider/Layers/ProviderUsageLimitsIngestion.ts";
80import * as ProviderRegistry from "./provider/ProviderRegistry.ts";
81import * as ProviderUsageLimitsIngestion from "./provider/ProviderUsageLimitsIngestion.ts";
8382import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
8483import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
8584import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
......@@ -101,7 +100,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
101100import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";
102101import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
103102import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
104import * as Observability from "./observability/Layers/Observability.ts";
103import * as Observability from "./observability/Observability.ts";
105104import * as HeapSnapshot from "./observability/HeapSnapshot.ts";
106105import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts";
107106import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
......@@ -569,7 +568,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
569568 Layer.provideMerge(
570569 Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer),
571570 ),
572 Layer.provideMerge(ProviderRegistryLayer.layer),
571 Layer.provideMerge(ProviderRegistry.layer),
573572 // The instance registry is the new routing keystone — text generation,
574573 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`
575574 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;
......@@ -601,7 +600,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
601600 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer),
602601 ),
603602 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
604 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
603 // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but
605604 // the rewritten registry reads snapshots off the instance registry and
606605 // no longer transitively provides it. Exposing it at the runtime level
607606 // keeps a single Live for all opencode consumers.
apps/server/src/serverSettings.test.ts+1-1
......@@ -27,7 +27,7 @@ import * as TestClock from "effect/testing/TestClock";
2727import * as SqlClient from "effect/sql/SqlClient";
2828import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
2929import * as ServerConfig from "./config.ts";
30import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts";
30import * as SqlitePersistence from "./persistence/Sqlite.ts";
3131import { writeFileStringAtomically } from "./atomicWrite.ts";
3232import * as ServerSettingsModule from "./serverSettings.ts";
3333import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
apps/server/src/terminal/Manager.test.ts+1-1
......@@ -37,7 +37,7 @@ import { expect } from "vite-plus/test";
3737
3838import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3939import * as ServerConfig from "../config.ts";
40import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";
40import * as SqlitePersistence from "../persistence/Sqlite.ts";
4141import * as ProcessRunner from "../processRunner.ts";
4242import * as ServerSettings from "../serverSettings.ts";
4343import * as TerminalManager from "./Manager.ts";
apps/server/src/terminal/Manager.ts+1-1
......@@ -65,7 +65,7 @@ import * as ServerConfig from "../config.ts";
6565import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
6666import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
6767import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
68import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
68import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts";
6969import * as ServerSettings from "../serverSettings.ts";
7070import {
7171 increment,
apps/server/src/testUtils/shardWeights.json+4-4
......@@ -31,16 +31,16 @@
3131 "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4,
3232 "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7,
3333 "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3,
34 "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9,
35 "src/persistence/Layers/Sqlite.test.ts": 0.5,
34 "src/persistence/OrchestrationEventStore.test.ts": 1.9,
35 "src/persistence/Sqlite.test.ts": 0.5,
3636 "src/process/externalLauncher.test.ts": 2.3,
3737 "src/project/AgentSessionScanner.test.ts": 4.2,
3838 "src/project/ManagedProjectFolders.test.ts": 0.5,
3939 "src/provider/CodexChatGptAuth.test.ts": 5.4,
4040 "src/provider/Drivers/AntigravityDriver.test.ts": 2.3,
41 "src/provider/Layers/GrokProvider.test.ts": 0.9,
42 "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5,
41 "src/provider/GrokProvider.test.ts": 0.9,
4342 "src/provider/OpenCodeServerLedger.test.ts": 1.7,
43 "src/provider/ProviderInstanceRegistry.test.ts": 0.5,
4444 "src/provider/acp/AcpClientTerminals.test.ts": 1.3,
4545 "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2,
4646 "src/provider/acp/AcpRegistryProbe.test.ts": 1,
apps/server/src/testkit/RpcInitialMemory.fixture.mjs+1-1
......@@ -17,7 +17,7 @@ const [Prefix, Ws, Threads, Events] = await Promise.all([
1717 app("rpcInitialItems"),
1818 app("ws"),
1919 app("orchestration-v2/ThreadManagementService"),
20 app("persistence/Services/OrchestrationEventStore"),
20 app("persistence/OrchestrationEventStore"),
2121]);
2222const refs = [];
2323const fibers = [];
apps/server/src/textGeneration/CodexTextGeneration.ts+1-1
......@@ -20,7 +20,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
2020import { resolveAttachmentPath } from "../attachmentStore.ts";
2121import * as ServerConfig from "../config.ts";
2222import { expandHomePath } from "../pathExpansion.ts";
23import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts";
23import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts";
2424import * as TextGeneration from "./TextGeneration.ts";
2525import {
2626 buildBranchNamePrompt,
apps/server/src/textGeneration/TextGeneration.test.ts+1-1
......@@ -9,7 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts";
99import { createModelSelection } from "@t3tools/shared/model";
1010
1111import type { ProviderInstance } from "../provider/ProviderDriver.ts";
12import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
12import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
1313import * as TextGeneration from "./TextGeneration.ts";
1414import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
1515import * as Layer from "effect/Layer";
apps/server/src/textGeneration/TextGeneration.ts+1-1
......@@ -9,7 +9,7 @@ import type {
99} from "@t3tools/contracts";
1010import { TextGenerationError } from "@t3tools/contracts";
1111
12import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
12import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
1313import type { ProviderInstance } from "../provider/ProviderDriver.ts";
1414import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
1515import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
apps/server/src/usage/cliproxyApi.ts+3-3
......@@ -12,9 +12,9 @@ import * as Effect from "effect/Effect";
1212import * as Schema from "effect/Schema";
1313import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
1414
15import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts";
16import { codexRateLimitsToLimits } from "../provider/Layers/codexUsageLimits.ts";
17import { claudeUsageResponseToLimits } from "../provider/Layers/claudeUsageLimits.ts";
15import { codexPlanLabel } from "../provider/CodexProvider.ts";
16import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts";
17import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts";
1818import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts";
1919
2020const AuthFile = Schema.Struct({
apps/server/src/ws.ts+4-4
......@@ -158,21 +158,21 @@ import {
158158} from "./orchestration-v2/WireProjection.ts";
159159import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
160160import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";
161import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
161import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts";
162162import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
163163import {
164164 observeRpcEffect,
165165 observeRpcStream,
166166 observeRpcStreamEffect,
167167} from "./observability/RpcInstrumentation.ts";
168import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
169import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";
168import * as ProviderRegistry from "./provider/ProviderRegistry.ts";
169import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts";
170170import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts";
171171import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts";
172172import * as ModelManifest from "./provider/ModelManifest.ts";
173173import * as ProviderMaintenance from "./provider/providerMaintenance.ts";
174174import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
175import * as ProviderAuthService from "./provider/Services/ProviderAuthService.ts";
175import * as ProviderAuthService from "./provider/ProviderAuthService.ts";
176176import { makeProviderInstallation } from "./provider/providerInstallation.ts";
177177import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
178178import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
docs/internals/providers.md+1-1
......@@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r
5252## Setup must not happen as a health-check side effect
5353
5454Opening a provider session can start MCP servers, run hooks, or launch a login browser.
55[Grok probes](../../apps/server/src/provider/Layers/GrokProvider.ts) avoid authentication and
55[Grok probes](../../apps/server/src/provider/GrokProvider.ts) avoid authentication and
5656session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for
5757explicit setup or model refresh; background checks use initialization only.
5858
docs/operations/observability.md+1-1
......@@ -552,7 +552,7 @@ const program = doWork().pipe(
552552
553553### Runtime Wiring
554554
555The server observability layer is assembled in `apps/server/src/observability/Layers/Observability.ts`.
555The server observability layer is assembled in `apps/server/src/observability/Observability.ts`.
556556
557557It provides:
558558