History

Add Codex provider health status to server config and chat banner

- add startup ProviderHealth service/layer to probe Codex install and auth state - extend server contracts and WS payloads to include provider statuses - show non-ready provider warnings in ChatView and cover with server/web tests

Julius Marminge committed 8 months ago

Commit 99e44f21 parent 7a0f2d99 files · +499 −4Browse files

Unified · Split

9 files
  1. apps/server/src/main.ts +2 −0
  2. apps/server/src/provider/Layers/ProviderHealth.test.ts +107 −0
  3. apps/server/src/provider/Layers/ProviderHealth.ts +246 −0
  4. apps/server/src/provider/Services/ProviderHealth.ts +22 −0
  5. apps/server/src/wsServer.test.ts +31 −1
  6. apps/server/src/wsServer.ts +11 −2
  7. apps/web/src/components/ChatView.tsx +38 −0
  8. apps/web/src/wsNativeApi.test.ts +15 −0
  9. packages/contracts/src/server.ts +27 −1
apps/server/src/main.ts+2−0
@@ -21,6 +21,7 @@ import { fixPath, resolveStateDir } from "./os-jank"; · 20 unmodified lines
2121 import { Open } from "./open";
2222 import * as SqlitePersistence from "./persistence/Layers/Sqlite";
2323 import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24+import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
2425 import { Server } from "./wsServer";
2526
2627 export class StartupError extends Data.TaggedError("StartupError")<{
@@ -197,6 +198,7 @@ const LayerLive = (input: CliInput) => · 170 unmodified lines
197198 Layer.empty.pipe(
198199 Layer.provideMerge(makeServerRuntimeServicesLayer()),
199200 Layer.provideMerge(makeServerProviderLayer()),
201+ Layer.provideMerge(ProviderHealthLive),
200202 Layer.provideMerge(SqlitePersistence.layerConfig),
201203 Layer.provideMerge(ServerConfigLive(input)),
202204 );
apps/server/src/provider/Layers/ProviderHealth.test.ts+107−0
@@ -0,0 +1,107 @@
1+import { describe, expect, it } from "vitest";
2+
3+import type { ProcessRunResult } from "../../processRunner";
4+import { checkProviderStatusesOnStartup } from "./ProviderHealth";
5+
6+function result(input: Partial<ProcessRunResult>): ProcessRunResult {
7+ return {
8+ stdout: "",
9+ stderr: "",
10+ code: 0,
11+ signal: null,
12+ timedOut: false,
13+ stdoutTruncated: false,
14+ stderrTruncated: false,
15+ ...input,
16+ };
17+}
18+
19+describe("checkProviderStatusesOnStartup", () => {
20+ it("returns ready when codex is installed and authenticated", async () => {
21+ const status = await checkProviderStatusesOnStartup(async (_command, args) => {
22+ if (args.join(" ") === "--version") {
23+ return result({ stdout: "codex 1.0.0\n" });
24+ }
25+ if (args.join(" ") === "auth status --json") {
26+ return result({ stdout: '{"authenticated":true}\n' });
27+ }
28+ throw new Error(`Unexpected command: ${args.join(" ")}`);
29+ });
30+
31+ expect(status).toEqual([
32+ expect.objectContaining({
33+ provider: "codex",
34+ status: "ready",
35+ available: true,
36+ authStatus: "authenticated",
37+ }),
38+ ]);
39+ });
40+
41+ it("returns unavailable when codex is missing", async () => {
42+ const status = await checkProviderStatusesOnStartup(async () => {
43+ throw new Error("Command not found: codex");
44+ });
45+
46+ expect(status).toEqual([
47+ expect.objectContaining({
48+ provider: "codex",
49+ status: "error",
50+ available: false,
51+ authStatus: "unknown",
52+ message: "Codex CLI (`codex`) is not installed or not on PATH.",
53+ }),
54+ ]);
55+ });
56+
57+ it("returns unauthenticated when auth probe reports login required", async () => {
58+ const status = await checkProviderStatusesOnStartup(async (_command, args) => {
59+ if (args.join(" ") === "--version") {
60+ return result({ stdout: "codex 1.0.0\n" });
61+ }
62+ if (args.join(" ") === "auth status --json") {
63+ return result({
64+ code: 1,
65+ stderr: "Not logged in. Run codex login.",
66+ });
67+ }
68+ throw new Error(`Unexpected command: ${args.join(" ")}`);
69+ });
70+
71+ expect(status).toEqual([
72+ expect.objectContaining({
73+ provider: "codex",
74+ status: "error",
75+ available: true,
76+ authStatus: "unauthenticated",
77+ message: "Codex CLI is not authenticated. Run `codex login` and try again.",
78+ }),
79+ ]);
80+ });
81+
82+ it("returns warning when auth status command is unsupported", async () => {
83+ const status = await checkProviderStatusesOnStartup(async (_command, args) => {
84+ if (args.join(" ") === "--version") {
85+ return result({ stdout: "codex 1.0.0\n" });
86+ }
87+ if (args.join(" ") === "auth status --json") {
88+ return result({
89+ code: 2,
90+ stderr: "error: unknown command 'auth'",
91+ });
92+ }
93+ throw new Error(`Unexpected command: ${args.join(" ")}`);
94+ });
95+
96+ expect(status).toEqual([
97+ expect.objectContaining({
98+ provider: "codex",
99+ status: "warning",
100+ available: true,
101+ authStatus: "unknown",
102+ message:
103+ "Codex CLI authentication status command is unavailable in this Codex version.",
104+ }),
105+ ]);
106+ });
107+});
apps/server/src/provider/Layers/ProviderHealth.ts+246−0
@@ -0,0 +1,246 @@
1+/**
2+ * ProviderHealthLive - Startup-time provider health checks.
3+ *
4+ * Performs one-time provider readiness probes when the server starts and
5+ * keeps the resulting snapshot in memory for `server.getConfig`.
6+ *
7+ * @module ProviderHealthLive
8+ */
9+import type {
10+ ServerProviderAuthStatus,
11+ ServerProviderStatus,
12+ ServerProviderStatusState,
13+} from "@t3tools/contracts";
14+import { Effect, Layer } from "effect";
15+
16+import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner";
17+import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
18+
19+type CommandRunner = (
20+ command: string,
21+ args: readonly string[],
22+ options?: ProcessRunOptions,
23+) => Promise<ProcessRunResult>;
24+
25+const DEFAULT_TIMEOUT_MS = 4_000;
26+const CODEX_PROVIDER = "codex" as const;
27+
28+function nonEmptyMessage(value: string | undefined): string | undefined {
29+ if (!value) return undefined;
30+ const trimmed = value.trim();
31+ return trimmed.length > 0 ? trimmed : undefined;
32+}
33+
34+function isCommandMissingError(error: unknown): boolean {
35+ if (!(error instanceof Error)) return false;
36+ const lower = error.message.toLowerCase();
37+ return (
38+ lower.includes("command not found: codex") ||
39+ lower.includes("spawn codex enoent") ||
40+ lower.includes("enoent")
41+ );
42+}
43+
44+function detailFromResult(result: ProcessRunResult): string | undefined {
45+ if (result.timedOut) return "Timed out while running command.";
46+ const stderr = nonEmptyMessage(result.stderr);
47+ if (stderr) return stderr;
48+ const stdout = nonEmptyMessage(result.stdout);
49+ if (stdout) return stdout;
50+ if (result.code !== null && result.code !== 0) {
51+ return `Command exited with code ${result.code}.`;
52+ }
53+ return undefined;
54+}
55+
56+function extractAuthBoolean(value: unknown): boolean | undefined {
57+ if (!value || typeof value !== "object" || Array.isArray(value)) {
58+ return undefined;
59+ }
60+
61+ const record = value as Record<string, unknown>;
62+ const directCandidates = [
63+ "authenticated",
64+ "isAuthenticated",
65+ "loggedIn",
66+ "isLoggedIn",
67+ ] as const;
68+ for (const key of directCandidates) {
69+ const candidate = record[key];
70+ if (typeof candidate === "boolean") {
71+ return candidate;
72+ }
73+ }
74+
75+ const nestedCandidates = ["auth", "status", "session", "account"] as const;
76+ for (const key of nestedCandidates) {
77+ const nested = extractAuthBoolean(record[key]);
78+ if (nested !== undefined) {
79+ return nested;
80+ }
81+ }
82+
83+ return undefined;
84+}
85+
86+function parseAuthStatusFromOutput(
87+ result: ProcessRunResult,
88+): {
89+ readonly status: ServerProviderStatusState;
90+ readonly authStatus: ServerProviderAuthStatus;
91+ readonly message?: string;
92+} {
93+ const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
94+ const unknownCommand =
95+ lowerOutput.includes("unknown command") ||
96+ lowerOutput.includes("unrecognized command") ||
97+ lowerOutput.includes("unexpected argument");
98+ if (unknownCommand) {
99+ return {
100+ status: "warning",
101+ authStatus: "unknown",
102+ message:
103+ "Codex CLI authentication status command is unavailable in this Codex version.",
104+ };
105+ }
106+
107+ const unauthenticatedSignal =
108+ lowerOutput.includes("not logged in") ||
109+ lowerOutput.includes("login required") ||
110+ lowerOutput.includes("authentication required") ||
111+ lowerOutput.includes("run `codex login`") ||
112+ lowerOutput.includes("run codex login");
113+ if (unauthenticatedSignal) {
114+ return {
115+ status: "error",
116+ authStatus: "unauthenticated",
117+ message: "Codex CLI is not authenticated. Run `codex login` and try again.",
118+ };
119+ }
120+
121+ const parsedAuth =
122+ (() => {
123+ const trimmed = result.stdout.trim();
124+ if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
125+ return undefined;
126+ }
127+ try {
128+ return extractAuthBoolean(JSON.parse(trimmed));
129+ } catch {
130+ return undefined;
131+ }
132+ })() ?? undefined;
133+
134+ if (parsedAuth === true) {
135+ return { status: "ready", authStatus: "authenticated" };
136+ }
137+ if (parsedAuth === false) {
138+ return {
139+ status: "error",
140+ authStatus: "unauthenticated",
141+ message: "Codex CLI is not authenticated. Run `codex login` and try again.",
142+ };
143+ }
144+
145+ if (result.code === 0) {
146+ // Successful command with no explicit auth marker is still a pass.
147+ return { status: "ready", authStatus: "authenticated" };
148+ }
149+
150+ const detail = detailFromResult(result);
151+ return {
152+ status: "warning",
153+ authStatus: "unknown",
154+ ...(detail
155+ ? { message: `Could not verify Codex authentication status. ${detail}` }
156+ : { message: "Could not verify Codex authentication status." }),
157+ };
158+}
159+
160+async function checkCodexProviderStatus(run: CommandRunner): Promise<ServerProviderStatus> {
161+ const checkedAt = new Date().toISOString();
162+
163+ try {
164+ const version = await run("codex", ["--version"], {
165+ timeoutMs: DEFAULT_TIMEOUT_MS,
166+ allowNonZeroExit: true,
167+ outputMode: "truncate",
168+ });
169+
170+ if (version.code !== 0 || version.timedOut) {
171+ const detail = detailFromResult(version);
172+ return {
173+ provider: CODEX_PROVIDER,
174+ status: "error",
175+ available: false,
176+ authStatus: "unknown",
177+ checkedAt,
178+ message:
179+ detail !== undefined
180+ ? `Codex CLI is installed but failed to run. ${detail}`
181+ : "Codex CLI is installed but failed to run.",
182+ };
183+ }
184+ } catch (error) {
185+ return {
186+ provider: CODEX_PROVIDER,
187+ status: "error",
188+ available: false,
189+ authStatus: "unknown",
190+ checkedAt,
191+ message: isCommandMissingError(error)
192+ ? "Codex CLI (`codex`) is not installed or not on PATH."
193+ : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
194+ };
195+ }
196+
197+ try {
198+ const auth = await run("codex", ["auth", "status", "--json"], {
199+ timeoutMs: DEFAULT_TIMEOUT_MS,
200+ allowNonZeroExit: true,
201+ outputMode: "truncate",
202+ });
203+ const parsed = parseAuthStatusFromOutput(auth);
204+ return {
205+ provider: CODEX_PROVIDER,
206+ status: parsed.status,
207+ available: true,
208+ authStatus: parsed.authStatus,
209+ checkedAt,
210+ ...(parsed.message ? { message: parsed.message } : {}),
211+ };
212+ } catch (error) {
213+ return {
214+ provider: CODEX_PROVIDER,
215+ status: "warning",
216+ available: true,
217+ authStatus: "unknown",
218+ checkedAt,
219+ message:
220+ error instanceof Error
221+ ? `Could not verify Codex authentication status: ${error.message}.`
222+ : "Could not verify Codex authentication status.",
223+ };
224+ }
225+}
226+
227+export async function checkProviderStatusesOnStartup(
228+ run: CommandRunner = runProcess,
229+): Promise<ReadonlyArray<ServerProviderStatus>> {
230+ return [await checkCodexProviderStatus(run)];
231+}
232+
233+const makeProviderHealth = (run: CommandRunner = runProcess) =>
234+ Effect.gen(function* () {
235+ const statuses = yield* Effect.promise(() => checkProviderStatusesOnStartup(run));
236+
237+ return {
238+ getStatuses: Effect.succeed(statuses),
239+ } satisfies ProviderHealthShape;
240+ });
241+
242+export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth());
243+
244+export function makeProviderHealthLive(run?: CommandRunner) {
245+ return Layer.effect(ProviderHealth, makeProviderHealth(run));
246+}
apps/server/src/provider/Services/ProviderHealth.ts+22−0
@@ -0,0 +1,22 @@
1+/**
2+ * ProviderHealth - Provider readiness snapshot service.
3+ *
4+ * Owns startup-time provider health checks (install/auth reachability) and
5+ * exposes the cached results to transport layers.
6+ *
7+ * @module ProviderHealth
8+ */
9+import type { ServerProviderStatus } from "@t3tools/contracts";
10+import { ServiceMap } from "effect";
11+import type { Effect } from "effect";
12+
13+export interface ProviderHealthShape {
14+ /**
15+ * Read provider health statuses computed at server startup.
16+ */
17+ readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;
18+}
19+
20+export class ProviderHealth extends ServiceMap.Service<ProviderHealth, ProviderHealthShape>()(
21+ "t3/provider/Services/ProviderHealth",
22+) {}
apps/server/src/wsServer.test.ts+31−1
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 WS_METHODS,
2626 type WebSocketResponse,
2727 type ProviderRuntimeEvent,
28+ type ServerProviderStatus,
2829 type KeybindingsConfig,
2930 type ResolvedKeybindingsConfig,
3031 type WsPush,
@@ -43,6 +44,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ · 12 unmodified lines
4344 import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
4445 import { SqlClient, SqlError } from "effect/unstable/sql";
4546 import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
47+import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth";
4648 import { Open, type OpenShape } from "./open";
4749 import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
4850 import type { GitCoreShape } from "./git/Services/GitCore.ts";
@@ -69,6 +71,20 @@ const defaultOpenService: OpenShape = { · 20 unmodified lines
6971 openInEditor: () => Effect.void,
7072 };
7173
74+const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [
75+ {
76+ provider: "codex",
77+ status: "ready",
78+ available: true,
79+ authStatus: "authenticated",
80+ checkedAt: "2026-01-01T00:00:00.000Z",
81+ },
82+];
83+
84+const defaultProviderHealthService: ProviderHealthShape = {
85+ getStatuses: Effect.succeed(defaultProviderStatuses),
86+};
87+
7288 class MockTerminalManager implements TerminalManagerShape {
7389 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
7490 private readonly listeners = new Set<(event: TerminalEvent) => void>();
@@ -377,6 +393,7 @@ describe("WebSocket Server", () => { · 302 unmodified lines
377393 stateDir?: string;
378394 staticDir?: string;
379395 providerLayer?: Layer.Layer<ProviderService, never>;
396+ providerHealth?: ProviderHealthShape;
380397 open?: OpenShape;
381398 gitManager?: GitManagerShape;
382399 gitCore?: Pick<
@@ -394,6 +411,10 @@ describe("WebSocket Server", () => { · 11 unmodified lines
394411 const scope = await Effect.runPromise(Scope.make("sequential"));
395412 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
396413 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
414+ const providerHealthLayer = Layer.succeed(
415+ ProviderHealth,
416+ options.providerHealth ?? defaultProviderHealthService,
417+ );
397418 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
398419 const serverConfigLayer = Layer.succeed(ServerConfig, {
399420 mode: "web",
@@ -428,6 +449,7 @@ describe("WebSocket Server", () => { · 28 unmodified lines
428449 );
429450 const dependenciesLayer = Layer.empty.pipe(
430451 Layer.provideMerge(runtimeLayer),
452+ Layer.provideMerge(providerHealthLayer),
431453 Layer.provideMerge(openLayer),
432454 Layer.provideMerge(serverConfigLayer),
433455 Layer.provideMerge(NodeServices.layer),
@@ -736,6 +758,7 @@ describe("WebSocket Server", () => { · 302 unmodified lines
736758 keybindingsConfigPath: keybindingsPath,
737759 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
738760 issues: [],
761+ providers: defaultProviderStatuses,
739762 availableEditors: expect.any(Array),
740763 });
741764 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
@@ -761,6 +784,7 @@ describe("WebSocket Server", () => { · 19 unmodified lines
761784 keybindingsConfigPath: keybindingsPath,
762785 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
763786 issues: [],
787+ providers: defaultProviderStatuses,
764788 availableEditors: expect.any(Array),
765789 });
766790 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
@@ -796,6 +820,7 @@ describe("WebSocket Server", () => { · 29 unmodified lines
796820 message: expect.stringContaining("expected JSON array"),
797821 },
798822 ],
823+ providers: defaultProviderStatuses,
799824 availableEditors: expect.any(Array),
800825 });
801826 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
@@ -830,6 +855,7 @@ describe("WebSocket Server", () => { · 28 unmodified lines
830855 keybindingsConfigPath: string;
831856 keybindings: ResolvedKeybindingsConfig;
832857 issues: Array<{ kind: string; index?: number; message: string }>;
858+ providers: ReadonlyArray<ServerProviderStatus>;
833859 availableEditors: unknown;
834860 };
835861 expect(result.cwd).toBe("/my/workspace");
@@ -849,6 +875,7 @@ describe("WebSocket Server", () => { · 13 unmodified lines
849875 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
850876 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
851877 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
878+ expect(result.providers).toEqual(defaultProviderStatuses);
852879 expectAvailableEditors(result.availableEditors);
853880 });
854881
@@ -877,6 +904,7 @@ describe("WebSocket Server", () => { · 22 unmodified lines
877904 );
878905 expect(malformedPush.data).toEqual({
879906 issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
907+ providers: defaultProviderStatuses,
880908 });
881909
882910 fs.writeFileSync(keybindingsPath, "[]", "utf8");
@@ -887,7 +915,7 @@ describe("WebSocket Server", () => { · 4 unmodified lines
887915 Array.isArray((push.data as { issues?: unknown[] }).issues) &&
888916 (push.data as { issues: unknown[] }).issues.length === 0,
889917 );
890− expect(successPush.data).toEqual({ issues: [] });
918+ expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses });
891919 });
892920
893921 it("routes shell.openInEditor through the injected open service", async () => {
@@ -947,6 +975,7 @@ describe("WebSocket Server", () => { · 53 unmodified lines
947975 keybindingsConfigPath: keybindingsPath,
948976 keybindings: compileKeybindings(persistedConfig),
949977 issues: [],
978+ providers: defaultProviderStatuses,
950979 availableEditors: expect.any(Array),
951980 });
952981 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
@@ -994,6 +1023,7 @@ describe("WebSocket Server", () => { · 41 unmodified lines
9941023 keybindingsConfigPath: keybindingsPath,
9951024 keybindings: compileKeybindings(persistedConfig),
9961025 issues: [],
1026+ providers: defaultProviderStatuses,
9971027 availableEditors: expect.any(Array),
9981028 });
9991029 expectAvailableEditors(
apps/server/src/wsServer.ts+11−2
@@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati · 51 unmodified lines
5252 import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
5353 import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
5454 import { ProviderService } from "./provider/Services/ProviderService";
55+import { ProviderHealth } from "./provider/Services/ProviderHealth";
5556 import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
5657 import { clamp } from "effect/Number";
5758 import { Open, resolveAvailableEditors } from "./open";
@@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices = · 96 unmodified lines
154155 | ProjectionSnapshotQuery
155156 | CheckpointDiffQuery
156157 | OrchestrationReactor
157− | ProviderService;
158+ | ProviderService
159+ | ProviderHealth;
158160
159161 export type ServerRuntimeServices =
160162 | ServerCoreRuntimeServices
@@ -198,6 +200,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 37 unmodified lines
198200 const gitManager = yield* GitManager;
199201 const terminalManager = yield* TerminalManager;
200202 const keybindingsManager = yield* Keybindings;
203+ const providerHealth = yield* ProviderHealth;
201204 const git = yield* GitCore;
202205 const fileSystem = yield* FileSystem.FileSystem;
203206 const path = yield* Path.Path;
@@ -212,6 +215,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 8 unmodified lines
212215 ),
213216 );
214217
218+ const providerStatuses = yield* providerHealth.getStatuses;
219+
215220 const clients = yield* Ref.make(new Set<WebSocket>());
216221 const logger = createLogger("ws");
217222
@@ -537,7 +542,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 319 unmodified lines
537542 broadcastPush({
538543 type: "push",
539544 channel: WS_CHANNELS.serverConfigUpdated,
540− data: event,
545+ data: {
546+ issues: event.issues,
547+ providers: providerStatuses,
548+ },
541549 }),
542550 ).pipe(Effect.forkIn(subscriptionsScope));
543551
@@ -735,6 +743,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 191 unmodified lines
735743 keybindingsConfigPath,
736744 keybindings: keybindingsConfig.keybindings,
737745 issues: keybindingsConfig.issues,
746+ providers: providerStatuses,
738747 availableEditors,
739748 };
740749
apps/web/src/components/ChatView.tsx+38−0
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 type ReasoningEffort,
1616 type ResolvedKeybindingsConfig,
1717 type ProviderApprovalDecision,
18+ type ServerProviderStatus,
1819 type ThreadId,
1920 type TurnId,
2021 normalizeModelSlug,
@@ -148,6 +149,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; · 127 unmodified lines
148149 const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
149150 const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
150151 const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
152+const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
151153 const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
152154 const SCRIPT_TERMINAL_COLS = 120;
153155 const SCRIPT_TERMINAL_ROWS = 30;
@@ -936,6 +938,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 782 unmodified lines
936938 );
937939 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
938940 const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
941+ const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
942+ const activeProvider = activeThread?.session?.provider ?? "codex";
943+ const activeProviderStatus = useMemo(
944+ () => providerStatuses.find((status) => status.provider === activeProvider) ?? null,
945+ [activeProvider, providerStatuses],
946+ );
939947 const activeProjectCwd = activeProject?.cwd ?? null;
940948 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
941949 const threadTerminalRuntimeEnv = useMemo(() => {
@@ -2497,6 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1555 unmodified lines
24972505 </header>
24982506
24992507 {/* Error banner */}
2508+ <ProviderHealthBanner status={activeProviderStatus} />
25002509 <ThreadErrorBanner error={activeThread.error} />
25012510 <PendingApprovalsPanel
25022511 pendingApprovals={pendingApprovals}
@@ -2997,6 +3006,35 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st · 494 unmodified lines
29973006 );
29983007 });
29993008
3009+const ProviderHealthBanner = memo(function ProviderHealthBanner({
3010+ status,
3011+}: {
3012+ status: ServerProviderStatus | null;
3013+}) {
3014+ if (!status || status.status === "ready") {
3015+ return null;
3016+ }
3017+
3018+ const defaultMessage =
3019+ status.status === "error"
3020+ ? `${status.provider} provider is unavailable.`
3021+ : `${status.provider} provider has limited availability.`;
3022+
3023+ return (
3024+ <div className="pt-3 mx-auto max-w-3xl">
3025+ <Alert variant={status.status === "error" ? "error" : "warning"}>
3026+ <CircleAlertIcon />
3027+ <AlertTitle>
3028+ {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`}
3029+ </AlertTitle>
3030+ <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}>
3031+ {status.message ?? defaultMessage}
3032+ </AlertDescription>
3033+ </Alert>
3034+ </div>
3035+ );
3036+});
3037+
30003038 interface PendingApprovalsPanelProps {
30013039 pendingApprovals: PendingApproval[];
30023040 respondingRequestIds: ApprovalRequestId[];
apps/web/src/wsNativeApi.test.ts+15−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 ProjectId,
77 ThreadId,
88 WS_CHANNELS,
9+ type ServerProviderStatus,
910 } from "@t3tools/contracts";
1011 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1112
@@ -62,6 +63,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn · 50 unmodified lines
6263 return testGlobal.window;
6364 }
6465
66+const defaultProviders: ReadonlyArray<ServerProviderStatus> = [
67+ {
68+ provider: "codex",
69+ status: "ready",
70+ available: true,
71+ authStatus: "authenticated",
72+ checkedAt: "2026-01-01T00:00:00.000Z",
73+ },
74+];
75+
6576 beforeEach(() => {
6677 vi.resetModules();
6778 requestMock.mockReset();
@@ -162,6 +173,7 @@ describe("wsNativeApi", () => { · 94 unmodified lines
162173 message: "Entry at index 1 is invalid.",
163174 },
164175 ],
176+ providers: defaultProviders,
165177 } as const;
166178 emitPush(WS_CHANNELS.serverConfigUpdated, payload);
167179
@@ -185,14 +197,17 @@ describe("wsNativeApi", () => { · 17 unmodified lines
185197
186198 emitPush(WS_CHANNELS.serverConfigUpdated, {
187199 issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }],
200+ providers: defaultProviders,
188201 });
189202 emitPush(WS_CHANNELS.serverConfigUpdated, {
190203 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
204+ providers: defaultProviders,
191205 });
192206
193207 expect(listener).toHaveBeenCalledTimes(1);
194208 expect(listener).toHaveBeenCalledWith({
195209 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
210+ providers: defaultProviders,
196211 });
197212 expect(warnSpy).toHaveBeenCalledTimes(1);
198213 });
packages/contracts/src/server.ts+27−1
@@ -1,7 +1,8 @@
11 import { Schema } from "effect";
2−import { TrimmedNonEmptyString } from "./baseSchemas";
2+import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
33 import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
44 import { EditorId } from "./editor";
5+import { ProviderKind } from "./orchestration";
56
67 export const KeybindingsMalformedConfigIssue = Schema.Struct({
78 kind: Schema.Literal("keybindings.malformed-config"),
@@ -25,11 +26,35 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; · 17 unmodified lines
2526 export const ServerConfigIssues = Schema.Array(ServerConfigIssue);
2627 export type ServerConfigIssues = typeof ServerConfigIssues.Type;
2728
29+export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]);
30+export type ServerProviderStatusState = typeof ServerProviderStatusState.Type;
31+
32+export const ServerProviderAuthStatus = Schema.Literals([
33+ "authenticated",
34+ "unauthenticated",
35+ "unknown",
36+]);
37+export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;
38+
39+export const ServerProviderStatus = Schema.Struct({
40+ provider: ProviderKind,
41+ status: ServerProviderStatusState,
42+ available: Schema.Boolean,
43+ authStatus: ServerProviderAuthStatus,
44+ checkedAt: IsoDateTime,
45+ message: Schema.optional(TrimmedNonEmptyString),
46+});
47+export type ServerProviderStatus = typeof ServerProviderStatus.Type;
48+
49+export const ServerProviderStatuses = Schema.Array(ServerProviderStatus);
50+export type ServerProviderStatuses = typeof ServerProviderStatuses.Type;
51+
2852 export const ServerConfig = Schema.Struct({
2953 cwd: TrimmedNonEmptyString,
3054 keybindingsConfigPath: TrimmedNonEmptyString,
3155 keybindings: ResolvedKeybindingsConfig,
3256 issues: ServerConfigIssues,
57+ providers: ServerProviderStatuses,
3358 availableEditors: Schema.Array(EditorId),
3459 });
3560 export type ServerConfig = typeof ServerConfig.Type;
@@ -45,5 +70,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T · 9 unmodified lines
4570
4671 export const ServerConfigUpdatedPayload = Schema.Struct({
4772 issues: ServerConfigIssues,
73+ providers: ServerProviderStatuses,
4874 });
4975 export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t