Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions apps/server/src/provider/Layers/ProviderHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
ServerProviderStatus,
ServerProviderStatusState,
} from "@t3tools/contracts";
import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Ref, Result, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import {
Expand Down Expand Up @@ -592,12 +592,25 @@ export const checkClaudeProviderStatus: Effect.Effect<
export const ProviderHealthLive = Layer.effect(
ProviderHealth,
Effect.gen(function* () {
const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const runProviderChecks = Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], {
concurrency: "unbounded",
}).pipe(Effect.forkScoped);
}).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const statusesFiber = yield* runProviderChecks.pipe(Effect.forkScoped);
const initialStatuses = yield* Fiber.join(statusesFiber);
const statusesRef = yield* Ref.make(initialStatuses);

return {
getStatuses: Fiber.join(statusesFiber),
getStatuses: Ref.get(statusesRef),
refreshStatuses: Effect.flatMap(runProviderChecks, (statuses) =>
Ref.set(statusesRef, statuses).pipe(Effect.as(statuses)),
),
} satisfies ProviderHealthShape;
}),
);
5 changes: 5 additions & 0 deletions apps/server/src/provider/Services/ProviderHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface ProviderHealthShape {
* Read the latest provider health statuses.
*/
readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;

/**
* Re-run provider health probes and update the cached snapshot.
*/
readonly refreshStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;
}

export class ProviderHealth extends ServiceMap.Service<ProviderHealth, ProviderHealthShape>()(
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [

const defaultProviderHealthService: ProviderHealthShape = {
getStatuses: Effect.succeed(defaultProviderStatuses),
refreshStatuses: Effect.succeed(defaultProviderStatuses),
};

class MockTerminalManager implements TerminalManagerShape {
Expand Down Expand Up @@ -1111,6 +1112,38 @@ describe("WebSocket Server", () => {
);
});

it("refreshes provider statuses on demand", async () => {
const refreshedProviders: ReadonlyArray<ServerProviderStatus> = [
{
provider: "codex",
status: "warning",
available: true,
authStatus: "unknown",
checkedAt: "2026-01-02T00:00:00.000Z",
message: "Could not verify Codex authentication status.",
},
];

server = await createTestServer({
cwd: "/my/workspace",
providerHealth: {
getStatuses: Effect.succeed(defaultProviderStatuses),
refreshStatuses: Effect.succeed(refreshedProviders),
},
});
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.serverRefreshProviderStatuses);
expect(response.error).toBeUndefined();
expect(response.result).toEqual({
providers: refreshedProviders,
});
});

it("returns error for unknown methods", async () => {
server = await createTestServer({ cwd: "/test" });
const addr = server.address();
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
);

const providerStatuses = yield* providerHealth.getStatuses;

const clients = yield* Ref.make(new Set<WebSocket>());
const logger = createLogger("ws");
const readiness = yield* makeServerReadiness;
Expand Down Expand Up @@ -877,6 +876,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
availableEditors,
};

case WS_METHODS.serverRefreshProviderStatuses: {
const providers = yield* providerHealth.refreshStatuses;
return { providers };
}

case WS_METHODS.serverUpsertKeybinding: {
const body = stripRequestTag(request.body);
const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { describe, expect, it } from "vitest";
import {
AppSettingsSchema,
DEFAULT_TIMESTAMP_FORMAT,
getEnabledProviderOptions,
getAppModelOptions,
getCustomModelOptionsByProvider,
getCustomModelsByProvider,
getCustomModelsForProvider,
getDefaultCustomModelsForProvider,
isProviderEnabled,
MODEL_PROVIDER_SETTINGS,
normalizeCustomModelSlugs,
patchProviderEnabled,
patchCustomModels,
resolveAppModelSelection,
} from "./appSettings";
Expand Down Expand Up @@ -197,6 +200,56 @@ describe("provider-indexed custom model settings", () => {
});
});

describe("provider enablement", () => {
it("defaults providers to enabled when decoding older persisted settings", () => {
const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));

expect(
decode(
JSON.stringify({
codexBinaryPath: "/usr/local/bin/codex",
}),
).enabledProviders,
).toEqual({
codex: true,
claudeAgent: true,
});
});

it("reads enabled providers", () => {
const settings = {
enabledProviders: {
codex: true,
claudeAgent: false,
},
} as const;

expect(isProviderEnabled(settings, "codex")).toBe(true);
expect(isProviderEnabled(settings, "claudeAgent")).toBe(false);
expect(getEnabledProviderOptions(settings)).toEqual(["codex"]);
});

it("patches provider enabled state without resetting other providers", () => {
expect(
patchProviderEnabled(
{
enabledProviders: {
codex: true,
claudeAgent: true,
},
},
"claudeAgent",
false,
),
).toEqual({
enabledProviders: {
codex: true,
claudeAgent: false,
},
});
});
});

describe("AppSettingsSchema", () => {
it("fills decoding defaults for persisted settings that predate newer keys", () => {
const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"])
export type TimestampFormat = typeof TimestampFormat.Type;
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
type EnabledProvidersSettings = {
[provider in ProviderKind]: boolean;
};
export type ProviderCustomModelConfig = {
provider: ProviderKind;
settingsKey: CustomModelSettingsKey;
Expand Down Expand Up @@ -47,6 +50,10 @@ const withDefaults =
);

export const AppSettingsSchema = Schema.Struct({
enabledProviders: Schema.Struct({
codex: Schema.Boolean,
claudeAgent: Schema.Boolean,
}).pipe(withDefaults(() => ({ codex: true, claudeAgent: true }))),
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
Expand Down Expand Up @@ -116,6 +123,34 @@ export function normalizeCustomModelSlugs(
return normalizedModels;
}

export function isProviderEnabled(
settings: Pick<AppSettings, "enabledProviders">,
provider: ProviderKind,
): boolean {
return settings.enabledProviders[provider] ?? true;
}

export function getEnabledProviderOptions(
settings: Pick<AppSettings, "enabledProviders">,
): ProviderKind[] {
return (Object.entries(settings.enabledProviders) as Array<[ProviderKind, boolean]>)
.filter(([, enabled]) => enabled)
.map(([provider]) => provider);
}

export function patchProviderEnabled(
settings: Pick<AppSettings, "enabledProviders">,
provider: ProviderKind,
enabled: boolean,
): { enabledProviders: EnabledProvidersSettings } {
return {
enabledProviders: {
...settings.enabledProviders,
[provider]: enabled,
},
};
}

function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
Expand Down
Loading