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....@@ -69,4 +69,4 @@ Gradle auto-downloads any additional pinned SDK packages on demand.
69## Known flaky tests (unrelated to setup)69## Known flaky tests (unrelated to setup)
7070
71- `apps/server/src/git/GitManager.test.ts` (cross-repo PR metadata, can time out at 12s).71- `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";...@@ -35,8 +35,8 @@ import * as HttpApi from "effect/http-api/HttpApi";
35import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";35import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
36import { HttpRouter, HttpServer } from "effect/http";36import { HttpRouter, HttpServer } from "effect/http";
37import { Rpc, RpcGroup, RpcServer, RpcSerialization } from "effect/rpc";37import { Rpc, RpcGroup, RpcServer, RpcSerialization } from "effect/rpc";
38import * as SqlitePersistence from "../src/persistence/Layers/Sqlite.ts";38import * as SqlitePersistence from "../src/persistence/Sqlite.ts";
39import * as OrchestrationEventStore from "../src/persistence/Layers/OrchestrationEventStore.ts";39import * as OrchestrationEventStore from "../src/persistence/OrchestrationEventStore.ts";
40import * as EventStore from "../src/orchestration-v2/EventStore.ts";40import * as EventStore from "../src/orchestration-v2/EventStore.ts";
41import * as EventSink from "../src/orchestration-v2/EventSink.ts";41import * as EventSink from "../src/orchestration-v2/EventSink.ts";
42import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts";42import * 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* (...@@ -476,7 +476,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
476 yield* Console.log("Running migrations on the snapshot...");476 yield* Console.log("Running migrations on the snapshot...");
477 const executed = yield* Effect.gen(function* () {477 const executed = yield* Effect.gen(function* () {
478 const sql = yield* SqlClient.SqlClient;478 const sql = yield* SqlClient.SqlClient;
479 // Mirror server boot (persistence/Layers/Sqlite.ts).479 // Mirror server boot (persistence/Sqlite.ts).
480 yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared;480 yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared;
481 return yield* runMigrations();481 return yield* runMigrations();
482 }).pipe(482 }).pipe(
apps/server/scripts/record-codex-app-server-replay-fixture.ts+1-1
...@@ -2,7 +2,7 @@ import * as NodeOS from "node:os";...@@ -2,7 +2,7 @@ import * as NodeOS from "node:os";
22
3import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts";3import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts";
4import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts";4import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts";
5import { buildCodexInitializeParams } from "../src/provider/Layers/CodexProvider.ts";5import { buildCodexInitializeParams } from "../src/provider/CodexProvider.ts";
6import * as NodeRuntime from "@effect/platform-node/NodeRuntime";6import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
7import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";8import { 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";...@@ -10,7 +10,7 @@ import * as SqlClient from "effect/sql/SqlClient";
10import * as ServerConfig from "../config.ts";10import * as ServerConfig from "../config.ts";
11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
12import * as PersistenceErrors from "../persistence/Errors.ts";12import * as PersistenceErrors from "../persistence/Errors.ts";
13import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";13import * as SqlitePersistence from "../persistence/Sqlite.ts";
14import * as PairingGrantStore from "./PairingGrantStore.ts";14import * as PairingGrantStore from "./PairingGrantStore.ts";
15import * as EnvironmentAuth from "./EnvironmentAuth.ts";15import * as EnvironmentAuth from "./EnvironmentAuth.ts";
1616
apps/server/src/auth/EnvironmentAuth.ts+1-1
...@@ -38,7 +38,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts";...@@ -38,7 +38,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts";
38import * as SessionStore from "./SessionStore.ts";38import * as SessionStore from "./SessionStore.ts";
39import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";39import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
40import { verifyRequestDpopProof } from "./dpop.ts";40import { verifyRequestDpopProof } from "./dpop.ts";
41import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";41import * as SqlitePersistence from "../persistence/Sqlite.ts";
4242
43const DEFAULT_SESSION_SUBJECT = "cli-issued-session";43const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
44export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";44export 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";...@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
55
6import * as ServerConfig from "../config.ts";6import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
8import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";8import * as SqlitePersistence from "../persistence/Sqlite.ts";
9import * as EnvironmentAuth from "./EnvironmentAuth.ts";9import * as EnvironmentAuth from "./EnvironmentAuth.ts";
10import * as ServerSecretStore from "./ServerSecretStore.ts";10import * as ServerSecretStore from "./ServerSecretStore.ts";
11import * as SessionStore from "./SessionStore.ts";11import * 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";...@@ -10,7 +10,7 @@ import * as TestClock from "effect/testing/TestClock";
10import * as ServerConfig from "../config.ts";10import * as ServerConfig from "../config.ts";
11import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";11import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
12import { PersistenceSqlError } from "../persistence/Errors.ts";12import { PersistenceSqlError } from "../persistence/Errors.ts";
13import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";13import * as SqlitePersistence from "../persistence/Sqlite.ts";
14import * as PairingGrantStore from "./PairingGrantStore.ts";14import * as PairingGrantStore from "./PairingGrantStore.ts";
1515
16const layerServerConfig = (16const layerServerConfig = (
apps/server/src/auth/SessionStore.test.ts+1-1
...@@ -16,7 +16,7 @@ import * as ServerConfig from "../config.ts";...@@ -16,7 +16,7 @@ import * as ServerConfig from "../config.ts";
16import * as ServerEnvironment from "../environment/ServerEnvironment.ts";16import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
17import { PersistenceSqlError } from "../persistence/Errors.ts";17import { PersistenceSqlError } from "../persistence/Errors.ts";
1818
19import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";19import * as SqlitePersistence from "../persistence/Sqlite.ts";
20import * as AuthSessions from "../persistence/AuthSessions.ts";20import * as AuthSessions from "../persistence/AuthSessions.ts";
21import * as SessionStore from "./SessionStore.ts";21import * as SessionStore from "./SessionStore.ts";
22import * as ServerSecretStore from "./ServerSecretStore.ts";22import * 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";...@@ -15,7 +15,7 @@ import * as HttpRouter from "effect/http/HttpRouter";
1515
16import * as ServerConfig from "../config.ts";16import * as ServerConfig from "../config.ts";
17import * as ServerEnvironment from "../environment/ServerEnvironment.ts";17import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
18import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";18import * as SqlitePersistence from "../persistence/Sqlite.ts";
19import * as EnvironmentAuth from "./EnvironmentAuth.ts";19import * as EnvironmentAuth from "./EnvironmentAuth.ts";
20import * as ServerSecretStore from "./ServerSecretStore.ts";20import * as ServerSecretStore from "./ServerSecretStore.ts";
21import * as AuthHttp from "./http.ts";21import * 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";...@@ -31,7 +31,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts";
31import * as EventStore from "../orchestration-v2/EventStore.ts";31import * as EventStore from "../orchestration-v2/EventStore.ts";
32import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";32import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
33import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";33import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
34import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";34import * as SqlitePersistence from "../persistence/Sqlite.ts";
35import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";35import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
36import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";36import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
37import * as ProjectService from "../project/ProjectService.ts";37import * 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";...@@ -24,7 +24,7 @@ import * as HttpApiClient from "effect/http-api/HttpApiClient";
24import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";24import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2525
26import * as ServerConfig from "../config.ts";26import * as ServerConfig from "../config.ts";
27import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";27import * as SqlitePersistence from "../persistence/Sqlite.ts";
28import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";28import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
29import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";29import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
30import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";30import * 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...@@ -53,9 +53,9 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr
53import * as ServerConfig from "../config.ts";53import * as ServerConfig from "../config.ts";
54import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";54import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
55import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";55import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
56import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";56import * as SqlitePersistence from "../persistence/Sqlite.ts";
57import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";57import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
58import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";58import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
59import * as ServerSettings from "../serverSettings.ts";59import * as ServerSettings from "../serverSettings.ts";
60import * as GitManager from "./GitManager.ts";60import * as GitManager from "./GitManager.ts";
6161
apps/server/src/git/GitManager.ts+1-1
...@@ -66,7 +66,7 @@ import {...@@ -66,7 +66,7 @@ import {
66import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";66import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
67import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";67import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
68import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";68import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
69import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";69import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
70import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";70import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
71import { detachStackFrame } from "./detachStackFrame.ts";71import { detachStackFrame } from "./detachStackFrame.ts";
72import * as ServerSettings from "../serverSettings.ts";72import * 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";...@@ -17,7 +17,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
17import { expect, it } from "vite-plus/test";17import { expect, it } from "vite-plus/test";
1818
19import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";19import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
20import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";20import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
21import * as ProjectService from "../project/ProjectService.ts";21import * as ProjectService from "../project/ProjectService.ts";
22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
23import * as SecretRequests from "../secrets/SecretRequests.ts";23import * 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...@@ -20,7 +20,7 @@ import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts
20import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";20import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
21import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";21import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
23import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";23import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
24import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";24import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
25import * as ProjectService from "../project/ProjectService.ts";25import * as ProjectService from "../project/ProjectService.ts";
26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
apps/server/src/mcp/OrchestratorMcpService.ts+1-1
...@@ -76,7 +76,7 @@ import {...@@ -76,7 +76,7 @@ import {
76} from "../orchestration-v2/SubagentProjection.ts";76} from "../orchestration-v2/SubagentProjection.ts";
77import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";77import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
78import * as ProjectService from "../project/ProjectService.ts";78import * as ProjectService from "../project/ProjectService.ts";
79import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";79import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
80import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";80import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
81import {81import {
82 type McpInvocationScope,82 type McpInvocationScope,
apps/server/src/mcp/toolkits/core.test.ts+1-1
...@@ -22,7 +22,7 @@ import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator...@@ -22,7 +22,7 @@ import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator
22import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";22import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
23import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";23import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
24import * as ProjectService from "../../project/ProjectService.ts";24import * as ProjectService from "../../project/ProjectService.ts";
25import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts";25import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
26import * as SecretRequests from "../../secrets/SecretRequests.ts";26import * as SecretRequests from "../../secrets/SecretRequests.ts";
27import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";27import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
28import * as McpHttpServer from "../McpHttpServer.ts";28import * 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...@@ -17,7 +17,7 @@ import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdap
17import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";17import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
18import * as ProjectService from "../../../project/ProjectService.ts";18import * as ProjectService from "../../../project/ProjectService.ts";
19import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";19import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
20import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts";20import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
21import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";21import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
22import * as SecretRequests from "../../../secrets/SecretRequests.ts";22import * as SecretRequests from "../../../secrets/SecretRequests.ts";
23import * as ServerSettings from "../../../serverSettings.ts";23import * 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 {...@@ -15,7 +15,7 @@ import {
15import * as Console from "effect/Console";15import * as Console from "effect/Console";
16import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
18import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";18import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
19import { FetchHttpClient } from "effect/http";19import { FetchHttpClient } from "effect/http";
20import { describe } from "vite-plus/test";20import { describe } from "vite-plus/test";
2121
...@@ -23,13 +23,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";...@@ -23,13 +23,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
23import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";23import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
24import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";24import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
25import * as ServerConfig from "../config.ts";25import * as ServerConfig from "../config.ts";
26import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";26import * as SqlitePersistence from "../persistence/Sqlite.ts";
27import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";27import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
28import * as CodexInstallation from "../provider/CodexInstallation.ts";28import * as CodexInstallation from "../provider/CodexInstallation.ts";
29import * as ServerEnvironment from "../environment/ServerEnvironment.ts";29import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as ModelManifest from "../provider/ModelManifest.ts";30import * as ModelManifest from "../provider/ModelManifest.ts";
31import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";31import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
32import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";32import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
33import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";33import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
34import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";34import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
35import * as ServerSettings from "../serverSettings.ts";35import * 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";...@@ -31,7 +31,7 @@ import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts";
31import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts";31import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts";
32import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";32import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
33import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";33import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";
34import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";34import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
35import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";35import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
36import * as IdAllocator from "../IdAllocator.ts";36import * as IdAllocator from "../IdAllocator.ts";
37import { makeProviderFailure } from "../ProviderFailure.ts";37import { 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";...@@ -59,7 +59,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
59import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";59import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
60import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";60import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
61import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";61import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
62import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";62import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
63import {63import {
64 ProviderAdapterV2RuntimePolicy,64 ProviderAdapterV2RuntimePolicy,
65 type ProviderAdapterV2Event,65 type ProviderAdapterV2Event,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+4-4
...@@ -106,13 +106,13 @@ import {...@@ -106,13 +106,13 @@ import {
106 boundProviderEventForLogging,106 boundProviderEventForLogging,
107 type EventNdjsonLogger,107 type EventNdjsonLogger,
108 shouldPersistProviderEvent,108 shouldPersistProviderEvent,
109} from "../../provider/Layers/EventNdjsonLogger.ts";109} from "../../provider/EventNdjsonLogger.ts";
110import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";110import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
111import {111import {
112 claudeRateLimitEventToUpdate,112 claudeRateLimitEventToUpdate,
113 type ClaudeScopedLimitNames,113 type ClaudeScopedLimitNames,
114} from "../../provider/Layers/claudeUsageLimits.ts";114} from "../../provider/claudeUsageLimits.ts";
115import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts";115import type { ServerProviderShape } from "../../provider/ServerProvider.ts";
116import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";116import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
117import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";117import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";
118import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts";118import { 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";...@@ -51,8 +51,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
51import packageJson from "../../../package.json" with { type: "json" };51import packageJson from "../../../package.json" with { type: "json" };
52import * as ServerConfig from "../../config.ts";52import * as ServerConfig from "../../config.ts";
53import * as McpProviderSession from "../../mcp/McpProviderSession.ts";53import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
54import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";54import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
55import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";55import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
56import * as IdAllocator from "../IdAllocator.ts";56import * as IdAllocator from "../IdAllocator.ts";
57import * as EffectWorker from "../EffectWorker.ts";57import * as EffectWorker from "../EffectWorker.ts";
58import * as Orchestrator from "../Orchestrator.ts";58import * as Orchestrator from "../Orchestrator.ts";
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+6-9
...@@ -12,15 +12,15 @@ import {...@@ -12,15 +12,15 @@ import {
12 completeCodexTurnTokenUsage,12 completeCodexTurnTokenUsage,
13 type CodexTurnTokenUsageState,13 type CodexTurnTokenUsageState,
14} from "../../provider/CodexTurnTokenUsage.ts";14} from "../../provider/CodexTurnTokenUsage.ts";
15import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts";15import type { ServerProviderShape } from "../../provider/ServerProvider.ts";
16import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";16import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";
17import { buildCodexInitializeParams } from "../../provider/Layers/CodexProvider.ts";17import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts";
18import {18import {
19 codexRateLimitsToUpdate,19 codexRateLimitsToUpdate,
20 mergeCodexRateLimits,20 mergeCodexRateLimits,
21 codexUsageLimitResetAt,21 codexUsageLimitResetAt,
22 type CodexRateLimitSnapshot,22 type CodexRateLimitSnapshot,
23} from "../../provider/Layers/codexUsageLimits.ts";23} from "../../provider/codexUsageLimits.ts";
24import {24import {
25 CodexSettings,25 CodexSettings,
26 defaultInstanceIdForDriver,26 defaultInstanceIdForDriver,
...@@ -101,12 +101,9 @@ import {...@@ -101,12 +101,9 @@ import {
101 boundProviderEventForLogging,101 boundProviderEventForLogging,
102 type EventNdjsonLogger,102 type EventNdjsonLogger,
103 shouldPersistProviderEvent,103 shouldPersistProviderEvent,
104} from "../../provider/Layers/EventNdjsonLogger.ts";104} from "../../provider/EventNdjsonLogger.ts";
105import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";105import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts";
106import {106import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts";
107 codexAppServerArgs,
108 resolveCodexLaunchArgs,
109} from "../../provider/Layers/codexLaunchArgs.ts";
110import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";107import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
111import * as McpProviderSession from "../../mcp/McpProviderSession.ts";108import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
112import {109import {
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts+1-1
...@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";...@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import { vi } from "vite-plus/test";6import { vi } from "vite-plus/test";
77
8import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";8import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
9import * as CursorAgentSdk from "./CursorAgentSdk.ts";9import * as CursorAgentSdk from "./CursorAgentSdk.ts";
1010
11const cursorSdkMock = vi.hoisted(() => {11const cursorSdkMock = vi.hoisted(() => {
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts+2-2
...@@ -17,8 +17,8 @@ import * as Layer from "effect/Layer";...@@ -17,8 +17,8 @@ import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
1818
19import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts";19import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts";
20import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";20import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
21import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";21import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
2222
23export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;23export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;
24export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor");24export 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";...@@ -24,9 +24,9 @@ import type * as EffectAcpSchema from "effect-acp/compat";
2424
25import * as ServerConfig from "../../config.ts";25import * as ServerConfig from "../../config.ts";
26import * as ProjectStore from "../ProjectStore.ts";26import * as ProjectStore from "../ProjectStore.ts";
27import { buildInitialGrokProviderSnapshot } from "../../provider/Layers/GrokProvider.ts";27import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts";
28import type { ProviderInstance } from "../../provider/ProviderDriver.ts";28import type { ProviderInstance } from "../../provider/ProviderDriver.ts";
29import * as ProviderInstanceRegistry from "../../provider/Services/ProviderInstanceRegistry.ts";29import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
30import * as IdAllocator from "../IdAllocator.ts";30import * as IdAllocator from "../IdAllocator.ts";
31import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";31import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
32import * as RuntimePolicy from "../RuntimePolicy.ts";32import * as RuntimePolicy from "../RuntimePolicy.ts";
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+1-1
...@@ -56,7 +56,7 @@ import {...@@ -56,7 +56,7 @@ import {
56import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";56import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
57import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";57import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";
58import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";58import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
59import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";59import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
60import * as IdAllocator from "../IdAllocator.ts";60import * as IdAllocator from "../IdAllocator.ts";
61import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";61import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";
62import * as ProviderAdapter from "../ProviderAdapter.ts";62import * 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";...@@ -32,7 +32,7 @@ import * as Stream from "effect/Stream";
32import * as TestClock from "effect/testing/TestClock";32import * as TestClock from "effect/testing/TestClock";
3333
34import * as ServerConfig from "../../config.ts";34import * as ServerConfig from "../../config.ts";
35import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";35import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
36import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts";36import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts";
37import * as IdAllocator from "../IdAllocator.ts";37import * 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";...@@ -8,7 +8,7 @@ import * as Schema from "effect/Schema";
88
9import * as ServerConfig from "../../config.ts";9import * as ServerConfig from "../../config.ts";
10import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts";10import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts";
11import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";11import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
12import * as IdAllocator from "../IdAllocator.ts";12import * as IdAllocator from "../IdAllocator.ts";
13import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts";13import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts";
14import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";14import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+2-2
...@@ -58,8 +58,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";...@@ -58,8 +58,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
58import * as ServerConfig from "../../config.ts";58import * as ServerConfig from "../../config.ts";
59import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts";59import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts";
60import * as McpProviderSession from "../../mcp/McpProviderSession.ts";60import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
61import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";61import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
62import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";62import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
63import {63import {
64 structuralProtocolMethod,64 structuralProtocolMethod,
65 summarizeNativeProtocolPayload,65 summarizeNativeProtocolPayload,
apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts+1-1
...@@ -21,7 +21,7 @@ import * as Layer from "effect/Layer";...@@ -21,7 +21,7 @@ import * as Layer from "effect/Layer";
21import * as Ref from "effect/Ref";21import * as Ref from "effect/Ref";
22import * as SqlClient from "effect/sql/SqlClient";22import * as SqlClient from "effect/sql/SqlClient";
2323
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";25import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
26import { VcsProcessTimeoutError } from "@t3tools/contracts";26import { VcsProcessTimeoutError } from "@t3tools/contracts";
27import * as CheckpointService from "./CheckpointService.ts";27import * 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";...@@ -19,7 +19,7 @@ import * as IdAllocator from "./IdAllocator.ts";
19import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts";19import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts";
20import * as ProjectionStore from "./ProjectionStore.ts";20import * as ProjectionStore from "./ProjectionStore.ts";
21import * as ThreadManagement from "./ThreadManagementService.ts";21import * as ThreadManagement from "./ThreadManagementService.ts";
22import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";22import * as SqlitePersistence from "../persistence/Sqlite.ts";
23const layerProjection = Layer.mergeAll(23const layerProjection = Layer.mergeAll(
24 ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),24 ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),
25 SqlitePersistence.layerMemory,25 SqlitePersistence.layerMemory,
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+1-1
...@@ -26,7 +26,7 @@ import * as Stream from "effect/Stream";...@@ -26,7 +26,7 @@ import * as Stream from "effect/Stream";
26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";27import * as SecretRequests from "../secrets/SecretRequests.ts";
28import * as Scheduler from "../scheduling/Scheduler.ts";28import * as Scheduler from "../scheduling/Scheduler.ts";
29import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";29import * as SqlitePersistence from "../persistence/Sqlite.ts";
30import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";30import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
31import * as EffectWorker from "./EffectWorker.ts";31import * as EffectWorker from "./EffectWorker.ts";
32import * as EventSink from "./EventSink.ts";32import * as EventSink from "./EventSink.ts";
apps/server/src/orchestration-v2/CommandReceiptStore.ts+2-4
...@@ -5,9 +5,7 @@ import * as Effect from "effect/Effect";...@@ -5,9 +5,7 @@ import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
88import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts";
9import * as OrchestrationCommandReceiptsLayer from "../persistence/Layers/OrchestrationCommandReceipts.ts";
10import * as OrchestrationCommandReceipts from "../persistence/Services/OrchestrationCommandReceipts.ts";
119
12/**10/**
13 * ERRORS11 * ERRORS
...@@ -209,6 +207,6 @@ const layerBase: Layer.Layer<...@@ -209,6 +207,6 @@ const layerBase: Layer.Layer<
209 }),207 }),
210);208);
211209
212export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceiptsLayer.layer));210export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceipts.layer));
213211
214export const layerFromApplicationReceipts = layerBase;212export const layerFromApplicationReceipts = layerBase;
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+4-4
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13import * as Console from "effect/Console";13import * as Console from "effect/Console";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";16import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
17import { FetchHttpClient } from "effect/http";17import { FetchHttpClient } from "effect/http";
18import { describe } from "vite-plus/test";18import { describe } from "vite-plus/test";
1919
...@@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";...@@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
21import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";21import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
22import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";22import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
23import * as ServerConfig from "../config.ts";23import * as ServerConfig from "../config.ts";
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";25import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
26import * as CodexInstallation from "../provider/CodexInstallation.ts";26import * as CodexInstallation from "../provider/CodexInstallation.ts";
27import * as ServerEnvironment from "../environment/ServerEnvironment.ts";27import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
28import * as ModelManifest from "../provider/ModelManifest.ts";28import * as ModelManifest from "../provider/ModelManifest.ts";
29import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";29import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
30import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";30import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
31import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";31import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
32import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";32import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
33import * as ServerSettings from "../serverSettings.ts";33import * 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";...@@ -23,11 +23,11 @@ import * as Stream from "effect/Stream";
23import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";23import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
24import * as ServerConfig from "../config.ts";24import * as ServerConfig from "../config.ts";
25import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";25import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
26import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";26import * as SqlitePersistence from "../persistence/Sqlite.ts";
27import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";27import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
28import * as ProjectService from "../project/ProjectService.ts";28import * as ProjectService from "../project/ProjectService.ts";
29import type { ProviderInstance } from "../provider/ProviderDriver.ts";29import type { ProviderInstance } from "../provider/ProviderDriver.ts";
30import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";30import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
31import * as ServerSettings from "../serverSettings.ts";31import * as ServerSettings from "../serverSettings.ts";
32import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";32import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
33import * as VcsProcess from "../vcs/VcsProcess.ts";33import * 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";...@@ -23,7 +23,7 @@ import * as Stream from "effect/Stream";
23import * as SqlClient from "effect/sql/SqlClient";23import * as SqlClient from "effect/sql/SqlClient";
2424
25import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts";25import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts";
26import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts";26import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts";
27import { projectDomainEventForWire } from "./WireProjection.ts";27import { projectDomainEventForWire } from "./WireProjection.ts";
2828
29import * as CommandReceiptStore from "./CommandReceiptStore.ts";29import * as CommandReceiptStore from "./CommandReceiptStore.ts";
apps/server/src/orchestration-v2/EventStore.ts+2-4
...@@ -12,9 +12,7 @@ import * as Layer from "effect/Layer";...@@ -12,9 +12,7 @@ import * as Layer from "effect/Layer";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
14import type * as SqlClient from "effect/sql/SqlClient";14import type * as SqlClient from "effect/sql/SqlClient";
1515import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
16import * as OrchestrationEventStoreLayer from "../persistence/Layers/OrchestrationEventStore.ts";
17import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
1816
19export class EventStoreAppendEventsError extends Schema.TaggedError<EventStoreAppendEventsError>()(17export class EventStoreAppendEventsError extends Schema.TaggedError<EventStoreAppendEventsError>()(
20 "EventStoreAppendEventsError",18 "EventStoreAppendEventsError",
...@@ -150,7 +148,7 @@ const layerBase: Layer.Layer<EventStoreV2, never, OrchestrationEventStore.Orches...@@ -150,7 +148,7 @@ const layerBase: Layer.Layer<EventStoreV2, never, OrchestrationEventStore.Orches
150 );148 );
151149
152export const layer: Layer.Layer<EventStoreV2, never, SqlClient.SqlClient> = layerBase.pipe(150export const layer: Layer.Layer<EventStoreV2, never, SqlClient.SqlClient> = layerBase.pipe(
153 Layer.provide(OrchestrationEventStoreLayer.layer),151 Layer.provide(OrchestrationEventStore.layer),
154);152);
155153
156export const layerFromOrchestrationEventStore = layerBase;154export const layerFromOrchestrationEventStore = layerBase;
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+1-1
...@@ -43,7 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -43,7 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient";
43import * as Statement from "effect/sql/Statement";43import * as Statement from "effect/sql/Statement";
4444
45import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts";45import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts";
46import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";46import * as SqlitePersistence from "../persistence/Sqlite.ts";
47import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";47import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
48import * as CommandReceiptStore from "./CommandReceiptStore.ts";48import * as CommandReceiptStore from "./CommandReceiptStore.ts";
49import * as EffectOutbox from "./EffectOutbox.ts";49import * as EffectOutbox from "./EffectOutbox.ts";
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+4-4
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13import * as Console from "effect/Console";13import * as Console from "effect/Console";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";16import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
17import { FetchHttpClient } from "effect/http";17import { FetchHttpClient } from "effect/http";
18import { describe } from "vite-plus/test";18import { describe } from "vite-plus/test";
1919
...@@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";...@@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
21import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";21import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
22import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";22import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
23import * as ServerConfig from "../config.ts";23import * as ServerConfig from "../config.ts";
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";25import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
26import * as CodexInstallation from "../provider/CodexInstallation.ts";26import * as CodexInstallation from "../provider/CodexInstallation.ts";
27import * as ServerEnvironment from "../environment/ServerEnvironment.ts";27import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
28import * as ModelManifest from "../provider/ModelManifest.ts";28import * as ModelManifest from "../provider/ModelManifest.ts";
29import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";29import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
30import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";30import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
31import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";31import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
32import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";32import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
33import * as ServerSettings from "../serverSettings.ts";33import * 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";...@@ -43,18 +43,18 @@ import { FetchHttpClient, HttpClient } from "effect/http";
43import { ChildProcess, ChildProcessSpawner } from "effect/process";43import { ChildProcess, ChildProcessSpawner } from "effect/process";
44import { describe } from "vite-plus/test";44import { describe } from "vite-plus/test";
4545
46import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts";46import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts";
47import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";47import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
48import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";48import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
49import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";49import * as HostPowerMonitor from "../background/HostPowerMonitor.ts";
50import * as ServerConfig from "../config.ts";50import * as ServerConfig from "../config.ts";
51import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";51import * as SqlitePersistence from "../persistence/Sqlite.ts";
52import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";52import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
53import * as CodexInstallation from "../provider/CodexInstallation.ts";53import * as CodexInstallation from "../provider/CodexInstallation.ts";
54import * as ServerEnvironment from "../environment/ServerEnvironment.ts";54import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
55import * as ModelManifest from "../provider/ModelManifest.ts";55import * as ModelManifest from "../provider/ModelManifest.ts";
56import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts";56import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
57import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts";57import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts";
58import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts";58import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts";
59import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";59import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
60import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";60import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts";
...@@ -63,7 +63,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -63,7 +63,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
63import * as VcsProcess from "../vcs/VcsProcess.ts";63import * as VcsProcess from "../vcs/VcsProcess.ts";
64import * as EffectWorker from "./EffectWorker.ts";64import * as EffectWorker from "./EffectWorker.ts";
65import * as Orchestrator from "./Orchestrator.ts";65import * as Orchestrator from "./Orchestrator.ts";
66import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";66import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
67import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts";67import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts";
68import * as RuntimeLayer from "./runtimeLayer.ts";68import * as RuntimeLayer from "./runtimeLayer.ts";
69import * as IdAllocator from "./IdAllocator.ts";69import * 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";...@@ -21,7 +21,7 @@ import * as DateTime from "effect/DateTime";
21import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
23import * as SqlClient from "effect/sql/SqlClient";23import * as SqlClient from "effect/sql/SqlClient";
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";25import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
26import * as Orchestrator from "./Orchestrator.ts";26import * as Orchestrator from "./Orchestrator.ts";
27import * as ProjectionStore from "./ProjectionStore.ts";27import * as ProjectionStore from "./ProjectionStore.ts";
apps/server/src/orchestration-v2/ProjectCommands.ts+1-1
...@@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime";...@@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime";
13import * as Result from "effect/Result";13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
1515
16import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts";16import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts";
17import type { ProjectRow } from "./ProjectStore.ts";17import type { ProjectRow } from "./ProjectStore.ts";
1818
19export interface ProjectCreateCommand {19export interface ProjectCreateCommand {
apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts+2-2
...@@ -15,11 +15,11 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";...@@ -15,11 +15,11 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
15import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";15import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
1616
17import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";17import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
18import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";18import * as SqlitePersistence from "../persistence/Sqlite.ts";
19import { runMigrations } from "../persistence/Migrations.ts";19import { runMigrations } from "../persistence/Migrations.ts";
20import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";20import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
21import * as ProjectService from "../project/ProjectService.ts";21import * as ProjectService from "../project/ProjectService.ts";
22import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";22import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
23import * as ServerSettings from "../serverSettings.ts";23import * as ServerSettings from "../serverSettings.ts";
24import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";24import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
25import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";25import * 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";...@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as SqlClient from "effect/sql/SqlClient";6import * as SqlClient from "effect/sql/SqlClient";
77
8import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";8import * as SqlitePersistence from "../persistence/Sqlite.ts";
9import * as ProjectStore from "./ProjectStore.ts";9import * as ProjectStore from "./ProjectStore.ts";
1010
11it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))(11it.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";...@@ -20,7 +20,7 @@ import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";21import * as Option from "effect/Option";
22import * as SqlClient from "effect/sql/SqlClient";22import * as SqlClient from "effect/sql/SqlClient";
23import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";23import * as SqlitePersistence from "../persistence/Sqlite.ts";
24import * as ProjectionStore from "./ProjectionStore.ts";24import * as ProjectionStore from "./ProjectionStore.ts";
25import * as ProviderSessionManager from "./ProviderSessionManager.ts";25import * as ProviderSessionManager from "./ProviderSessionManager.ts";
26import * as ProviderTurnControlService from "./ProviderTurnControlService.ts";26import * 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";...@@ -22,7 +22,7 @@ import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
23import * as SqlClient from "effect/sql/SqlClient";23import * as SqlClient from "effect/sql/SqlClient";
2424
25import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";25import * as SqlitePersistence from "../persistence/Sqlite.ts";
26import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";26import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
27import * as EffectOutbox from "./EffectOutbox.ts";27import * as EffectOutbox from "./EffectOutbox.ts";
28import * as ProjectionStore from "./ProjectionStore.ts";28import * 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";...@@ -21,7 +21,7 @@ import * as Schema from "effect/Schema";
21import * as SqlClient from "effect/sql/SqlClient";21import * as SqlClient from "effect/sql/SqlClient";
22import * as Statement from "effect/sql/Statement";22import * as Statement from "effect/sql/Statement";
2323
24import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as ProjectionStore from "./ProjectionStore.ts";25import * as ProjectionStore from "./ProjectionStore.ts";
26import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts";26import { 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";...@@ -30,7 +30,7 @@ import * as Schema from "effect/Schema";
30import * as SqlClient from "effect/sql/SqlClient";30import * as SqlClient from "effect/sql/SqlClient";
31import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";31import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";
3232
33import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";33import * as SqlitePersistence from "../persistence/Sqlite.ts";
34import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";34import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
35import * as ProjectionStore from "./ProjectionStore.ts";35import * as ProjectionStore from "./ProjectionStore.ts";
36import {36import {
apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts+2-2
...@@ -19,9 +19,9 @@ import type * as Scope from "effect/Scope";...@@ -19,9 +19,9 @@ import type * as Scope from "effect/Scope";
19import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
2020
21import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts";21import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts";
22import type { ProviderAuthController } from "../provider/Services/ProviderAuthService.ts";22import type { ProviderAuthController } from "../provider/ProviderAuthService.ts";
23import type { ProviderInstance } from "../provider/ProviderDriver.ts";23import type { ProviderInstance } from "../provider/ProviderDriver.ts";
24import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";24import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
25import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts";25import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
26import {26import {
27 ProviderAdapterDriverCreateError,27 ProviderAdapterDriverCreateError,
apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts+1-1
...@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";...@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
14import * as Scope from "effect/Scope";14import * as Scope from "effect/Scope";
1515
16import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";16import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
17import {17import {
18 ProviderAdapterDriverCreateError,18 ProviderAdapterDriverCreateError,
19 type AnyProviderAdapterDriver,19 type AnyProviderAdapterDriver,
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+1-1
...@@ -27,7 +27,7 @@ import * as Layer from "effect/Layer";...@@ -27,7 +27,7 @@ import * as Layer from "effect/Layer";
27import * as Stream from "effect/Stream";27import * as Stream from "effect/Stream";
28import * as TestClock from "effect/testing/TestClock";28import * as TestClock from "effect/testing/TestClock";
2929
30import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as EventSink from "./EventSink.ts";31import * as EventSink from "./EventSink.ts";
32import * as EventStore from "./EventStore.ts";32import * as EventStore from "./EventStore.ts";
33import * as IdAllocator from "./IdAllocator.ts";33import * 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";...@@ -25,7 +25,7 @@ import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";25import * as Layer from "effect/Layer";
26import * as SqlClient from "effect/sql/SqlClient";26import * as SqlClient from "effect/sql/SqlClient";
2727
28import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";28import * as SqlitePersistence from "../persistence/Sqlite.ts";
29import * as ServerSettings from "../serverSettings.ts";29import * as ServerSettings from "../serverSettings.ts";
30import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";30import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
31import * as EffectOutbox from "./EffectOutbox.ts";31import * 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";...@@ -36,7 +36,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
36import * as ProjectService from "../project/ProjectService.ts";36import * as ProjectService from "../project/ProjectService.ts";
37import * as McpProviderSession from "../mcp/McpProviderSession.ts";37import * as McpProviderSession from "../mcp/McpProviderSession.ts";
38import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";38import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
39import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";39import * as SqlitePersistence from "../persistence/Sqlite.ts";
40import * as ServerSettings from "../serverSettings.ts";40import * as ServerSettings from "../serverSettings.ts";
41import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";41import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
42import * as EventSink from "./EventSink.ts";42import * 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";...@@ -26,7 +26,7 @@ import * as Schema from "effect/Schema";
2626
27import * as GitWorkflow from "../git/GitWorkflowService.ts";27import * as GitWorkflow from "../git/GitWorkflowService.ts";
28import * as ProjectService from "../project/ProjectService.ts";28import * as ProjectService from "../project/ProjectService.ts";
29import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts";29import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
30import * as ContextHandoffService from "./ContextHandoffService.ts";30import * as ContextHandoffService from "./ContextHandoffService.ts";
31import * as EventSink from "./EventSink.ts";31import * as EventSink from "./EventSink.ts";
32import * as IdAllocator from "./IdAllocator.ts";32import * as IdAllocator from "./IdAllocator.ts";
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+1-1
...@@ -23,7 +23,7 @@ import * as Schema from "effect/Schema";...@@ -23,7 +23,7 @@ import * as Schema from "effect/Schema";
2323
24import * as GitWorkflowService from "../git/GitWorkflowService.ts";24import * as GitWorkflowService from "../git/GitWorkflowService.ts";
25import * as ProjectService from "../project/ProjectService.ts";25import * as ProjectService from "../project/ProjectService.ts";
26import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts";26import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
27import * as EventSink from "./EventSink.ts";27import * as EventSink from "./EventSink.ts";
28import * as ContextHandoffService from "./ContextHandoffService.ts";28import * as ContextHandoffService from "./ContextHandoffService.ts";
29import {29import {
apps/server/src/orchestration-v2/RunCompletionReads.test.ts+1-1
...@@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime";...@@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
15import * as SqlClient from "effect/sql/SqlClient";15import * as SqlClient from "effect/sql/SqlClient";
16import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";16import * as SqlitePersistence from "../persistence/Sqlite.ts";
17import * as ProjectionStore from "./ProjectionStore.ts";17import * as ProjectionStore from "./ProjectionStore.ts";
1818
19const layerDatabase = Layer.mergeAll(19const layerDatabase = Layer.mergeAll(
apps/server/src/orchestration-v2/RuntimePolicy.test.ts+1-1
...@@ -15,7 +15,7 @@ import * as Option from "effect/Option";...@@ -15,7 +15,7 @@ import * as Option from "effect/Option";
15import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
1616
17import type { ProviderInstance } from "../provider/ProviderDriver.ts";17import type { ProviderInstance } from "../provider/ProviderDriver.ts";
18import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";18import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
19import * as ProjectStore from "./ProjectStore.ts";19import * as ProjectStore from "./ProjectStore.ts";
20import * as RuntimePolicy from "./RuntimePolicy.ts";20import * 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";...@@ -11,7 +11,7 @@ import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
1313
14import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";14import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
15import {15import {
16 ProviderAdapterV2RuntimePolicy,16 ProviderAdapterV2RuntimePolicy,
17 type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType,17 type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType,
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+1-1
...@@ -40,7 +40,7 @@ import * as Schema from "effect/Schema";...@@ -40,7 +40,7 @@ import * as Schema from "effect/Schema";
40import * as TestClock from "effect/testing/TestClock";40import * as TestClock from "effect/testing/TestClock";
4141
42import * as GitWorkflow from "../git/GitWorkflowService.ts";42import * as GitWorkflow from "../git/GitWorkflowService.ts";
43import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";43import * as SqlitePersistence from "../persistence/Sqlite.ts";
44import * as ProjectStore from "./ProjectStore.ts";44import * as ProjectStore from "./ProjectStore.ts";
45import * as ProjectService from "../project/ProjectService.ts";45import * as ProjectService from "../project/ProjectService.ts";
46import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";46import * 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";...@@ -39,7 +39,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
39import * as ProjectService from "../project/ProjectService.ts";39import * as ProjectService from "../project/ProjectService.ts";
40import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";40import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
41import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";41import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
42import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";42import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
43import * as ServerSettings from "../serverSettings.ts";43import * as ServerSettings from "../serverSettings.ts";
44import * as TextGeneration from "../textGeneration/TextGeneration.ts";44import * as TextGeneration from "../textGeneration/TextGeneration.ts";
45import * as CommandReceiptStore from "./CommandReceiptStore.ts";45import * 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";...@@ -12,7 +12,7 @@ import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as SqlClient from "effect/sql/SqlClient";13import * as SqlClient from "effect/sql/SqlClient";
1414
15import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";15import * as SqlitePersistence from "../persistence/Sqlite.ts";
16import * as ProjectionStore from "./ProjectionStore.ts";16import * as ProjectionStore from "./ProjectionStore.ts";
17import * as ProjectStore from "./ProjectStore.ts";17import * as ProjectStore from "./ProjectStore.ts";
18import * as ThreadSearch from "./ThreadSearch.ts";18import * 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";...@@ -19,7 +19,7 @@ import * as Schema from "effect/Schema";
19import * as SqlClient from "effect/sql/SqlClient";19import * as SqlClient from "effect/sql/SqlClient";
20import * as TestClock from "effect/testing/TestClock";20import * as TestClock from "effect/testing/TestClock";
2121
22import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";22import * as SqlitePersistence from "../persistence/Sqlite.ts";
23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
24import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";24import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
25import * as Orchestrator from "./Orchestrator.ts";25import * 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";...@@ -16,7 +16,7 @@ import * as TestClock from "effect/testing/TestClock";
16import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";17import * as Option from "effect/Option";
1818
19import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";19import * as SqlitePersistence from "../persistence/Sqlite.ts";
20import * as ProjectStore from "./ProjectStore.ts";20import * as ProjectStore from "./ProjectStore.ts";
21import * as ServerSettings from "../serverSettings.ts";21import * as ServerSettings from "../serverSettings.ts";
22import * as TextGeneration from "../textGeneration/TextGeneration.ts";22import * 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";...@@ -16,7 +16,7 @@ import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
18import * as SqlClient from "effect/sql/SqlClient";18import * as SqlClient from "effect/sql/SqlClient";
19import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";19import * as SqlitePersistence from "../persistence/Sqlite.ts";
20import * as ProjectionStore from "./ProjectionStore.ts";20import * as ProjectionStore from "./ProjectionStore.ts";
2121
22const layerDatabase = Layer.mergeAll(22const layerDatabase = Layer.mergeAll(
apps/server/src/orchestration-v2/http.ts+1-1
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 failEnvironmentNotFound,17 failEnvironmentNotFound,
18 requireEnvironmentScope,18 requireEnvironmentScope,
19} from "../auth/http.ts";19} from "../auth/http.ts";
20import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";20import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
21import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";21import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
22import {22import {
23 buildBoundedThreadProjection,23 buildBoundedThreadProjection,
apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts+1-1
...@@ -27,7 +27,7 @@ import * as Stream from "effect/Stream";...@@ -27,7 +27,7 @@ import * as Stream from "effect/Stream";
27import * as SqlClient from "effect/sql/SqlClient";27import * as SqlClient from "effect/sql/SqlClient";
2828
29import { runMigrations } from "../../persistence/Migrations.ts";29import { runMigrations } from "../../persistence/Migrations.ts";
30import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";30import * as SqlitePersistence from "../../persistence/Sqlite.ts";
31import Migration0042 from "../../persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts";31import Migration0042 from "../../persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts";
32import Migration0043 from "../../persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts";32import Migration0043 from "../../persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts";
33import Migration0044 from "../../persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts";33import 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";...@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";
6import * as Tracer from "effect/Tracer";6import * as Tracer from "effect/Tracer";
7import * as SqlClient from "effect/sql/SqlClient";7import * as SqlClient from "effect/sql/SqlClient";
88
9import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";9import * as SqlitePersistence from "../../persistence/Sqlite.ts";
10import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts";10import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts";
11import * as EventSink from "../EventSink.ts";11import * as EventSink from "../EventSink.ts";
12import * as EventStore from "../EventStore.ts";12import * 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";...@@ -43,13 +43,13 @@ import * as SqlClient from "effect/sql/SqlClient";
43import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";43import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
44import * as GitWorkflow from "../git/GitWorkflowService.ts";44import * as GitWorkflow from "../git/GitWorkflowService.ts";
45import * as ServerConfig from "../config.ts";45import * as ServerConfig from "../config.ts";
46import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";46import * as SqlitePersistence from "../persistence/Sqlite.ts";
47import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";47import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
48import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";48import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
49import * as ProjectService from "../project/ProjectService.ts";49import * as ProjectService from "../project/ProjectService.ts";
50import * as ServerSettings from "../serverSettings.ts";50import * as ServerSettings from "../serverSettings.ts";
51import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";51import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
52import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";52import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
53import type { ProviderInstance } from "../provider/ProviderDriver.ts";53import type { ProviderInstance } from "../provider/ProviderDriver.ts";
54import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";54import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
55import * as VcsProcess from "../vcs/VcsProcess.ts";55import * as VcsProcess from "../vcs/VcsProcess.ts";
apps/server/src/orchestration-v2/runtimeLayer.ts+3-3
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts";1import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts";
2import * as Scheduler from "../scheduling/Scheduler.ts";2import * as Scheduler from "../scheduling/Scheduler.ts";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as OrchestrationCommandReceipts from "../persistence/Layers/OrchestrationCommandReceipts.ts";4import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts";
5import * as OrchestrationEventStore from "../persistence/Layers/OrchestrationEventStore.ts";5import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
6import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";6import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";
7import * as TextGeneration from "../textGeneration/TextGeneration.ts";7import * as TextGeneration from "../textGeneration/TextGeneration.ts";
8import * as ProviderAuthService from "../provider/Layers/ProviderAuthService.ts";8import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
9import * as AgentSessionImporter from "../project/AgentSessionImporter.ts";9import * as AgentSessionImporter from "../project/AgentSessionImporter.ts";
10import * as AgentSessionScanner from "../project/AgentSessionScanner.ts";10import * as AgentSessionScanner from "../project/AgentSessionScanner.ts";
11import * as ProjectService from "../project/ProjectService.ts";11import * as ProjectService from "../project/ProjectService.ts";
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts+1-1
...@@ -21,7 +21,7 @@ import {...@@ -21,7 +21,7 @@ import {
21} from "../Adapters/CursorAdapterV2.testkit.ts";21} from "../Adapters/CursorAdapterV2.testkit.ts";
22import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts";22import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts";
23import * as IdAllocator from "../IdAllocator.ts";23import * as IdAllocator from "../IdAllocator.ts";
24import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";24import * as SqlitePersistence from "../../persistence/Sqlite.ts";
25import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";25import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
26import {26import {
27 CLAUDE_MODEL_SELECTION,27 CLAUDE_MODEL_SELECTION,
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts+1-1
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12} from "../Adapters/ClaudeAdapterV2.testkit.ts";12} from "../Adapters/ClaudeAdapterV2.testkit.ts";
13import * as IdAllocator from "../IdAllocator.ts";13import * as IdAllocator from "../IdAllocator.ts";
14import * as EffectWorker from "../EffectWorker.ts";14import * as EffectWorker from "../EffectWorker.ts";
15import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";15import * as SqlitePersistence from "../../persistence/Sqlite.ts";
16import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";16import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
17import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts";17import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts";
18import {18import {
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+2-2
...@@ -13,7 +13,7 @@ import type { SqlError } from "effect/sql/SqlError";...@@ -13,7 +13,7 @@ import type { SqlError } from "effect/sql/SqlError";
1313
14import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";14import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
15import * as ServerConfig from "../../config.ts";15import * as ServerConfig from "../../config.ts";
16import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";16import * as SqlitePersistence from "../../persistence/Sqlite.ts";
17import * as ServerSettings from "../../serverSettings.ts";17import * as ServerSettings from "../../serverSettings.ts";
18import * as ThreadManagementService from "../ThreadManagementService.ts";18import * as ThreadManagementService from "../ThreadManagementService.ts";
19import * as McpSessionRegistryTestkit from "../../mcp/McpSessionRegistry.testkit.ts";19import * as McpSessionRegistryTestkit from "../../mcp/McpSessionRegistry.testkit.ts";
...@@ -34,7 +34,7 @@ import * as Orchestrator from "../Orchestrator.ts";...@@ -34,7 +34,7 @@ import * as Orchestrator from "../Orchestrator.ts";
34import * as ProjectionStore from "../ProjectionStore.ts";34import * as ProjectionStore from "../ProjectionStore.ts";
35import * as ProjectStore from "../ProjectStore.ts";35import * as ProjectStore from "../ProjectStore.ts";
36import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";36import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
37import * as ProviderAuthService from "../../provider/Services/ProviderAuthService.ts";37import * as ProviderAuthService from "../../provider/ProviderAuthService.ts";
38import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";38import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";
39import * as ProviderContinuationService from "../ProviderContinuationService.ts";39import * as ProviderContinuationService from "../ProviderContinuationService.ts";
40import * as ProviderEventIngestor from "../ProviderEventIngestor.ts";40import * 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";...@@ -32,7 +32,7 @@ import * as Stream from "effect/Stream";
32import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
33import * as SqlClient from "effect/sql/SqlClient";33import * as SqlClient from "effect/sql/SqlClient";
3434
35import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts";35import * as SqlitePersistence from "../../persistence/Sqlite.ts";
36import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts";36import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts";
37import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts";37import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts";
38import {38import {
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...@@ -25,7 +25,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project
25 app("orchestration-v2/ContextHandoffService"),25 app("orchestration-v2/ContextHandoffService"),
26 app("git/GitWorkflowService"),26 app("git/GitWorkflowService"),
27 app("project/ProjectService"),27 app("project/ProjectService"),
28 app("provider/Services/ProviderAuthService"),28 app("provider/ProviderAuthService"),
29 ]);29 ]);
30let current;30let current;
31let fullReads = 0;31let 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";...@@ -8,7 +8,7 @@ import * as SqlClient from "effect/sql/SqlClient";
8import * as AuthPairingLinks from "./AuthPairingLinks.ts";8import * as AuthPairingLinks from "./AuthPairingLinks.ts";
9import * as AuthSessions from "./AuthSessions.ts";9import * as AuthSessions from "./AuthSessions.ts";
10import * as PersistenceErrors from "./Errors.ts";10import * as PersistenceErrors from "./Errors.ts";
11import * as SqlitePersistence from "./Layers/Sqlite.ts";11import * as SqlitePersistence from "./Sqlite.ts";
12import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts";12import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts";
1313
14const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");14const 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";...@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";
13import * as SqlClient from "effect/sql/SqlClient";13import * as SqlClient from "effect/sql/SqlClient";
1414
15import * as ServerConfig from "../config.ts";15import * as ServerConfig from "../config.ts";
16import * as SqlitePersistence from "./Layers/Sqlite.ts";16import * as SqlitePersistence from "./Sqlite.ts";
17import { runMigrations } from "./Migrations.ts";17import { runMigrations } from "./Migrations.ts";
18import { initializeV2Database } from "./initializeV2Database.ts";18import { initializeV2Database } from "./initializeV2Database.ts";
19import * as EventStore from "../orchestration-v2/EventStore.ts";19import * 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";...@@ -13,7 +13,7 @@ import * as Scope from "effect/Scope";
13import * as ServerConfig from "../config.ts";13import * as ServerConfig from "../config.ts";
14import * as GitWorkflow from "../git/GitWorkflowService.ts";14import * as GitWorkflow from "../git/GitWorkflowService.ts";
15import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";15import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
16import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";16import * as SqlitePersistence from "../persistence/Sqlite.ts";
17import * as ProcessRunner from "../processRunner.ts";17import * as ProcessRunner from "../processRunner.ts";
18import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";18import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
19import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";19import * 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";...@@ -27,7 +27,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
27import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";27import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
28import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";28import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
29import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";29import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
30import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";31import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
32import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";32import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
33import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";33import * 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";...@@ -18,7 +18,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
18import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";18import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
19import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";19import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
20import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";20import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
21import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";21import * as SqlitePersistence from "../persistence/Sqlite.ts";
22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";23import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
24import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";24import * 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 {...@@ -28,7 +28,7 @@ import {
28 forwardAntigravityCallback,28 forwardAntigravityCallback,
29 validateAntigravityCallbackUrl,29 validateAntigravityCallbackUrl,
30} from "./antigravityCallback.ts";30} from "./antigravityCallback.ts";
31import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";31import type { ProviderAuthController } from "./ProviderAuthService.ts";
3232
33const AUTH_TIMEOUT_MS = 300_000;33const AUTH_TIMEOUT_MS = 300_000;
34const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again.";34const 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";...@@ -17,7 +17,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
17import * as CodexInstallation from "./CodexInstallation.ts";17import * as CodexInstallation from "./CodexInstallation.ts";
18import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts";18import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts";
19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts";20import { codexAppServerArgs } from "./codexLaunchArgs.ts";
21import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts";21import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts";
2222
23const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));23const 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";...@@ -17,7 +17,7 @@ import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";17import * as SubscriptionRef from "effect/SubscriptionRef";
1818
19import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts";19import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts";
20import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";20import type { ProviderAuthController } from "./ProviderAuthService.ts";
2121
22const AUTH_TIMEOUT_MS = 300_000;22const 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 {...@@ -29,7 +29,7 @@ import {
29 ANTIGRAVITY_AUTH_STDOUT_PREFIX,29 ANTIGRAVITY_AUTH_STDOUT_PREFIX,
30 resolveAntigravityInstanceDirectories,30 resolveAntigravityInstanceDirectories,
31} from "../antigravityAuthSupport.ts";31} from "../antigravityAuthSupport.ts";
32import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";32import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
33import * as ModelManifest from "../ModelManifest.ts";33import * as ModelManifest from "../ModelManifest.ts";
34import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";34import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
35import { AntigravityDriver } from "./AntigravityDriver.ts";35import { AntigravityDriver } from "./AntigravityDriver.ts";
apps/server/src/provider/Drivers/AntigravityDriver.ts+2-2
...@@ -52,8 +52,8 @@ import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderCo...@@ -52,8 +52,8 @@ import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderCo
52import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts";52import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts";
53import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";53import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
54import { ProviderDriverError } from "../Errors.ts";54import { ProviderDriverError } from "../Errors.ts";
55import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts";55import { makeAntigravityProvider } from "../AntigravityProvider.ts";
56import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";56import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
57import * as ModelManifest from "../ModelManifest.ts";57import * as ModelManifest from "../ModelManifest.ts";
58import {58import {
59 defaultProviderContinuationIdentity,59 defaultProviderContinuationIdentity,
apps/server/src/provider/Drivers/ClaudeDriver.ts+5-5
...@@ -27,22 +27,22 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat...@@ -27,22 +27,22 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat
27import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";27import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
28import * as ServerConfig from "../../config.ts";28import * as ServerConfig from "../../config.ts";
29import { expandHomePath } from "../../pathExpansion.ts";29import { expandHomePath } from "../../pathExpansion.ts";
30import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";30import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
31import {31import {
32 createClaudeAdapterV2,32 createClaudeAdapterV2,
33 type ClaudeAdapterV2DriverEnv,33 type ClaudeAdapterV2DriverEnv,
34} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";34} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
35import * as ServerSettings from "../../serverSettings.ts";35import * as ServerSettings from "../../serverSettings.ts";
36import { ProviderDriverError } from "../Errors.ts";36import { ProviderDriverError } from "../Errors.ts";
37import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";37import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";
38import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts";38import * as ClaudeResetCredits from "../claudeResetCredits.ts";
39import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";39import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
40import {40import {
41 checkClaudeProviderStatus,41 checkClaudeProviderStatus,
42 makePendingClaudeProvider,42 makePendingClaudeProvider,
43 probeClaudeCapabilities,43 probeClaudeCapabilities,
44 probeClaudeWorkspaceSnapshot,44 probeClaudeWorkspaceSnapshot,
45} from "../Layers/ClaudeProvider.ts";45} from "../ClaudeProvider.ts";
46import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";46import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
47import * as ModelManifest from "../ModelManifest.ts";47import * as ModelManifest from "../ModelManifest.ts";
48import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";48import { 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";...@@ -25,8 +25,8 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";25import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
26import * as ServerConfig from "../../config.ts";26import * as ServerConfig from "../../config.ts";
27import * as ServerSettings from "../../serverSettings.ts";27import * as ServerSettings from "../../serverSettings.ts";
28import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";28import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
29import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";29import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
30import * as ModelManifest from "../ModelManifest.ts";30import * as ModelManifest from "../ModelManifest.ts";
31import {31import {
32 createProviderVersionAdvisory,32 createProviderVersionAdvisory,
apps/server/src/provider/Drivers/CodexDriver.ts+4-4
...@@ -34,21 +34,21 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio...@@ -34,21 +34,21 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio
34import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";34import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
35import * as ServerConfig from "../../config.ts";35import * as ServerConfig from "../../config.ts";
36import { expandHomePath } from "../../pathExpansion.ts";36import { expandHomePath } from "../../pathExpansion.ts";
37import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";37import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
38import {38import {
39 createCodexAdapterV2,39 createCodexAdapterV2,
40 type CodexAdapterV2DriverEnv,40 type CodexAdapterV2DriverEnv,
41} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";41} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
42import * as ServerSettings from "../../serverSettings.ts";42import * as ServerSettings from "../../serverSettings.ts";
43import { ProviderDriverError } from "../Errors.ts";43import { ProviderDriverError } from "../Errors.ts";
44import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";44import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
45import {45import {
46 checkCodexProviderStatus,46 checkCodexProviderStatus,
47 makePendingCodexProvider,47 makePendingCodexProvider,
48 probeCodexSkillsForCwd,48 probeCodexSkillsForCwd,
49 withCodexAppServerClient,49 withCodexAppServerClient,
50} from "../Layers/CodexProvider.ts";50} from "../CodexProvider.ts";
51import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";51import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts";
52import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";52import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
53import * as ModelManifest from "../ModelManifest.ts";53import * as ModelManifest from "../ModelManifest.ts";
54import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";54import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
apps/server/src/provider/Drivers/CodexManagedProvider.ts+1-1
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 checkCodexProviderStatus,13 checkCodexProviderStatus,
14 makePendingCodexProvider,14 makePendingCodexProvider,
15 probeCodexSkillsForCwd,15 probeCodexSkillsForCwd,
16} from "../Layers/CodexProvider.ts";16} from "../CodexProvider.ts";
17import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";17import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
18import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";18import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
19import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts";19import { 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";...@@ -14,7 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";14import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
15import * as ServerConfig from "../../config.ts";15import * as ServerConfig from "../../config.ts";
16import * as ServerSettings from "../../serverSettings.ts";16import * as ServerSettings from "../../serverSettings.ts";
17import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";17import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
18import { CursorDriver } from "./CursorDriver.ts";18import { CursorDriver } from "./CursorDriver.ts";
19import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts";19import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts";
20import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";20import * 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";...@@ -13,7 +13,7 @@ import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
15import { HttpClient } from "effect/http";15import { HttpClient } from "effect/http";
16import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts";16import { readCursorUsageLimits } from "../cursorUsageLimits.ts";
1717
18import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";18import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
19import * as ServerConfig from "../../config.ts";19import * as ServerConfig from "../../config.ts";
...@@ -27,8 +27,8 @@ import { ProviderDriverError } from "../Errors.ts";...@@ -27,8 +27,8 @@ import { ProviderDriverError } from "../Errors.ts";
27import {27import {
28 buildInitialCursorProviderSnapshot,28 buildInitialCursorProviderSnapshot,
29 checkCursorProviderStatus,29 checkCursorProviderStatus,
30} from "../Layers/CursorProvider.ts";30} from "../CursorProvider.ts";
31import * as CursorSdkCatalog from "../Layers/CursorSdkCatalog.ts";31import * as CursorSdkCatalog from "../CursorSdkCatalog.ts";
32import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";32import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
33import {33import {
34 defaultProviderContinuationIdentity,34 defaultProviderContinuationIdentity,
apps/server/src/provider/Drivers/GrokDriver.test.ts+1-1
...@@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";...@@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";12import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
13import * as ServerConfig from "../../config.ts";13import * as ServerConfig from "../../config.ts";
14import * as ServerSettings from "../../serverSettings.ts";14import * as ServerSettings from "../../serverSettings.ts";
15import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";15import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
16import { GrokDriver } from "./GrokDriver.ts";16import { GrokDriver } from "./GrokDriver.ts";
1717
18import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";18import * 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";...@@ -9,7 +9,7 @@ import { ChildProcessSpawner } from "effect/process";
99
10import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";10import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
11import * as ServerConfig from "../../config.ts";11import * as ServerConfig from "../../config.ts";
12import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";12import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
13import * as ServerSettings from "../../serverSettings.ts";13import * as ServerSettings from "../../serverSettings.ts";
14import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";14import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
15import {15import {
...@@ -21,8 +21,8 @@ import {...@@ -21,8 +21,8 @@ import {
21 buildInitialGrokProviderSnapshot,21 buildInitialGrokProviderSnapshot,
22 checkGrokProviderStatus,22 checkGrokProviderStatus,
23 enrichGrokSnapshot,23 enrichGrokSnapshot,
24} from "../Layers/GrokProvider.ts";24} from "../GrokProvider.ts";
25import { readGrokAccount } from "../Layers/grokUsageLimits.ts";25import { readGrokAccount } from "../grokUsageLimits.ts";
26import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";26import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
27import {27import {
28 defaultProviderContinuationIdentity,28 defaultProviderContinuationIdentity,
apps/server/src/provider/Drivers/OpenCodeDriver.test.ts+1-1
...@@ -18,7 +18,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";...@@ -18,7 +18,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
18import * as ServerConfig from "../../config.ts";18import * as ServerConfig from "../../config.ts";
19import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";19import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts";
20import * as ServerSettings from "../../serverSettings.ts";20import * as ServerSettings from "../../serverSettings.ts";
21import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts";21import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
22import * as ProviderMaintenance from "../providerMaintenance.ts";22import * as ProviderMaintenance from "../providerMaintenance.ts";
23import * as OpenCodeRuntime from "../opencodeRuntime.ts";23import * as OpenCodeRuntime from "../opencodeRuntime.ts";
24import {24import {
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-2
...@@ -34,7 +34,7 @@ import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdap...@@ -34,7 +34,7 @@ import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdap
34import * as ServerSettings from "../../serverSettings.ts";34import * as ServerSettings from "../../serverSettings.ts";
35import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";35import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";
36import { ProviderDriverError } from "../Errors.ts";36import { ProviderDriverError } from "../Errors.ts";
37import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts";37import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts";
38import {38import {
39 checkOpenCodeProviderStatus,39 checkOpenCodeProviderStatus,
40 loadOpenCode2Workspace,40 loadOpenCode2Workspace,
...@@ -44,7 +44,7 @@ import {...@@ -44,7 +44,7 @@ import {
44 openCode2SkillsToServerProviderSkills,44 openCode2SkillsToServerProviderSkills,
45 openCodeSkillsToServerProviderSkills,45 openCodeSkillsToServerProviderSkills,
46 openCodeCommandsToServerProviderSlashCommands,46 openCodeCommandsToServerProviderSlashCommands,
47} from "../Layers/OpenCodeProvider.ts";47} from "../OpenCodeProvider.ts";
48import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";48import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
49import * as OpenCodeRuntime from "../opencodeRuntime.ts";49import * as OpenCodeRuntime from "../opencodeRuntime.ts";
50import {50import {
apps/server/src/provider/Drivers/PiDriver.ts+1-1
...@@ -27,7 +27,7 @@ import {...@@ -27,7 +27,7 @@ import {
27 buildInitialPiProviderSnapshot,27 buildInitialPiProviderSnapshot,
28 checkPiProviderStatus,28 checkPiProviderStatus,
29 enrichPiSnapshot,29 enrichPiSnapshot,
30} from "../Layers/PiProvider.ts";30} from "../PiProvider.ts";
31import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";31import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
32import {32import {
33 defaultProviderContinuationIdentity,33 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";...@@ -21,7 +21,7 @@ import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";21import * as Stream from "effect/Stream";
22import * as SubscriptionRef from "effect/SubscriptionRef";22import * as SubscriptionRef from "effect/SubscriptionRef";
2323
24import type * as ProviderAuthService from "./Services/ProviderAuthService.ts";24import type * as ProviderAuthService from "./ProviderAuthService.ts";
2525
26export interface ProviderAuthFlowContext {26export interface ProviderAuthFlowContext {
27 readonly flowId: string;27 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";...@@ -40,8 +40,8 @@ import type * as Scope from "effect/Scope";
40import type { TextGeneration } from "../textGeneration/TextGeneration.ts";40import type { TextGeneration } from "../textGeneration/TextGeneration.ts";
41import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";41import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
42import type { ProviderDriverError } from "./Errors.ts";42import type { ProviderDriverError } from "./Errors.ts";
43import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";43import type { ProviderAuthController } from "./ProviderAuthService.ts";
44import type { ServerProviderShape } from "./Services/ServerProvider.ts";44import type { ServerProviderShape } from "./ServerProvider.ts";
4545
46/**46/**
47 * Static metadata advertised by a driver. Used for default presentation47 * 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";...@@ -8,7 +8,7 @@ import * as Logger from "effect/Logger";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as AcpErrors from "effect-acp/errors";9import * as AcpErrors from "effect-acp/errors";
1010
11import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";11import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
12import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts";12import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts";
1313
14const nodeServicesIt = it.layer(NodeServices.layer);14const nodeServicesIt = it.layer(NodeServices.layer);
apps/server/src/provider/acp/AcpNativeLogging.ts+1-1
...@@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime";...@@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import type * as EffectAcpProtocol from "effect-acp/protocol";7import type * as EffectAcpProtocol from "effect-acp/protocol";
88
9import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";9import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts";
10import {10import {
11 structuralProtocolMethod,11 structuralProtocolMethod,
12 summarizeNativeProtocolPayload,12 summarizeNativeProtocolPayload,
apps/server/src/provider/builtInProviderCatalog.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";1import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
2import type * as Stream from "effect/Stream";2import type * as Stream from "effect/Stream";
3import type { ServerProviderShape } from "./Services/ServerProvider.ts";3import type { ServerProviderShape } from "./ServerProvider.ts";
44
5export type ProviderSnapshotSource = {5export type ProviderSnapshotSource = {
6 /**6 /**
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";...@@ -18,7 +18,7 @@ import * as Semaphore from "effect/Semaphore";
18import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";18import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
19import * as ServerSettings from "../serverSettings.ts";19import * as ServerSettings from "../serverSettings.ts";
20import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts";20import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts";
21import type { ServerProviderShape } from "./Services/ServerProvider.ts";21import type { ServerProviderShape } from "./ServerProvider.ts";
2222
23interface ProviderSnapshotState {23interface ProviderSnapshotState {
24 readonly snapshot: ServerProvider;24 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";...@@ -10,9 +10,8 @@ import * as Ref from "effect/Ref";
10import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
11import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
12import * as ModelManifest from "./ModelManifest.ts";12import * as ModelManifest from "./ModelManifest.ts";
13import * as ProviderRegistryLayer from "./Layers/ProviderRegistry.ts";13import * as ProviderRegistry from "./ProviderRegistry.ts";
14import * as ProviderRegistry from "./Services/ProviderRegistry.ts";14import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
15import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts";
16import type { ProviderInstance } from "./ProviderDriver.ts";15import type { ProviderInstance } from "./ProviderDriver.ts";
17import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts";16import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
18import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts";17import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts";
...@@ -362,7 +361,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist...@@ -362,7 +361,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist
362 assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported");361 assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported");
363 assert.strictEqual(updated?.status, "error");362 assert.strictEqual(updated?.status, "error");
364 assert.strictEqual(updated?.message, "Authentication failed");363 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))));
366 }).pipe(Effect.scoped),365 }).pipe(Effect.scoped),
367);366);
368367
apps/server/src/provider/providerInstallation.test.ts+2-2
...@@ -17,8 +17,8 @@ import * as CodexInstallation from "./CodexInstallation.ts";...@@ -17,8 +17,8 @@ import * as CodexInstallation from "./CodexInstallation.ts";
17import * as AntigravityInstallation from "./AntigravityInstallation.ts";17import * as AntigravityInstallation from "./AntigravityInstallation.ts";
18import type { ProviderInstance } from "./ProviderDriver.ts";18import type { ProviderInstance } from "./ProviderDriver.ts";
19import { makeProviderInstallation } from "./providerInstallation.ts";19import { makeProviderInstallation } from "./providerInstallation.ts";
20import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts";20import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
21import * as ProviderRegistry from "./Services/ProviderRegistry.ts";21import * as ProviderRegistry from "./ProviderRegistry.ts";
2222
23const instanceId = ProviderInstanceId.make("antigravity");23const instanceId = ProviderInstanceId.make("antigravity");
24const driver = ProviderDriverKind.make("antigravity");24const driver = ProviderDriverKind.make("antigravity");
apps/server/src/provider/providerInstallation.ts+3-3
...@@ -15,9 +15,9 @@ import * as Stream from "effect/Stream";...@@ -15,9 +15,9 @@ import * as Stream from "effect/Stream";
15import * as CodexInstallation from "./CodexInstallation.ts";15import * as CodexInstallation from "./CodexInstallation.ts";
16import * as ServerSettings from "../serverSettings.ts";16import * as ServerSettings from "../serverSettings.ts";
17import * as AntigravityInstallation from "./AntigravityInstallation.ts";17import * as AntigravityInstallation from "./AntigravityInstallation.ts";
18import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts";18import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts";
19import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts";19import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
20import * as ProviderRegistry from "./Services/ProviderRegistry.ts";20import * as ProviderRegistry from "./ProviderRegistry.ts";
21import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";21import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
2222
23const ANTIGRAVITY = ProviderDriverKind.make("antigravity");23const ANTIGRAVITY = ProviderDriverKind.make("antigravity");
apps/server/src/provider/providerMaintenanceRunner.test.ts+3-3
...@@ -20,7 +20,7 @@ import { ChildProcessSpawner } from "effect/process";...@@ -20,7 +20,7 @@ import { ChildProcessSpawner } from "effect/process";
20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import { SpawnExecutableResolution } from "@t3tools/shared/shell";21import { SpawnExecutableResolution } from "@t3tools/shared/shell";
2222
23import * as ProviderRegistry from "./Services/ProviderRegistry.ts";23import * as ProviderRegistry from "./ProviderRegistry.ts";
24import * as ModelManifest from "./ModelManifest.ts";24import * as ModelManifest from "./ModelManifest.ts";
25import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";25import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
26import {26import {
...@@ -190,7 +190,7 @@ function makeRegistry(...@@ -190,7 +190,7 @@ function makeRegistry(
190 );190 );
191 });191 });
192192
193 const registry: ProviderRegistry.ProviderRegistryShape = {193 const registry: ProviderRegistry.ProviderRegistry["Service"] = {
194 getProviders: Ref.get(providersRef),194 getProviders: Ref.get(providersRef),
195 refresh: () => Ref.get(providersRef),195 refresh: () => Ref.get(providersRef),
196 refreshInstance: () => Ref.get(providersRef),196 refreshInstance: () => Ref.get(providersRef),
...@@ -210,7 +210,7 @@ function makeRegistry(...@@ -210,7 +210,7 @@ function makeRegistry(
210}210}
211211
212const makeTestRunner = (212const makeTestRunner = (
213 registry: ProviderRegistry.ProviderRegistryShape,213 registry: ProviderRegistry.ProviderRegistry["Service"],
214 // Generic updater fixtures use synthetic versions. Keep their compatibility214 // Generic updater fixtures use synthetic versions. Keep their compatibility
215 // unknown so real harness minimums do not bypass the command under test.215 // unknown so real harness minimums do not bypass the command under test.
216 manifest: ModelManifest.ModelManifestData = {216 manifest: ModelManifest.ModelManifestData = {
apps/server/src/provider/providerMaintenanceRunner.ts+1-1
...@@ -23,7 +23,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";...@@ -23,7 +23,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
2323
24import * as ModelManifest from "./ModelManifest.ts";24import * as ModelManifest from "./ModelManifest.ts";
25import { resolveProviderCompatibility } from "./providerCompatibility.ts";25import { resolveProviderCompatibility } from "./providerCompatibility.ts";
26import * as ProviderRegistry from "./Services/ProviderRegistry.ts";26import * as ProviderRegistry from "./ProviderRegistry.ts";
27import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";27import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
28import {28import {
29 enrichProviderSnapshotWithVersionAdvisory,29 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 @@...@@ -1,4 +1,4 @@
1import * as ProviderRegistry from "../Services/ProviderRegistry.ts";1import * as ProviderRegistry from "../ProviderRegistry.ts";
2import type { ServerProvider } from "@t3tools/contracts";2import type { ServerProvider } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -7,7 +7,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte...@@ -7,7 +7,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte
77
8const makeProviderRegistryMock = (8const makeProviderRegistryMock = (
9 providers: ReadonlyArray<ServerProvider> = [],9 providers: ReadonlyArray<ServerProvider> = [],
10): ProviderRegistry.ProviderRegistryShape => ({10): ProviderRegistry.ProviderRegistry["Service"] => ({
11 getProviders: Effect.succeed(providers),11 getProviders: Effect.succeed(providers),
12 refresh: () => Effect.succeed(providers),12 refresh: () => Effect.succeed(providers),
13 refreshInstance: () => Effect.succeed(providers),13 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";...@@ -25,7 +25,7 @@ import * as ServerSettingsService from "../serverSettings.ts";
25import { PullRequestOperationError } from "@t3tools/contracts";25import { PullRequestOperationError } from "@t3tools/contracts";
2626
27import * as ProjectService from "../project/ProjectService.ts";27import * as ProjectService from "../project/ProjectService.ts";
28import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";28import * as SqlitePersistence from "../persistence/Sqlite.ts";
29import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";29import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
30import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";30import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
31import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";31import * 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";...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SqlClient from "effect/sql/SqlClient";4import * as SqlClient from "effect/sql/SqlClient";
55
6import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";6import * as SqlitePersistence from "../persistence/Sqlite.ts";
7import { listLinkedPullRequestThreads } from "./linkedThreads.ts";7import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
88
9const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));9const 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";...@@ -11,7 +11,7 @@ import * as TestClock from "effect/testing/TestClock";
11import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";11import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
12import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";12import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
13import * as SecretRequests from "../secrets/SecretRequests.ts";13import * as SecretRequests from "../secrets/SecretRequests.ts";
14import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";14import * as SqlitePersistence from "../persistence/Sqlite.ts";
15import * as ScheduledTaskService from "./ScheduledTaskService.ts";15import * as ScheduledTaskService from "./ScheduledTaskService.ts";
1616
17const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput);17const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput);
apps/server/src/scheduledTasks/ScheduledTaskService.test.ts+1-1
...@@ -18,7 +18,7 @@ import * as TestClock from "effect/testing/TestClock";...@@ -18,7 +18,7 @@ import * as TestClock from "effect/testing/TestClock";
18import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";18import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
19import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";19import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
20import * as SecretRequests from "../secrets/SecretRequests.ts";20import * as SecretRequests from "../secrets/SecretRequests.ts";
21import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";21import * as SqlitePersistence from "../persistence/Sqlite.ts";
22import * as ScheduledTaskService from "./ScheduledTaskService.ts";22import * as ScheduledTaskService from "./ScheduledTaskService.ts";
2323
24const isScheduledTaskError = Schema.is(ScheduledTaskError);24const 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";...@@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock";
17import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";17import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
18import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";18import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
19import * as SecretRequests from "../secrets/SecretRequests.ts";19import * as SecretRequests from "../secrets/SecretRequests.ts";
20import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";20import * as SqlitePersistence from "../persistence/Sqlite.ts";
21import * as Scheduler from "../scheduling/Scheduler.ts";21import * as Scheduler from "../scheduling/Scheduler.ts";
22import * as ScheduledTaskService from "./ScheduledTaskService.ts";22import * 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";...@@ -22,7 +22,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
22import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";22import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
23import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";23import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
24import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts";24import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts";
25import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";25import * as SqlitePersistence from "../persistence/Sqlite.ts";
26import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";27import * as SecretRequests from "../secrets/SecretRequests.ts";
28import * as ServerSettings from "../serverSettings.ts";28import * as ServerSettings from "../serverSettings.ts";
apps/server/src/server.ts+11-12
...@@ -35,17 +35,17 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts";...@@ -35,17 +35,17 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts";
35import * as PullRequestHttp from "./pullRequest/http.ts";35import * as PullRequestHttp from "./pullRequest/http.ts";
36import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";36import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";
37import * as PullRequestService from "./pullRequest/PullRequestService.ts";37import * as PullRequestService from "./pullRequest/PullRequestService.ts";
38import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts";38import * as SqlitePersistence from "./persistence/Sqlite.ts";
39import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts";39import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts";
40import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";40import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
41import * as AnalyticsService from "./telemetry/AnalyticsService.ts";41import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
42import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts";42import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts";
43import * as ModelManifest from "./provider/ModelManifest.ts";43import * as ModelManifest from "./provider/ModelManifest.ts";
44import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts";44import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";
45import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";45import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts";
46import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";46import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";
47import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts";47import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts";
48import * as AcpRegistryCatalog from "./provider/Layers/AcpRegistryCatalog.ts";48import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts";
49import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";49import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
51import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";51import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
...@@ -54,7 +54,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts";...@@ -54,7 +54,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts";
54import * as GitLabCli from "./sourceControl/GitLabCli.ts";54import * as GitLabCli from "./sourceControl/GitLabCli.ts";
55import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";55import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
56import * as TextGeneration from "./textGeneration/TextGeneration.ts";56import * as TextGeneration from "./textGeneration/TextGeneration.ts";
57import * as ProviderInstanceRegistryHydration from "./provider/Layers/ProviderInstanceRegistryHydration.ts";57import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
58import * as TerminalManager from "./terminal/Manager.ts";58import * as TerminalManager from "./terminal/Manager.ts";
59import * as McpHttpServer from "./mcp/McpHttpServer.ts";59import * as McpHttpServer from "./mcp/McpHttpServer.ts";
60import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";60import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
...@@ -70,16 +70,15 @@ import * as Keybindings from "./keybindings.ts";...@@ -70,16 +70,15 @@ import * as Keybindings from "./keybindings.ts";
70import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";70import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
71import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";71import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
72import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";72import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
73import * as ProviderRegistryLayer from "./provider/Layers/ProviderRegistry.ts";
74import * as ServerSettings from "./serverSettings.ts";73import * as ServerSettings from "./serverSettings.ts";
75import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts";74import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts";
76import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";75import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
77import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts";76import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts";
78import * as CodexInstallation from "./provider/CodexInstallation.ts";77import * as CodexInstallation from "./provider/CodexInstallation.ts";
79import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";78import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts";
80import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts";79import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts";
81import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";80import * as ProviderRegistry from "./provider/ProviderRegistry.ts";
82import * as ProviderUsageLimitsIngestion from "./provider/Layers/ProviderUsageLimitsIngestion.ts";81import * as ProviderUsageLimitsIngestion from "./provider/ProviderUsageLimitsIngestion.ts";
83import * as UsageLimitSources from "./usage/UsageLimitSources.ts";82import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
84import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";83import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
85import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";84import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
...@@ -101,7 +100,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";...@@ -101,7 +100,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
101import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";100import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";
102import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";101import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
103import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";102import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
104import * as Observability from "./observability/Layers/Observability.ts";103import * as Observability from "./observability/Observability.ts";
105import * as HeapSnapshot from "./observability/HeapSnapshot.ts";104import * as HeapSnapshot from "./observability/HeapSnapshot.ts";
106import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts";105import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts";
107import * as ServerEnvironment from "./environment/ServerEnvironment.ts";106import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
...@@ -569,7 +568,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -569,7 +568,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
569 Layer.provideMerge(568 Layer.provideMerge(
570 Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer),569 Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer),
571 ),570 ),
572 Layer.provideMerge(ProviderRegistryLayer.layer),571 Layer.provideMerge(ProviderRegistry.layer),
573 // The instance registry is the new routing keystone — text generation,572 // The instance registry is the new routing keystone — text generation,
574 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`573 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`
575 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;574 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;
...@@ -601,7 +600,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(...@@ -601,7 +600,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
601 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer),600 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer),
602 ),601 ),
603 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old602 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
604 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but603 // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but
605 // the rewritten registry reads snapshots off the instance registry and604 // the rewritten registry reads snapshots off the instance registry and
606 // no longer transitively provides it. Exposing it at the runtime level605 // no longer transitively provides it. Exposing it at the runtime level
607 // keeps a single Live for all opencode consumers.606 // 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";...@@ -27,7 +27,7 @@ import * as TestClock from "effect/testing/TestClock";
27import * as SqlClient from "effect/sql/SqlClient";27import * as SqlClient from "effect/sql/SqlClient";
28import * as ServerSecretStore from "./auth/ServerSecretStore.ts";28import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
29import * as ServerConfig from "./config.ts";29import * as ServerConfig from "./config.ts";
30import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts";30import * as SqlitePersistence from "./persistence/Sqlite.ts";
31import { writeFileStringAtomically } from "./atomicWrite.ts";31import { writeFileStringAtomically } from "./atomicWrite.ts";
32import * as ServerSettingsModule from "./serverSettings.ts";32import * as ServerSettingsModule from "./serverSettings.ts";
33import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";33import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
apps/server/src/terminal/Manager.test.ts+1-1
...@@ -37,7 +37,7 @@ import { expect } from "vite-plus/test";...@@ -37,7 +37,7 @@ import { expect } from "vite-plus/test";
3737
38import * as ServerSecretStore from "../auth/ServerSecretStore.ts";38import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
39import * as ServerConfig from "../config.ts";39import * as ServerConfig from "../config.ts";
40import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts";40import * as SqlitePersistence from "../persistence/Sqlite.ts";
41import * as ProcessRunner from "../processRunner.ts";41import * as ProcessRunner from "../processRunner.ts";
42import * as ServerSettings from "../serverSettings.ts";42import * as ServerSettings from "../serverSettings.ts";
43import * as TerminalManager from "./Manager.ts";43import * as TerminalManager from "./Manager.ts";
apps/server/src/terminal/Manager.ts+1-1
...@@ -65,7 +65,7 @@ import * as ServerConfig from "../config.ts";...@@ -65,7 +65,7 @@ import * as ServerConfig from "../config.ts";
65import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";65import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
66import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";66import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
67import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";67import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
68import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts";68import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts";
69import * as ServerSettings from "../serverSettings.ts";69import * as ServerSettings from "../serverSettings.ts";
70import {70import {
71 increment,71 increment,
apps/server/src/testUtils/shardWeights.json+4-4
...@@ -31,16 +31,16 @@...@@ -31,16 +31,16 @@
31 "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4,31 "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4,
32 "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7,32 "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7,
33 "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3,33 "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3,
34 "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9,34 "src/persistence/OrchestrationEventStore.test.ts": 1.9,
35 "src/persistence/Layers/Sqlite.test.ts": 0.5,35 "src/persistence/Sqlite.test.ts": 0.5,
36 "src/process/externalLauncher.test.ts": 2.3,36 "src/process/externalLauncher.test.ts": 2.3,
37 "src/project/AgentSessionScanner.test.ts": 4.2,37 "src/project/AgentSessionScanner.test.ts": 4.2,
38 "src/project/ManagedProjectFolders.test.ts": 0.5,38 "src/project/ManagedProjectFolders.test.ts": 0.5,
39 "src/provider/CodexChatGptAuth.test.ts": 5.4,39 "src/provider/CodexChatGptAuth.test.ts": 5.4,
40 "src/provider/Drivers/AntigravityDriver.test.ts": 2.3,40 "src/provider/Drivers/AntigravityDriver.test.ts": 2.3,
41 "src/provider/Layers/GrokProvider.test.ts": 0.9,41 "src/provider/GrokProvider.test.ts": 0.9,
42 "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5,
43 "src/provider/OpenCodeServerLedger.test.ts": 1.7,42 "src/provider/OpenCodeServerLedger.test.ts": 1.7,
43 "src/provider/ProviderInstanceRegistry.test.ts": 0.5,
44 "src/provider/acp/AcpClientTerminals.test.ts": 1.3,44 "src/provider/acp/AcpClientTerminals.test.ts": 1.3,
45 "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2,45 "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2,
46 "src/provider/acp/AcpRegistryProbe.test.ts": 1,46 "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([...@@ -17,7 +17,7 @@ const [Prefix, Ws, Threads, Events] = await Promise.all([
17 app("rpcInitialItems"),17 app("rpcInitialItems"),
18 app("ws"),18 app("ws"),
19 app("orchestration-v2/ThreadManagementService"),19 app("orchestration-v2/ThreadManagementService"),
20 app("persistence/Services/OrchestrationEventStore"),20 app("persistence/OrchestrationEventStore"),
21]);21]);
22const refs = [];22const refs = [];
23const fibers = [];23const fibers = [];
apps/server/src/textGeneration/CodexTextGeneration.ts+1-1
...@@ -20,7 +20,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";...@@ -20,7 +20,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
20import { resolveAttachmentPath } from "../attachmentStore.ts";20import { resolveAttachmentPath } from "../attachmentStore.ts";
21import * as ServerConfig from "../config.ts";21import * as ServerConfig from "../config.ts";
22import { expandHomePath } from "../pathExpansion.ts";22import { expandHomePath } from "../pathExpansion.ts";
23import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts";23import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts";
24import * as TextGeneration from "./TextGeneration.ts";24import * as TextGeneration from "./TextGeneration.ts";
25import {25import {
26 buildBranchNamePrompt,26 buildBranchNamePrompt,
apps/server/src/textGeneration/TextGeneration.test.ts+1-1
...@@ -9,7 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts";...@@ -9,7 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts";
9import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
1010
11import type { ProviderInstance } from "../provider/ProviderDriver.ts";11import type { ProviderInstance } from "../provider/ProviderDriver.ts";
12import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";12import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
13import * as TextGeneration from "./TextGeneration.ts";13import * as TextGeneration from "./TextGeneration.ts";
14import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";14import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
apps/server/src/textGeneration/TextGeneration.ts+1-1
...@@ -9,7 +9,7 @@ import type {...@@ -9,7 +9,7 @@ import type {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { TextGenerationError } from "@t3tools/contracts";10import { TextGenerationError } from "@t3tools/contracts";
1111
12import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";12import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
13import type { ProviderInstance } from "../provider/ProviderDriver.ts";13import type { ProviderInstance } from "../provider/ProviderDriver.ts";
14import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";14import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
15import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";15import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
apps/server/src/usage/cliproxyApi.ts+3-3
...@@ -12,9 +12,9 @@ import * as Effect from "effect/Effect";...@@ -12,9 +12,9 @@ import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";13import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
1414
15import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts";15import { codexPlanLabel } from "../provider/CodexProvider.ts";
16import { codexRateLimitsToLimits } from "../provider/Layers/codexUsageLimits.ts";16import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts";
17import { claudeUsageResponseToLimits } from "../provider/Layers/claudeUsageLimits.ts";17import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts";
18import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts";18import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts";
1919
20const AuthFile = Schema.Struct({20const AuthFile = Schema.Struct({
apps/server/src/ws.ts+4-4
...@@ -158,21 +158,21 @@ import {...@@ -158,21 +158,21 @@ import {
158} from "./orchestration-v2/WireProjection.ts";158} from "./orchestration-v2/WireProjection.ts";
159import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";159import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
160import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";160import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";
161import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";161import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts";
162import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";162import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
163import {163import {
164 observeRpcEffect,164 observeRpcEffect,
165 observeRpcStream,165 observeRpcStream,
166 observeRpcStreamEffect,166 observeRpcStreamEffect,
167} from "./observability/RpcInstrumentation.ts";167} from "./observability/RpcInstrumentation.ts";
168import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";168import * as ProviderRegistry from "./provider/ProviderRegistry.ts";
169import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts";169import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts";
170import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts";170import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts";
171import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts";171import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts";
172import * as ModelManifest from "./provider/ModelManifest.ts";172import * as ModelManifest from "./provider/ModelManifest.ts";
173import * as ProviderMaintenance from "./provider/providerMaintenance.ts";173import * as ProviderMaintenance from "./provider/providerMaintenance.ts";
174import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";174import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
175import * as ProviderAuthService from "./provider/Services/ProviderAuthService.ts";175import * as ProviderAuthService from "./provider/ProviderAuthService.ts";
176import { makeProviderInstallation } from "./provider/providerInstallation.ts";176import { makeProviderInstallation } from "./provider/providerInstallation.ts";
177import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";177import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
178import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";178import * 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...@@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r
52## Setup must not happen as a health-check side effect52## Setup must not happen as a health-check side effect
5353
54Opening a provider session can start MCP servers, run hooks, or launch a login browser.54Opening 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 and55[Grok probes](../../apps/server/src/provider/GrokProvider.ts) avoid authentication and
56session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for56session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for
57explicit setup or model refresh; background checks use initialization only.57explicit setup or model refresh; background checks use initialization only.
5858
docs/operations/observability.md+1-1
...@@ -552,7 +552,7 @@ const program = doWork().pipe(...@@ -552,7 +552,7 @@ const program = doWork().pipe(
552552
553### Runtime Wiring553### 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
557It provides:557It provides:
558558